Separate path module and a normalized newtype.

pull/3/head
Joshua Potter 2021-12-30 14:17:42 -05:00
parent ba69724c49
commit 4fa6654423
6 changed files with 301 additions and 161 deletions

View File

@ -1,6 +1,6 @@
# homesync # homesync
**Caution! This is unstable code!** **Caution! This is a work in progress and far from complete!**
## Introduction ## Introduction
@ -31,7 +31,39 @@ configuration is according to package manager, platform, etc.
## Usage ## 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 ## Contribution

View File

@ -1,9 +1,10 @@
use ansi_term::Colour::Green; use super::path;
use super::path::{NormalPathBuf, Normalize};
use serde_derive::{Deserialize, Serialize}; use serde_derive::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::{env, error, fmt, fs, io}; use std::{error, fmt, fs, io};
// ======================================== // ========================================
// Error // Error
@ -70,12 +71,12 @@ impl Config {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct PathConfig(pub PathBuf, pub Config); pub struct PathConfig(pub NormalPathBuf, pub Config);
impl PathConfig { impl PathConfig {
pub fn new(path: &Path, config: Option<Config>) -> Self { pub fn new(path: &NormalPathBuf, config: Option<Config>) -> Self {
PathConfig( PathConfig(
path.to_path_buf(), path.clone(),
config.unwrap_or(Config { config.unwrap_or(Config {
remote: Remote { remote: Remote {
owner: "example-user".to_owned(), owner: "example-user".to_owned(),
@ -86,8 +87,8 @@ impl PathConfig {
) )
} }
pub fn write(&self) -> Result<()> {
// TODO(jrpotter): Create backup file before overwriting. // TODO(jrpotter): Create backup file before overwriting.
pub fn write(&self) -> Result<()> {
let mut file = fs::File::create(&self.0)?; let mut file = fs::File::create(&self.0)?;
let serialized = serde_yaml::to_string(&self.1)?; let serialized = serde_yaml::to_string(&self.1)?;
file.write_all(serialized.as_bytes())?; file.write_all(serialized.as_bytes())?;
@ -99,45 +100,27 @@ impl PathConfig {
// Loading // Loading
// ======================================== // ========================================
/// Returns the default configuration files `homesync` looks for. pub const DEFAULT_PATHS: &[&str] = &[
/// "$HOME/.homesync.yml",
/// - `$HOME/.homesync.yml` "$HOME/.config/homesync/homesync.yml",
/// - `$HOME/.config/homesync/homesync.yml` "$XDG_CONFIG_HOME/homesync.yml",
/// - `$XDG_CONFIG_HOME/homesync.yml` "$XDG_CONFIG_HOME/homesync/homesync.yml",
/// - `$XDG_CONFIG_HOME/homesync/homesync.yml` ];
///
/// Returned `PathBuf`s are looked for in the above order.
pub fn default_paths() -> Vec<PathBuf> { pub fn default_paths() -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = Vec::new(); DEFAULT_PATHS.iter().map(|s| PathBuf::from(s)).collect()
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
} }
pub fn load(candidates: &Vec<PathBuf>) -> Result<PathConfig> { pub fn load(candidates: &Vec<NormalPathBuf>) -> Result<PathConfig> {
// When trying our paths, the only acceptable error is a `NotFound` file. // When trying our paths, the only acceptable error is a `NotFound` file.
// Anything else should be surfaced to the end user. // Anything else should be surfaced to the end user.
for path in candidates { for candidate in candidates {
match fs::read_to_string(path) { match fs::read_to_string(candidate) {
Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(Error::FileError(err)), Err(err) => return Err(Error::FileError(err)),
Ok(contents) => { Ok(contents) => {
let config = Config::new(&contents)?; let config = Config::new(&contents)?;
return Ok(PathConfig::new(&path, Some(config))); return Ok(PathConfig::new(candidate, Some(config)));
} }
} }
} }

View File

@ -1,114 +1,93 @@
use super::config;
use super::config::PathConfig; use super::config::PathConfig;
use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use super::path;
use regex::Regex; use super::path::{NormalPathBuf, Normalize};
use notify::{RecommendedWatcher, Watcher};
use std::collections::HashSet; use std::collections::HashSet;
use std::env; use std::path::PathBuf;
use std::ffi::{OsStr, OsString};
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::mpsc::channel; use std::sync::mpsc::channel;
use std::time::Duration; use std::time::Duration;
// TODO(jrpotter): Add logging. // TODO(jrpotter): Add logging.
// TODO(jrpotter): Add pid file to only allow one daemon at a time. // 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. // State
// // ========================================
// Returns `None` in the case an environment variable present within the
// argument is not defined. struct WatchState {
fn expand_str(s: &OsStr) -> Option<OsString> { // Paths that we were not able to watch properly but could potentially do so
let re = Regex::new(r"\$(?P<env>[[:alnum:]]+)").unwrap(); // in the future. These include paths that did not exist at the time of
let lossy = s.to_string_lossy(); // canonicalization or did not have environment variables defined that may
let mut path = lossy.clone().to_string(); // be defined later on.
for caps in re.captures_iter(&lossy) { pending: HashSet<PathBuf>,
let evar = env::var(&caps["env"]).ok()?; // Paths that we are currently watching.
path = path.replace(&format!("${}", &caps["env"]), &evar); watching: HashSet<NormalPathBuf>,
} // Paths that are not valid and will never become valid. These may include
Some(path.into()) // paths that include prefixes or refer to directories that could never be
// reached (e.g. parent of root).
invalid: HashSet<PathBuf>,
} }
// Normalizes the provided path, returning a new instance. impl WatchState {
// pub fn new(config: &PathConfig) -> notify::Result<Self> {
// There current doesn't exist a method that yields some canonical path for let mut pending: HashSet<PathBuf> = HashSet::new();
// files that do not exist (at least in the parts of the standard library I've let mut watching: HashSet<NormalPathBuf> = HashSet::new();
// looked in). We create a consistent view of every path so as to avoid watching.insert(config.0.clone());
// watching the same path multiple times, which would duplicate messages on // We try and resolve our configuration again here. We want to
// changes. // specifically track any new configs that may pop up with higher
fn normalize_path(path: &Path) -> io::Result<PathBuf> { // priority.
let mut pb = env::current_dir()?; for path in config::default_paths() {
for comp in path.components() { match path::normalize(&path)? {
match comp { // TODO(jrpotter): Check if the path can be canonicalized.
Component::Prefix(_) => { Normalize::Done(p) => watching.insert(p),
return Err(io::Error::new( Normalize::Pending => pending.insert(path),
io::ErrorKind::InvalidInput, };
"We do not currently support Windows.",
))
} }
Component::RootDir => { Ok(WatchState {
pb.clear(); pending,
pb.push(Component::RootDir) watching,
invalid: HashSet::new(),
})
} }
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()),
))
}
},
}
}
Ok(pb)
} }
/// Launches the daemon instance. // ========================================
/// // Daemon
/// This method also spawns an additional thread responsible for handling // ========================================
/// polling of files that are specified in the config but do not exist.
fn reload_config(config: &PathConfig) -> notify::Result<WatchState> {
let state = WatchState::new(config)?;
Ok(state)
}
pub fn launch(config: PathConfig) -> notify::Result<()> { pub fn launch(config: PathConfig) -> notify::Result<()> {
let (tx, rx) = channel(); let (tx, rx) = channel();
// Create a "debounced" watcher. Events will not trigger until after the // Create a "debounced" watcher. Events will not trigger until after the
// specified duration has passed with no additional changes. // specified duration has passed with no additional changes.
let mut watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(2))?; let mut watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(2))?;
// Take in the `homesync` configuration and add watchers to all paths // for (_, package) in &config.1.packages {
// specified in the configuration. `watch` appends the path onto a list so // for path in &package.configs {
// avoid tracking the same path multiple times. // match normalize_path(&path) {
let mut tracked_paths: HashSet<PathBuf> = HashSet::new(); // // `notify-rs` is not able to handle files that do not exist and
// TODO(jrpotter): Spawn thread responsible for polling for missing files. // // are then created. This is handled internally by the library
let mut missing_paths: HashSet<PathBuf> = HashSet::new(); // // via the `fs::canonicalize` which fails on missing paths. So
for (_, package) in &config.1.packages { // // track which paths end up missing and apply polling on them.
for path in &package.configs { // Ok(normalized) => match watcher.watch(&normalized, RecursiveMode::NonRecursive) {
match normalize_path(&path) { // Ok(_) => {
// `notify-rs` is not able to handle files that do not exist and // tracked_paths.insert(normalized);
// are then created. This is handled internally by the library // }
// via the `fs::canonicalize` which fails on missing paths. So // Err(notify::Error::PathNotFound) => {
// track which paths end up missing and apply polling on them. // missing_paths.insert(normalized);
Ok(normalized) => match watcher.watch(&normalized, RecursiveMode::NonRecursive) { // }
Ok(_) => { // Err(e) => return Err(e),
tracked_paths.insert(normalized); // },
} // // TODO(jrpotter): Retry even in cases where environment
Err(notify::Error::PathNotFound) => { // // variables are not defined.
missing_paths.insert(normalized); // Err(e) => eprintln!("{}", e),
} // };
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, // This is a simple loop, but you may want to use more complex logic here,
// for example to handle I/O. // for example to handle I/O.
loop { loop {

View File

@ -1,23 +1,31 @@
pub mod cli; pub mod cli;
pub mod config; pub mod config;
pub mod daemon; pub mod daemon;
pub mod path;
use config::PathConfig; use config::PathConfig;
use path::NormalPathBuf;
use std::error::Error; use std::error::Error;
use std::path::PathBuf; use std::io;
pub fn run_add(_candidates: Vec<PathBuf>) -> Result<(), config::Error> { pub fn run_add(_config: PathConfig) -> Result<(), config::Error> {
// TODO(jrpotter): Show $EDITOR that allows writing specific package. // TODO(jrpotter): Show $EDITOR that allows writing specific package.
Ok(()) Ok(())
} }
pub fn run_daemon(candidates: Vec<PathBuf>) -> Result<(), Box<dyn Error>> { pub fn run_daemon(config: PathConfig) -> Result<(), Box<dyn Error>> {
let loaded = config::load(&candidates)?; daemon::launch(config)?;
daemon::launch(loaded)?;
Ok(()) Ok(())
} }
pub fn run_init(candidates: Vec<PathBuf>) -> Result<(), config::Error> { pub fn run_init(candidates: Vec<NormalPathBuf>) -> 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) { match config::load(&candidates) {
// Check if we already have a local config somewhere. If so, reprompt // Check if we already have a local config somewhere. If so, reprompt
// the same configuration options and override the values present in the // the same configuration options and override the values present in the
@ -37,16 +45,15 @@ pub fn run_init(candidates: Vec<PathBuf>) -> Result<(), config::Error> {
} }
} }
pub fn run_list(candidates: Vec<PathBuf>) -> Result<(), config::Error> { pub fn run_list(config: PathConfig) -> Result<(), config::Error> {
let loaded = config::load(&candidates)?; cli::list_packages(config);
cli::list_packages(loaded);
Ok(()) Ok(())
} }
pub fn run_pull() -> Result<(), Box<dyn Error>> { pub fn run_pull(_config: PathConfig) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }
pub fn run_push() -> Result<(), Box<dyn Error>> { pub fn run_push(_config: PathConfig) -> Result<(), Box<dyn Error>> {
Ok(()) Ok(())
} }

View File

@ -1,20 +1,9 @@
use clap::{App, AppSettings, Arg}; use clap::{App, AppSettings, Arg};
use homesync::path::{NormalPathBuf, Normalize};
use std::error::Error; use std::error::Error;
use std::io;
use std::path::PathBuf; use std::path::PathBuf;
fn dispatch(paths: Vec<PathBuf>, matches: clap::ArgMatches) -> Result<(), Box<dyn Error>> {
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() { fn main() {
let matches = App::new("homesync") let matches = App::new("homesync")
.about("Cross desktop configuration sync tool.") .about("Cross desktop configuration sync tool.")
@ -37,12 +26,59 @@ fn main() {
.subcommand(App::new("push").about("Push local repository to remote repository")) .subcommand(App::new("push").about("Push local repository to remote repository"))
.get_matches(); .get_matches();
let candidates = match matches.value_of("config") { if let Err(e) = dispatch(matches) {
Some(path) => vec![PathBuf::from(path)],
None => homesync::config::default_paths(),
};
if let Err(e) = dispatch(candidates, matches) {
eprintln!("{}", e); eprintln!("{}", e);
} }
} }
fn dispatch(matches: clap::ArgMatches) -> Result<(), Box<dyn Error>> {
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<Vec<NormalPathBuf>, Box<dyn Error>> {
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)
}
}

103
src/path.rs Normal file
View File

@ -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<Path> for NormalPathBuf {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl AsRef<PathBuf> for NormalPathBuf {
fn as_ref(&self) -> &PathBuf {
&self.0
}
}
impl Hash for NormalPathBuf {
fn hash<H: Hasher>(&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<OsString> {
let re = Regex::new(r"\$(?P<env>[[: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<Normalize> {
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)))
}