From 4fa6654423245707da10613cac4de783ca29267f Mon Sep 17 00:00:00 2001 From: Joshua Potter Date: Thu, 30 Dec 2021 14:17:42 -0500 Subject: [PATCH] Separate path module and a normalized newtype. --- README.md | 36 ++++++++++- src/config.rs | 57 +++++++----------- src/daemon.rs | 163 ++++++++++++++++++++++---------------------------- src/lib.rs | 29 +++++---- src/main.rs | 74 +++++++++++++++++------ src/path.rs | 103 +++++++++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 161 deletions(-) create mode 100644 src/path.rs diff --git a/README.md b/README.md index 41c9729..d43a549 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # homesync -**Caution! This is unstable code!** +**Caution! This is a work in progress and far from complete!** ## Introduction @@ -31,7 +31,39 @@ configuration is according to package manager, platform, etc. ## Usage -TODO +Verify your installation by running `homesync` from the command line. If +installed, you will likely want to initialize a new config instance. Do so by +typing: + +```bash +$ homesync init +``` + +You can then walk through what github repository you want to sync your various +files with. You can have homesync automatically monitor all configuration files +and post updates on changes by running + +```bash +$ homesync daemon +``` + +As changes are made to your `homesync` config or any configuration files +referred to within the `homesync` config, the daemon service will sync the +changes to the local git repository. To push these changes upward, run + +```bash +$ homesync push --all +``` + +which will expose a git interface for you to complete the push. Lastly, to sync +the remote configurations to your local files, run + +```bash +$ homesync pull --all +``` + +This will load up a diff wrapper for you to ensure you make the changes you'd +like. ## Contribution diff --git a/src/config.rs b/src/config.rs index 04f627e..c2377d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,10 @@ -use ansi_term::Colour::Green; +use super::path; +use super::path::{NormalPathBuf, Normalize}; use serde_derive::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::Write; -use std::path::{Path, PathBuf}; -use std::{env, error, fmt, fs, io}; +use std::path::PathBuf; +use std::{error, fmt, fs, io}; // ======================================== // Error @@ -70,12 +71,12 @@ impl Config { } #[derive(Debug)] -pub struct PathConfig(pub PathBuf, pub Config); +pub struct PathConfig(pub NormalPathBuf, pub Config); impl PathConfig { - pub fn new(path: &Path, config: Option) -> Self { + pub fn new(path: &NormalPathBuf, config: Option) -> Self { PathConfig( - path.to_path_buf(), + path.clone(), config.unwrap_or(Config { remote: Remote { owner: "example-user".to_owned(), @@ -86,8 +87,8 @@ impl PathConfig { ) } + // TODO(jrpotter): Create backup file before overwriting. pub fn write(&self) -> Result<()> { - // TODO(jrpotter): Create backup file before overwriting. let mut file = fs::File::create(&self.0)?; let serialized = serde_yaml::to_string(&self.1)?; file.write_all(serialized.as_bytes())?; @@ -99,45 +100,27 @@ impl PathConfig { // Loading // ======================================== -/// Returns the default configuration files `homesync` looks for. -/// -/// - `$HOME/.homesync.yml` -/// - `$HOME/.config/homesync/homesync.yml` -/// - `$XDG_CONFIG_HOME/homesync.yml` -/// - `$XDG_CONFIG_HOME/homesync/homesync.yml` -/// -/// Returned `PathBuf`s are looked for in the above order. +pub const DEFAULT_PATHS: &[&str] = &[ + "$HOME/.homesync.yml", + "$HOME/.config/homesync/homesync.yml", + "$XDG_CONFIG_HOME/homesync.yml", + "$XDG_CONFIG_HOME/homesync/homesync.yml", +]; + pub fn default_paths() -> Vec { - let mut paths: Vec = Vec::new(); - if let Ok(home) = env::var("HOME") { - paths.extend_from_slice(&[ - [&home, ".homesync.yml"].iter().collect(), - [&home, ".config", "homesync", "homesync.yml"] - .iter() - .collect(), - ]); - } - if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { - paths.extend_from_slice(&[ - [&xdg_config_home, "homesync.yml"].iter().collect(), - [&xdg_config_home, "homesync", "homesync.yml"] - .iter() - .collect(), - ]); - } - paths + DEFAULT_PATHS.iter().map(|s| PathBuf::from(s)).collect() } -pub fn load(candidates: &Vec) -> Result { +pub fn load(candidates: &Vec) -> Result { // When trying our paths, the only acceptable error is a `NotFound` file. // Anything else should be surfaced to the end user. - for path in candidates { - match fs::read_to_string(path) { + for candidate in candidates { + match fs::read_to_string(candidate) { Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(Error::FileError(err)), Ok(contents) => { let config = Config::new(&contents)?; - return Ok(PathConfig::new(&path, Some(config))); + return Ok(PathConfig::new(candidate, Some(config))); } } } diff --git a/src/daemon.rs b/src/daemon.rs index f4ce347..6bedb11 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,114 +1,93 @@ +use super::config; use super::config::PathConfig; -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use regex::Regex; +use super::path; +use super::path::{NormalPathBuf, Normalize}; +use notify::{RecommendedWatcher, Watcher}; use std::collections::HashSet; -use std::env; -use std::ffi::{OsStr, OsString}; -use std::io; -use std::path::{Component, Path, PathBuf}; +use std::path::PathBuf; use std::sync::mpsc::channel; use std::time::Duration; // TODO(jrpotter): Add logging. // TODO(jrpotter): Add pid file to only allow one daemon at a time. -// Find environment variables found 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_str(s: &OsStr) -> Option { - 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()?; - path = path.replace(&format!("${}", &caps["env"]), &evar); - } - Some(path.into()) +// ======================================== +// State +// ======================================== + +struct WatchState { + // Paths that we were not able to watch properly but could potentially do so + // in the future. These include paths that did not exist at the time of + // canonicalization or did not have environment variables defined that may + // be defined later on. + pending: HashSet, + // Paths that we are currently watching. + watching: HashSet, + // Paths that are not valid and will never become valid. These may include + // paths that include prefixes or refer to directories that could never be + // reached (e.g. parent of root). + invalid: HashSet, } -// Normalizes the provided path, returning a new instance. -// -// There current doesn't exist a method that yields some canonical path for -// files that do not exist (at least in the parts of the standard library I've -// looked in). We create a consistent view of every path so as to avoid -// watching the same path multiple times, which would duplicate messages on -// changes. -fn normalize_path(path: &Path) -> io::Result { - let mut pb = 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 => { - pb.clear(); - pb.push(Component::RootDir) - } - Component::CurDir => (), // Make no changes. - Component::ParentDir => { - if !pb.pop() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Cannot take parent of root.", - )); - } - } - Component::Normal(c) => match expand_str(c) { - Some(c) => pb.push(Component::Normal(&c)), - None => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("Cannot find path {}", path.display().to_string()), - )) - } - }, +impl WatchState { + pub fn new(config: &PathConfig) -> notify::Result { + let mut pending: HashSet = HashSet::new(); + let mut watching: HashSet = HashSet::new(); + watching.insert(config.0.clone()); + // We try and resolve our configuration again here. We want to + // specifically track any new configs that may pop up with higher + // priority. + for path in config::default_paths() { + match path::normalize(&path)? { + // TODO(jrpotter): Check if the path can be canonicalized. + Normalize::Done(p) => watching.insert(p), + Normalize::Pending => pending.insert(path), + }; } + Ok(WatchState { + pending, + watching, + invalid: HashSet::new(), + }) } - Ok(pb) } -/// Launches the daemon instance. -/// -/// This method also spawns an additional thread responsible for handling -/// polling of files that are specified in the config but do not exist. +// ======================================== +// Daemon +// ======================================== + +fn reload_config(config: &PathConfig) -> notify::Result { + let state = WatchState::new(config)?; + Ok(state) +} + pub fn launch(config: PathConfig) -> notify::Result<()> { let (tx, rx) = channel(); // Create a "debounced" watcher. Events will not trigger until after the // specified duration has passed with no additional changes. let mut watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(2))?; - // Take in the `homesync` configuration and add watchers to all paths - // specified in the configuration. `watch` appends the path onto a list so - // avoid tracking the same path multiple times. - let mut tracked_paths: HashSet = HashSet::new(); - // TODO(jrpotter): Spawn thread responsible for polling for missing files. - let mut missing_paths: HashSet = HashSet::new(); - for (_, package) in &config.1.packages { - for path in &package.configs { - match normalize_path(&path) { - // `notify-rs` is not able to handle files that do not exist and - // are then created. This is handled internally by the library - // via the `fs::canonicalize` which fails on missing paths. So - // track which paths end up missing and apply polling on them. - Ok(normalized) => match watcher.watch(&normalized, RecursiveMode::NonRecursive) { - Ok(_) => { - tracked_paths.insert(normalized); - } - Err(notify::Error::PathNotFound) => { - missing_paths.insert(normalized); - } - Err(e) => return Err(e), - }, - // TODO(jrpotter): Retry even in cases where environment - // variables are not defined. - Err(e) => eprintln!("{}", e), - }; - } - } + // for (_, package) in &config.1.packages { + // for path in &package.configs { + // match normalize_path(&path) { + // // `notify-rs` is not able to handle files that do not exist and + // // are then created. This is handled internally by the library + // // via the `fs::canonicalize` which fails on missing paths. So + // // track which paths end up missing and apply polling on them. + // Ok(normalized) => match watcher.watch(&normalized, RecursiveMode::NonRecursive) { + // Ok(_) => { + // tracked_paths.insert(normalized); + // } + // Err(notify::Error::PathNotFound) => { + // missing_paths.insert(normalized); + // } + // Err(e) => return Err(e), + // }, + // // TODO(jrpotter): Retry even in cases where environment + // // variables are not defined. + // Err(e) => eprintln!("{}", e), + // }; + // } + // } // This is a simple loop, but you may want to use more complex logic here, // for example to handle I/O. loop { diff --git a/src/lib.rs b/src/lib.rs index 8857427..03fe9a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,23 +1,31 @@ pub mod cli; pub mod config; pub mod daemon; +pub mod path; use config::PathConfig; +use path::NormalPathBuf; use std::error::Error; -use std::path::PathBuf; +use std::io; -pub fn run_add(_candidates: Vec) -> Result<(), config::Error> { +pub fn run_add(_config: PathConfig) -> Result<(), config::Error> { // TODO(jrpotter): Show $EDITOR that allows writing specific package. Ok(()) } -pub fn run_daemon(candidates: Vec) -> Result<(), Box> { - let loaded = config::load(&candidates)?; - daemon::launch(loaded)?; +pub fn run_daemon(config: PathConfig) -> Result<(), Box> { + daemon::launch(config)?; Ok(()) } -pub fn run_init(candidates: Vec) -> Result<(), config::Error> { +pub fn run_init(candidates: Vec) -> Result<(), config::Error> { + debug_assert!(!candidates.is_empty(), "Empty candidates found in `init`."); + if candidates.is_empty() { + return 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 @@ -37,16 +45,15 @@ pub fn run_init(candidates: Vec) -> Result<(), config::Error> { } } -pub fn run_list(candidates: Vec) -> Result<(), config::Error> { - let loaded = config::load(&candidates)?; - cli::list_packages(loaded); +pub fn run_list(config: PathConfig) -> Result<(), config::Error> { + cli::list_packages(config); Ok(()) } -pub fn run_pull() -> Result<(), Box> { +pub fn run_pull(_config: PathConfig) -> Result<(), Box> { Ok(()) } -pub fn run_push() -> Result<(), Box> { +pub fn run_push(_config: PathConfig) -> Result<(), Box> { Ok(()) } diff --git a/src/main.rs b/src/main.rs index c89719f..606b919 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,9 @@ use clap::{App, AppSettings, Arg}; +use homesync::path::{NormalPathBuf, Normalize}; use std::error::Error; +use std::io; use std::path::PathBuf; -fn dispatch(paths: Vec, matches: clap::ArgMatches) -> Result<(), Box> { - match matches.subcommand() { - Some(("add", _)) => homesync::run_add(paths)?, - Some(("daemon", _)) => homesync::run_daemon(paths)?, - Some(("init", _)) => homesync::run_init(paths)?, - Some(("list", _)) => homesync::run_list(paths)?, - Some(("pull", _)) => homesync::run_pull()?, - Some(("push", _)) => homesync::run_push()?, - _ => unreachable!(), - }; - Ok(()) -} - fn main() { let matches = App::new("homesync") .about("Cross desktop configuration sync tool.") @@ -37,12 +26,59 @@ fn main() { .subcommand(App::new("push").about("Push local repository to remote repository")) .get_matches(); - let candidates = match matches.value_of("config") { - Some(path) => vec![PathBuf::from(path)], - None => homesync::config::default_paths(), - }; - - if let Err(e) = dispatch(candidates, matches) { + if let Err(e) = dispatch(matches) { eprintln!("{}", e); } } + +fn dispatch(matches: clap::ArgMatches) -> Result<(), Box> { + let candidates = find_candidates(&matches)?; + match matches.subcommand() { + Some(("init", _)) => Ok(homesync::run_init(candidates)?), + // All subcommands beside `init` require a config. If we invoke any of + // these, immediately attempt to load our config. Note once a config is + // loaded, this same config is used throughout the lifetime of the + // process. We avoid introducing the ability to "change" which config is + // used, even if one of higher priority is eventually defined. + subcommand => { + let config = homesync::config::load(&candidates)?; + match subcommand { + Some(("add", _)) => Ok(homesync::run_add(config)?), + Some(("daemon", _)) => Ok(homesync::run_daemon(config)?), + Some(("list", _)) => Ok(homesync::run_list(config)?), + Some(("pull", _)) => Ok(homesync::run_pull(config)?), + Some(("push", _)) => Ok(homesync::run_push(config)?), + _ => unreachable!(), + } + } + } +} + +fn find_candidates(matches: &clap::ArgMatches) -> Result, Box> { + let candidates = match matches.value_of("config") { + Some(config_match) => vec![PathBuf::from(config_match)], + None => homesync::config::default_paths(), + }; + let mut normals = vec![]; + for candidate in candidates { + if let Ok(Normalize::Done(n)) = homesync::path::normalize(&candidate) { + normals.push(n); + } + } + if normals.is_empty() { + if let Some(config_match) = matches.value_of("config") { + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("{} is not a valid config path.", config_match), + ))? + } else { + Err(io::Error::new( + io::ErrorKind::NotFound, + "Could not find a suitable configuration path. Is \ + $XDG_CONFIG_PATH or $HOME defined?", + ))? + } + } else { + Ok(normals) + } +} diff --git a/src/path.rs b/src/path.rs new file mode 100644 index 0000000..440ba23 --- /dev/null +++ b/src/path.rs @@ -0,0 +1,103 @@ +use regex::Regex; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::hash::{Hash, Hasher}; +use std::io; +use std::path::{Component, Path, PathBuf}; + +// ======================================== +// Path +// ======================================== + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NormalPathBuf(PathBuf); + +impl NormalPathBuf { + pub fn display(&self) -> std::path::Display { + self.0.display() + } +} + +impl AsRef for NormalPathBuf { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl AsRef for NormalPathBuf { + fn as_ref(&self) -> &PathBuf { + &self.0 + } +} + +impl Hash for NormalPathBuf { + fn hash(&self, h: &mut H) { + for component in self.0.components() { + component.hash(h); + } + } +} + +pub enum Normalize { + Done(NormalPathBuf), // An instance of a fully resolved path. + Pending, // An instance of a path that cannot yet be normalized. +} + +// Find environment variables found 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 { + 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()?; + path = path.replace(&format!("${}", &caps["env"]), &evar); + } + Some(path.into()) +} + +// Normalizes the provided path, returning a new instance. +// +// There currently does not exist a method that yields some canonical path for +// files that do not exist (at least in the parts of the standard library I've +// looked in). We create a consistent view of every path so as to avoid watching +// the same path multiple times, which would duplicate messages on changes. +// +// Note this does not actually prevent the issue fully. We could have two paths +// that refer to the same real path - normalization would not catch this. +pub fn normalize(path: &Path) -> io::Result { + let mut pb = 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 => { + pb.clear(); + pb.push(Component::RootDir) + } + Component::CurDir => (), // Make no changes. + Component::ParentDir => { + if !pb.pop() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Cannot take parent of root.", + )); + } + } + Component::Normal(c) => match expand_env(c) { + Some(c) => pb.push(Component::Normal(&c)), + // The environment variable isn't defined yet but might be in + // the future. + None => return Ok(Normalize::Pending), + }, + } + } + Ok(Normalize::Done(NormalPathBuf(pb))) +}