From 17e2bf7a698bb18032c5843e650c02e965eae79a Mon Sep 17 00:00:00 2001 From: Joshua Potter Date: Fri, 31 Dec 2021 20:12:21 -0500 Subject: [PATCH] Setup local and remote with deserialization rules. --- Cargo.toml | 1 + examples/config.yaml | 4 +- src/cli.rs | 147 +++++++++++++++++++++------- src/config.rs | 16 +--- src/daemon.rs | 4 +- src/git.rs | 4 +- src/lib.rs | 16 ++-- src/main.rs | 2 +- src/path.rs | 221 +++++++++++++++++++++++++++++++++---------- 9 files changed, 305 insertions(+), 110 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9504256..39f67d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,3 +20,4 @@ serde = "1.0" serde_derive = "1.0.132" serde_yaml = "0.8" yaml-rust = "0.4.4" +url = { version = "2.2.2", features = ["serde"] } diff --git a/examples/config.yaml b/examples/config.yaml index 547e913..466e849 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -1,7 +1,5 @@ --- -remote: - owner: jrpotter - name: home-config +remote: https://github.com/jrpotter/home-config.git packages: homesync: configs: diff --git a/src/cli.rs b/src/cli.rs index 6f15453..1986100 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,42 +1,125 @@ -use super::config; use super::config::PathConfig; -use ansi_term::Colour::Green as Success; -use ansi_term::Colour::Yellow as Warning; -use std::io; +use super::path::ResPathBuf; +use super::{config, path}; +use ansi_term::Colour::{Green, Yellow}; +use std::env::VarError; use std::io::Write; +use std::path::PathBuf; +use std::{error, fmt, fs, io}; +use url::{ParseError, Url}; + +// ======================================== +// Error +// ======================================== + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum Error { + ConfigError(config::Error), + IOError(io::Error), + ParseError(ParseError), + VarError(VarError), +} + +impl From for Error { + fn from(err: config::Error) -> Error { + Error::ConfigError(err) + } +} + +impl From for Error { + fn from(err: io::Error) -> Error { + Error::IOError(err) + } +} + +impl From for Error { + fn from(err: path::Error) -> Error { + match err { + path::Error::IOError(e) => Error::IOError(e), + path::Error::VarError(e) => Error::VarError(e), + } + } +} + +impl From for Error { + fn from(err: ParseError) -> Error { + Error::ParseError(err) + } +} + +impl From for Error { + fn from(err: VarError) -> Error { + Error::VarError(err) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::ConfigError(e) => write!(f, "{}", e), + Error::IOError(e) => write!(f, "{}", e), + Error::ParseError(e) => write!(f, "{}", e), + Error::VarError(e) => write!(f, "{}", e), + } + } +} + +impl error::Error for Error {} + +// ======================================== +// Prompts +// ======================================== // TODO(jrpotter): Use curses to make this module behave nicer. -pub fn write_config(mut pending: PathConfig) -> config::Result<()> { +fn prompt_local(config: &PathConfig) -> Result { + print!( + "Local git repository <{}> (enter to continue): ", + Yellow.paint( + config + .1 + .local + .as_ref() + .map_or("".to_owned(), |v| v.display().to_string()) + ) + ); + io::stdout().flush()?; + + let mut local = String::new(); + io::stdin().read_line(&mut local)?; + let expanded = PathBuf::from(path::expand_env(&local.trim())?); + // We need to generate the directory beforehand to verify the path is + // actually valid. Worst case this leaves empty directories scattered in + // various locations after repeated initialization. + fs::create_dir_all(&expanded)?; + // Hard resolution should succeed now that the above directory was created. + Ok(path::resolve(&expanded)?) +} + +fn prompt_remote(config: &PathConfig) -> Result { + print!( + "Remote git repository <{}> (enter to continue): ", + Yellow.paint(config.1.remote.to_string()) + ); + io::stdout().flush()?; + let mut remote = String::new(); + io::stdin().read_line(&mut remote)?; + Ok(Url::parse(&remote)?) +} + +// ======================================== +// CLI +// ======================================== + +pub fn write_config(mut pending: PathConfig) -> Result<()> { println!( "Generating config at {}...\n", - Success.paint(pending.0.unresolved().display().to_string()) + Green.paint(pending.0.unresolved().display().to_string()) ); - - print!( - "Git repository owner <{}> (enter to continue): ", - Warning.paint(pending.1.remote.owner.trim()) - ); - io::stdout().flush()?; - let mut owner = String::new(); - io::stdin().read_line(&mut owner)?; - let owner = owner.trim().to_owned(); - if !owner.is_empty() { - pending.1.remote.owner = owner; - } - - print!( - "Git repository name <{}> (enter to continue): ", - Warning.paint(pending.1.remote.name.trim()) - ); - io::stdout().flush()?; - let mut name = String::new(); - io::stdin().read_line(&mut name)?; - let name = name.trim().to_owned(); - if !name.is_empty() { - pending.1.remote.name = name; - } - + pending.1.local = Some(prompt_local(&pending)?); + pending.1.remote = prompt_remote(&pending)?; pending.write()?; println!("\nFinished writing configuration file."); Ok(()) @@ -45,7 +128,7 @@ pub fn write_config(mut pending: PathConfig) -> config::Result<()> { pub fn list_packages(config: PathConfig) { println!( "Listing packages in {}...\n", - Success.paint(config.0.unresolved().display().to_string()) + Green.paint(config.0.unresolved().display().to_string()) ); // Alphabetical ordered ensured by B-tree implementation. for (k, _) in config.1.packages { diff --git a/src/config.rs b/src/config.rs index f48714d..4c6b62e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::io::Write; use std::path::PathBuf; use std::{error, fmt, fs, io}; +use url::Url; // ======================================== // Error @@ -46,12 +47,6 @@ impl error::Error for Error {} // Config // ======================================== -#[derive(Debug, Deserialize, Serialize)] -pub struct Remote { - pub owner: String, - pub name: String, -} - #[derive(Debug, Deserialize, Serialize)] pub struct Package { pub configs: Vec, @@ -59,7 +54,8 @@ pub struct Package { #[derive(Debug, Deserialize, Serialize)] pub struct Config { - pub remote: Remote, + pub local: Option, + pub remote: Url, pub packages: BTreeMap, } @@ -77,10 +73,8 @@ impl PathConfig { PathConfig( path.clone(), config.unwrap_or(Config { - remote: Remote { - owner: "example-user".to_owned(), - name: "home-config".to_owned(), - }, + local: None, + remote: Url::parse("http://github.com/user/repo.git").unwrap(), packages: BTreeMap::new(), }), ) diff --git a/src/daemon.rs b/src/daemon.rs index 51c8057..783d0f2 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -26,7 +26,7 @@ enum PollEvent { fn resolve_pending(tx: &Sender, pending: &HashSet) -> Vec { let mut to_remove = vec![]; for path in pending { - match path::resolve(&path) { + match path::soft_resolve(&path) { Ok(Some(resolved)) => { to_remove.push(path.clone()); tx.send(DebouncedEvent::Create(resolved.into())) @@ -125,7 +125,7 @@ impl<'a> WatchState<'a> { self.watching.clear(); for (_, package) in &config.1.packages { for path in &package.configs { - match path::resolve(&path) { + match path::soft_resolve(&path) { Ok(None) => self.send_poll(PollEvent::Pending(path.clone())), Ok(Some(n)) => self.watch(n), Err(_) => (), diff --git a/src/git.rs b/src/git.rs index 550716d..a38a4b7 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,4 @@ use super::config::PathConfig; -use git2::Repository; -use octocrab; /// Sets up a local github repository all configuration files will be synced to. /// We attempt to clone the remote repository in favor of building our own. @@ -13,6 +11,6 @@ use octocrab; /// /// NOTE! This does not perform any syncing between local and remote. That /// should be done as a specific command line request. -pub async fn init(config: &PathConfig) { +pub async fn init(_config: &PathConfig) { // TODO(jrpotter): Fill this out. } diff --git a/src/lib.rs b/src/lib.rs index c2c5dff..2feb318 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,19 +19,22 @@ pub fn run_daemon(config: PathConfig, freq_secs: u64) -> Result<(), Box) -> Result<(), config::Error> { +pub fn run_init(candidates: Vec) -> Result<(), Box> { debug_assert!(!candidates.is_empty(), "Empty candidates found in `init`."); if candidates.is_empty() { - return Err(config::Error::FileError(io::Error::new( + Err(config::Error::FileError(io::Error::new( io::ErrorKind::NotFound, "No suitable config file found.", - ))); + )))?; } match config::load(&candidates) { // Check if we already have a local config somewhere. If so, reprompt // the same configuration options and override the values present in the // current YAML file. - Ok(pending) => cli::write_config(pending), + Ok(pending) => { + cli::write_config(pending)?; + Ok(()) + } // Otherwise create a new config file at the given location. We always // assume we want to write to the first file in our priority list. If // not, the user should specify which config they want to write using @@ -40,9 +43,10 @@ pub fn run_init(candidates: Vec) -> Result<(), config::Error> { // Make directories if necessary. Err(config::Error::MissingConfig) if !candidates.is_empty() => { let pending = PathConfig::new(&candidates[0], None); - cli::write_config(pending) + cli::write_config(pending)?; + Ok(()) } - Err(e) => Err(e), + Err(e) => Err(e)?, } } diff --git a/src/main.rs b/src/main.rs index 3b82917..ef3c0cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,7 +90,7 @@ fn find_candidates(matches: &clap::ArgMatches) -> Result, io::Er }; let mut resolved = vec![]; for candidate in candidates { - if let Ok(Some(r)) = homesync::path::resolve(&candidate) { + if let Ok(Some(r)) = homesync::path::soft_resolve(&candidate) { resolved.push(r); } } diff --git a/src/path.rs b/src/path.rs index f1720b9..55c12aa 100644 --- a/src/path.rs +++ b/src/path.rs @@ -1,9 +1,51 @@ use regex::Regex; -use std::env; -use std::ffi::{OsStr, OsString}; +use serde::de; +use serde::de::{Unexpected, Visitor}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::env::VarError; +use std::error; +use std::ffi::OsString; +use std::fmt; use std::hash::{Hash, Hasher}; -use std::io; use std::path::{Component, Path, PathBuf}; +use std::result; +use std::str; +use std::{env, io}; + +// ======================================== +// Error +// ======================================== + +pub type Result = result::Result; + +#[derive(Debug)] +pub enum Error { + IOError(io::Error), + VarError(VarError), +} + +impl From for Error { + fn from(err: io::Error) -> Error { + Error::IOError(err) + } +} + +impl From for Error { + fn from(err: VarError) -> Error { + Error::VarError(err) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::IOError(e) => write!(f, "{}", e), + Error::VarError(e) => write!(f, "{}", e), + } + } +} + +impl error::Error for Error {} // ======================================== // Path @@ -65,65 +107,140 @@ impl Hash for ResPathBuf { } } -/// Find environment variables found within the argument and expand them if -/// possible. +// ======================================== +// (De)serialization +// ======================================== + +impl Serialize for ResPathBuf { + fn serialize(&self, serializer: S) -> result::Result + where + S: Serializer, + { + self.inner.as_path().serialize(serializer) + } +} + +struct ResPathBufVisitor; + +impl<'de> Visitor<'de> for ResPathBufVisitor { + type Value = ResPathBuf; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("path string") + } + + fn visit_str(self, v: &str) -> result::Result + where + E: de::Error, + { + resolve(&PathBuf::from(&v)) + .map_err(|_| de::Error::custom(format!("Could not resolve path {}", v))) + } + + fn visit_string(self, v: String) -> result::Result + where + E: de::Error, + { + resolve(&PathBuf::from(&v)) + .map_err(|_| de::Error::custom(format!("Could not resolve path {}", v))) + } + + fn visit_bytes(self, v: &[u8]) -> result::Result + where + E: de::Error, + { + let value = str::from_utf8(v) + .map(From::from) + .map_err(|_| de::Error::invalid_value(Unexpected::Bytes(v), &self))?; + self.visit_str(value) + } + + fn visit_byte_buf(self, v: Vec) -> result::Result + where + E: de::Error, + { + let value = String::from_utf8(v) + .map(From::from) + .map_err(|e| de::Error::invalid_value(Unexpected::Bytes(&e.into_bytes()), &self))?; + self.visit_string(value) + } +} + +impl<'de> Deserialize<'de> for ResPathBuf { + fn deserialize(deserializer: D) -> result::Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_string(ResPathBufVisitor) + } +} + +// ======================================== +// Resolution +// ======================================== + +/// Find environment variables within the argument and expand them if possible. /// -/// Returns `None` in the case an environment variable present within the -/// argument is not defined. -fn expand_env(s: &OsStr) -> Option { +/// Returns an error if any found environment variables are not defined. +pub fn expand_env(s: &str) -> Result { let re = Regex::new(r"\$(?P[[:alnum:]]+)").unwrap(); - let lossy = s.to_string_lossy(); - let mut path = lossy.clone().to_string(); - for caps in re.captures_iter(&lossy) { - let evar = env::var(&caps["env"]).ok()?; + let mut path = s.to_owned(); + for caps in re.captures_iter(s) { + let evar = env::var(&caps["env"])?; path = path.replace(&format!("${}", &caps["env"]), &evar); } - Some(path.into()) + Ok(path) } /// Attempt to resolve the provided path, returning a fully resolved path -/// instance. +/// instance if successful. +pub fn resolve(path: &Path) -> Result { + let mut resolved = env::current_dir()?; + for comp in path.components() { + match comp { + Component::Prefix(_) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "We do not currently support Windows.", + ))?, + Component::RootDir => { + resolved.clear(); + resolved.push(Component::RootDir) + } + Component::CurDir => (), + Component::ParentDir => { + if !resolved.pop() { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot take parent of root.", + ))? + } + } + Component::Normal(c) => { + let c: OsString = expand_env(&c.to_string_lossy())?.into(); + resolved.push(Component::Normal(&c)); + } + } + } + let resolved = resolved.canonicalize()?; + Ok(ResPathBuf { + inner: resolved, + unresolved: path.to_path_buf(), + }) +} + +/// Attempt to resolve the provided path, returning a fully resolved path +/// instance if successful. /// /// If the provided file does not exist but could potentially exist in the /// future (e.g. for paths with environment variables defined), this will /// return a `None` instead of an error. -pub fn resolve(path: &Path) -> io::Result> { - let mut expanded = env::current_dir()?; - for comp in path.components() { - match comp { - Component::Prefix(_) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "We do not currently support Windows.", - )) - } - Component::RootDir => { - expanded.clear(); - expanded.push(Component::RootDir) - } - Component::CurDir => (), // Make no changes. - Component::ParentDir => { - if !expanded.pop() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Cannot take parent of root.", - )); - } - } - Component::Normal(c) => match expand_env(c) { - Some(c) => expanded.push(Component::Normal(&c)), - // The environment variable isn't defined yet but might be in - // the future. - None => return Ok(None), - }, - } - } - match expanded.canonicalize() { - Ok(resolved) => Ok(Some(ResPathBuf { - inner: resolved, - unresolved: path.to_path_buf(), - })), - Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e), +pub fn soft_resolve(path: &Path) -> Result> { + match resolve(path) { + Ok(resolved) => Ok(Some(resolved)), + Err(Error::IOError(e)) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e @ Error::IOError(_)) => Err(e), + // An ENV variable isn't defined yet, but we assume its possible it'll + // be defined in the future. Don't report as an error. + Err(Error::VarError(_)) => Ok(None), } }