diff --git a/Cargo.toml b/Cargo.toml index 97ae528..4361c44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,4 +12,4 @@ edition = "2021" [dependencies] clap = { version = "3.0.0-rc.9", features = ["derive"] } notify = "4.0.16" -yaml-rust = "0.4" +yaml-rust = "0.4.4" diff --git a/README.md b/README.md index 2732f96..41c9729 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,16 @@ TODO ## Configuration Homesync uses a YAML file, to be found in anyone of the following locations. -Locations are searched in the following order: +Locations are searched in the following priority: -- `$XDG_CONFIG_HOME/homesync/homesync.yml` -- `$XDG_CONFIG_HOME/homesync.yml` -- `$HOME/.config/homesync/homesync.yml` - `$HOME/.homesync.yml` +- `$HOME/.config/homesync/homesync.yml` +- `$XDG_CONFIG_HOME/homesync.yml` +- `$XDG_CONFIG_HOME/homesync/homesync.yml` That said, it is recommended to modify this config solely from the exposed -homesync CLI. Homesync will take responsibility ensuring how the config is -modified based on your package manager, platform, etc. +homesync CLI. Homesync will take responsibility ensuring the generated +configuration is according to package manager, platform, etc. ## Usage diff --git a/examples/config.yaml b/examples/config.yaml index ae726c0..706b461 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -1,9 +1,10 @@ remote: -system: + owner: jrpotter + name: home-config packages: homesync: configs: - - $XDG_CONFIG_HOME/homesync/homesync.yml - - $XDG_CONFIG_HOME/homesync.yml - - $HOME/.config/homesync/homesync.yml - $HOME/.homesync.yml + - $HOME/.config/homesync/homesync.yml + - $XDG_CONFIG_HOME/homesync.yml + - $XDG_CONFIG_HOME/homesync/homesync.yml diff --git a/src/homesync.rs b/src/homesync.rs new file mode 100644 index 0000000..ef68c36 --- /dev/null +++ b/src/homesync.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/src/homesync/config.rs b/src/homesync/config.rs new file mode 100644 index 0000000..8412b63 --- /dev/null +++ b/src/homesync/config.rs @@ -0,0 +1,259 @@ +use std::env; +use std::error; +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use yaml_rust::{ScanError, Yaml, YamlLoader}; + +// ======================================== +// Error +// ======================================== + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy)] +pub enum Key { + Packages, // OPTIONAL + Remote, // REQUIRED + RemoteName, // REQUIRED + RemoteOwner, // REQUIRED +} + +impl Key { + fn to_yaml(&self) -> Yaml { + Yaml::String(self.to_string()) + } +} + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Key::Packages => write!(f, "packages"), + Key::Remote => write!(f, "remote"), + Key::RemoteName => write!(f, "name"), + Key::RemoteOwner => write!(f, "owner"), + } + } +} + +#[derive(Debug, Clone)] +pub enum ErrorKind { + // Indicates our top-level data structure isn't a dictionary. + InvalidHash, + // Indicates a required key was not found. + MissingKey(Key), + // Indicates multiple YAML documents were found within our file. + MultipleDocuments, + // Indicates no YAML documents were found within our file. + NoDocument, + // Indicates there was a scan error when parsing the YAML. + ScanError(ScanError), +} + +impl fmt::Display for ErrorKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + ErrorKind::InvalidHash => write!(f, "expected dictionary"), + ErrorKind::MissingKey(k) => write!(f, "missing key '{}'", k), + ErrorKind::MultipleDocuments => write!(f, "has multiple YAML documents"), + ErrorKind::NoDocument => write!(f, "has no YAML document"), + ErrorKind::ScanError(s) => s.fmt(f), + } + } +} + +#[derive(Debug, Clone)] +pub struct ErrorWithFile { + path: PathBuf, + kind: ErrorKind, +} + +impl ErrorWithFile { + fn new(path: &Path, kind: ErrorKind) -> Self { + ErrorWithFile { + path: path.to_path_buf(), + kind, + } + } +} + +impl fmt::Display for ErrorWithFile { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let ErrorWithFile { path, kind } = self; + write!( + f, + "File {} failed with error: {}", + path.to_str().ok_or(fmt::Error)?, + kind + ) + } +} + +#[derive(Debug, Clone)] +pub enum Error { + // Indicates we could not find the configuration file at all. + MissingConfig, + // Indicates an error occurred when reading the configuration file. + WithFile(ErrorWithFile), +} + +impl Error { + pub fn new(path: &Path, kind: ErrorKind) -> Self { + Error::WithFile(ErrorWithFile::new(path, kind)) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::MissingConfig => write!( + f, + "\ + Could not find a valid configuration file. Looked in \ + \n\n- `$HOME/.homesync.yml` \ + \n- `$HOME/.config/homesync/homesync.yml` \ + \n- `$XDG_CONFIG_HOME/homesync.yml` \ + \n- `$XDG_CONFIG_HOME/homesync/homesync.yml` \ + \nin order." + ), + Error::WithFile(e) => write!(f, "{}", e), + } + } +} + +impl error::Error for Error {} + +// ======================================== +// Config +// ======================================== + +pub struct Remote { + pub owner: String, + pub name: String, +} + +pub struct Package { + pub name: String, + pub configs: Vec, +} + +pub struct Config { + pub path: PathBuf, + pub remote: Remote, + pub packages: Vec, +} + +impl Config { + pub fn new(path: &Path, contents: &str) -> Result { + if let Yaml::Hash(pairs) = get_document(path, contents)? { + let remote = pairs + .get(&Key::Remote.to_yaml()) + .ok_or(Error::new(path, ErrorKind::MissingKey(Key::Remote)))?; + let remote = parseRemote(path, remote)?; + let packages = pairs.get(&Key::Packages.to_yaml()).unwrap_or(&Yaml::Null); + let packages = parsePackages(path, packages)?; + // We intentionally ignore any other keys we may encounter. + Ok(Config { + path: path.to_path_buf(), + remote, + packages, + }) + } else { + Err(Error::new(path, ErrorKind::InvalidHash)) + } + } +} + +fn get_document(path: &Path, contents: &str) -> Result { + match YamlLoader::load_from_str(contents) { + Ok(mut docs) => { + if docs.len() > 1 { + Err(Error::new(path, ErrorKind::MultipleDocuments)) + } else if docs.is_empty() { + Err(Error::new(path, ErrorKind::NoDocument)) + } else { + Ok(docs.swap_remove(0)) + } + } + Err(e) => Err(Error::new(path, ErrorKind::ScanError(e))), + } +} + +// ======================================== +// Parsers +// ======================================== + +fn parseRemote(path: &Path, value: &Yaml) -> Result { + Ok(Remote { + owner: String::new(), + name: String::new(), + }) +} + +fn parsePackages(path: &Path, value: &Yaml) -> Result> { + Ok(Vec::new()) +} + +// ======================================== +// Public +// ======================================== + +/// Attempt to read in the project config in the following priorities: +/// +/// - `$HOME/.homesync.yml` +/// - `$HOME/.config/homesync/homesync.yml` +/// - `$XDG_CONFIG_HOME/homesync.yml` +/// - `$XDG_CONFIG_HOME/homesync/homesync.yml` +/// +/// Returns an error if a file does not exist in any of these locations or a +/// found file contains invalid YAML. +pub fn find_config() -> Result { + 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(), + ]); + } + // When trying our paths, the only acceptable error is a `NotFound` file. + // Anything else should be surfaced to the end user. + for path in paths { + if let Ok(Some(contents)) = read_optional_config(&path) { + return Ok(Config::new(&path, &contents)?); + } + } + Err(Error::MissingConfig) +} + +fn read_optional_config(path: &Path) -> io::Result> { + match fs::read_to_string(path) { + Err(err) => match err.kind() { + // Ignore `NotFound` since we want to try multiple paths. + io::ErrorKind::NotFound => Ok(None), + _ => Err(err), + }, + Ok(contents) => Ok(Some(contents)), + } +} + +pub fn generate_config() -> Config { + Config { + path: PathBuf::from(""), + remote: Remote { + owner: "".to_owned(), + name: "".to_owned(), + }, + packages: Vec::new(), + } +} diff --git a/src/lib.rs b/src/lib.rs index 2270557..70b5032 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,57 +1,28 @@ -use std::env; +mod homesync; + +use homesync::config; use std::error::Error; -use std::fs; -use std::io; -use std::path::PathBuf; -use yaml_rust::{Yaml, YamlLoader}; -fn read_config(path: &PathBuf) -> io::Result> { - match fs::read_to_string(path) { - Err(err) => match err.kind() { - // Ignore not found since we may try multiple paths. - io::ErrorKind::NotFound => Ok(None), - _ => Err(err), - }, - Ok(contents) => Ok(Some(contents)), - } -} - -fn find_config() -> Result, Box> { - let mut paths: Vec = Vec::new(); - if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { - paths.push( - [&xdg_config_home, "homesync", "homesync.yml"] - .iter() - .collect(), - ); - paths.push([&xdg_config_home, "homesync.yml"].iter().collect()); - } - if let Ok(home) = env::var("HOME") { - paths.push( - [&home, ".config", "homesync", "homesync.yml"] - .iter() - .collect(), - ); - paths.push([&home, ".homesync.yml"].iter().collect()); - } - for path in paths { - if let Ok(Some(contents)) = read_config(&path) { - return Ok(YamlLoader::load_from_str(&contents)?); - } - } - Err(Box::new(io::Error::new( - io::ErrorKind::NotFound, - "Could not find a homesync config.", - ))) -} - -pub fn run(config: Option) -> Result<(), Box> { - let _loaded = match config { - Some(path) => { - let contents = fs::read_to_string(path)?; - YamlLoader::load_from_str(&contents)? - } - None => find_config()?, +pub fn run_configure(_matches: &clap::ArgMatches) -> Result<(), Box> { + // 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. + let _config = match config::find_config() { + Ok(conf) => Ok(conf), + Err(config::Error::MissingConfig) => Ok(config::generate_config()), + Err(config::Error::WithFile(e)) => Err(e), }; Ok(()) } + +pub fn run_push(_matches: &clap::ArgMatches) -> Result<(), Box> { + Ok(()) +} + +pub fn run_pull(_matches: &clap::ArgMatches) -> Result<(), Box> { + Ok(()) +} + +pub fn run_add(_matches: &clap::ArgMatches) -> Result<(), Box> { + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index cae4882..926ad88 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,22 @@ -use clap::Parser; -use std::process; - -#[derive(Parser, Debug)] -#[clap(about, version, author)] -struct Args { - #[clap(short, long)] - config: Option, -} +use clap::{App, AppSettings}; fn main() { - let args = Args::parse(); - homesync::run(args.config).unwrap_or_else(|err| { - eprintln!("Problem parsing arguments: {}", err); - process::exit(1); - }); + let matches = App::new("homesync") + .about("Cross desktop configuration sync tool.") + .version("0.1.0") + .setting(AppSettings::SubcommandRequiredElseHelp) + .author("Joshua Potter ") + .subcommand(App::new("configure").about("Initialize the homesync local repository.")) + .subcommand(App::new("push").about("Push local repository to remote repository.")) + .subcommand(App::new("pull").about("Pull remote repository into local repository.")) + .subcommand(App::new("add").about("Add new configuration to local repository.")) + .get_matches(); + + match matches.subcommand() { + Some(("configure", ms)) => homesync::run_configure(ms).unwrap(), + Some(("push", ms)) => homesync::run_push(ms).unwrap(), + Some(("pull", ms)) => homesync::run_pull(ms).unwrap(), + Some(("add", ms)) => homesync::run_add(ms).unwrap(), + _ => unreachable!(), + } }