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
**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

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 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<Config>) -> Self {
pub fn new(path: &NormalPathBuf, config: Option<Config>) -> 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 {
)
}
pub fn write(&self) -> Result<()> {
// TODO(jrpotter): Create backup file before overwriting.
pub fn write(&self) -> Result<()> {
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<PathBuf> {
let mut paths: Vec<PathBuf> = 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<PathBuf>) -> Result<PathConfig> {
pub fn load(candidates: &Vec<NormalPathBuf>) -> Result<PathConfig> {
// 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)));
}
}
}

View File

@ -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<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())
// ========================================
// 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<PathBuf>,
// Paths that we are currently watching.
watching: HashSet<NormalPathBuf>,
// 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<PathBuf>,
}
// 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<PathBuf> {
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.",
))
impl WatchState {
pub fn new(config: &PathConfig) -> notify::Result<Self> {
let mut pending: HashSet<PathBuf> = HashSet::new();
let mut watching: HashSet<NormalPathBuf> = 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),
};
}
Component::RootDir => {
pb.clear();
pb.push(Component::RootDir)
Ok(WatchState {
pending,
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.
///
/// 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<WatchState> {
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<PathBuf> = HashSet::new();
// TODO(jrpotter): Spawn thread responsible for polling for missing files.
let mut missing_paths: HashSet<PathBuf> = 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 {

View File

@ -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<PathBuf>) -> 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<PathBuf>) -> Result<(), Box<dyn Error>> {
let loaded = config::load(&candidates)?;
daemon::launch(loaded)?;
pub fn run_daemon(config: PathConfig) -> Result<(), Box<dyn Error>> {
daemon::launch(config)?;
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) {
// 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<PathBuf>) -> Result<(), config::Error> {
}
}
pub fn run_list(candidates: Vec<PathBuf>) -> 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<dyn Error>> {
pub fn run_pull(_config: PathConfig) -> Result<(), Box<dyn Error>> {
Ok(())
}
pub fn run_push() -> Result<(), Box<dyn Error>> {
pub fn run_push(_config: PathConfig) -> Result<(), Box<dyn Error>> {
Ok(())
}

View File

@ -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<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() {
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<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)))
}