Setup local and remote with deserialization rules.

pull/3/head
Joshua Potter 2021-12-31 20:12:21 -05:00
parent 606e3da69f
commit 17e2bf7a69
9 changed files with 305 additions and 110 deletions

View File

@ -20,3 +20,4 @@ serde = "1.0"
serde_derive = "1.0.132" serde_derive = "1.0.132"
serde_yaml = "0.8" serde_yaml = "0.8"
yaml-rust = "0.4.4" yaml-rust = "0.4.4"
url = { version = "2.2.2", features = ["serde"] }

View File

@ -1,7 +1,5 @@
--- ---
remote: remote: https://github.com/jrpotter/home-config.git
owner: jrpotter
name: home-config
packages: packages:
homesync: homesync:
configs: configs:

View File

@ -1,42 +1,125 @@
use super::config;
use super::config::PathConfig; use super::config::PathConfig;
use ansi_term::Colour::Green as Success; use super::path::ResPathBuf;
use ansi_term::Colour::Yellow as Warning; use super::{config, path};
use std::io; use ansi_term::Colour::{Green, Yellow};
use std::env::VarError;
use std::io::Write; use std::io::Write;
use std::path::PathBuf;
use std::{error, fmt, fs, io};
use url::{ParseError, Url};
// ========================================
// Error
// ========================================
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
ConfigError(config::Error),
IOError(io::Error),
ParseError(ParseError),
VarError(VarError),
}
impl From<config::Error> for Error {
fn from(err: config::Error) -> Error {
Error::ConfigError(err)
}
}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Error {
Error::IOError(err)
}
}
impl From<path::Error> 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<ParseError> for Error {
fn from(err: ParseError) -> Error {
Error::ParseError(err)
}
}
impl From<VarError> 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. // 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<ResPathBuf> {
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<Url> {
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!( println!(
"Generating config at {}...\n", "Generating config at {}...\n",
Success.paint(pending.0.unresolved().display().to_string()) Green.paint(pending.0.unresolved().display().to_string())
); );
pending.1.local = Some(prompt_local(&pending)?);
print!( pending.1.remote = prompt_remote(&pending)?;
"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.write()?; pending.write()?;
println!("\nFinished writing configuration file."); println!("\nFinished writing configuration file.");
Ok(()) Ok(())
@ -45,7 +128,7 @@ pub fn write_config(mut pending: PathConfig) -> config::Result<()> {
pub fn list_packages(config: PathConfig) { pub fn list_packages(config: PathConfig) {
println!( println!(
"Listing packages in {}...\n", "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. // Alphabetical ordered ensured by B-tree implementation.
for (k, _) in config.1.packages { for (k, _) in config.1.packages {

View File

@ -4,6 +4,7 @@ use std::collections::BTreeMap;
use std::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::PathBuf;
use std::{error, fmt, fs, io}; use std::{error, fmt, fs, io};
use url::Url;
// ======================================== // ========================================
// Error // Error
@ -46,12 +47,6 @@ impl error::Error for Error {}
// Config // Config
// ======================================== // ========================================
#[derive(Debug, Deserialize, Serialize)]
pub struct Remote {
pub owner: String,
pub name: String,
}
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct Package { pub struct Package {
pub configs: Vec<PathBuf>, pub configs: Vec<PathBuf>,
@ -59,7 +54,8 @@ pub struct Package {
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
pub struct Config { pub struct Config {
pub remote: Remote, pub local: Option<ResPathBuf>,
pub remote: Url,
pub packages: BTreeMap<String, Package>, pub packages: BTreeMap<String, Package>,
} }
@ -77,10 +73,8 @@ impl PathConfig {
PathConfig( PathConfig(
path.clone(), path.clone(),
config.unwrap_or(Config { config.unwrap_or(Config {
remote: Remote { local: None,
owner: "example-user".to_owned(), remote: Url::parse("http://github.com/user/repo.git").unwrap(),
name: "home-config".to_owned(),
},
packages: BTreeMap::new(), packages: BTreeMap::new(),
}), }),
) )

View File

@ -26,7 +26,7 @@ enum PollEvent {
fn resolve_pending(tx: &Sender<DebouncedEvent>, pending: &HashSet<PathBuf>) -> Vec<PathBuf> { fn resolve_pending(tx: &Sender<DebouncedEvent>, pending: &HashSet<PathBuf>) -> Vec<PathBuf> {
let mut to_remove = vec![]; let mut to_remove = vec![];
for path in pending { for path in pending {
match path::resolve(&path) { match path::soft_resolve(&path) {
Ok(Some(resolved)) => { Ok(Some(resolved)) => {
to_remove.push(path.clone()); to_remove.push(path.clone());
tx.send(DebouncedEvent::Create(resolved.into())) tx.send(DebouncedEvent::Create(resolved.into()))
@ -125,7 +125,7 @@ impl<'a> WatchState<'a> {
self.watching.clear(); self.watching.clear();
for (_, package) in &config.1.packages { for (_, package) in &config.1.packages {
for path in &package.configs { for path in &package.configs {
match path::resolve(&path) { match path::soft_resolve(&path) {
Ok(None) => self.send_poll(PollEvent::Pending(path.clone())), Ok(None) => self.send_poll(PollEvent::Pending(path.clone())),
Ok(Some(n)) => self.watch(n), Ok(Some(n)) => self.watch(n),
Err(_) => (), Err(_) => (),

View File

@ -1,6 +1,4 @@
use super::config::PathConfig; use super::config::PathConfig;
use git2::Repository;
use octocrab;
/// Sets up a local github repository all configuration files will be synced to. /// 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. /// 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 /// NOTE! This does not perform any syncing between local and remote. That
/// should be done as a specific command line request. /// 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. // TODO(jrpotter): Fill this out.
} }

View File

@ -19,19 +19,22 @@ pub fn run_daemon(config: PathConfig, freq_secs: u64) -> Result<(), Box<dyn Erro
Ok(()) Ok(())
} }
pub fn run_init(candidates: Vec<ResPathBuf>) -> Result<(), config::Error> { pub fn run_init(candidates: Vec<ResPathBuf>) -> Result<(), Box<dyn Error>> {
debug_assert!(!candidates.is_empty(), "Empty candidates found in `init`."); debug_assert!(!candidates.is_empty(), "Empty candidates found in `init`.");
if candidates.is_empty() { if candidates.is_empty() {
return Err(config::Error::FileError(io::Error::new( Err(config::Error::FileError(io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
"No suitable config file found.", "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
// current YAML file. // 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 // 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 // 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 // not, the user should specify which config they want to write using
@ -40,9 +43,10 @@ pub fn run_init(candidates: Vec<ResPathBuf>) -> Result<(), config::Error> {
// Make directories if necessary. // Make directories if necessary.
Err(config::Error::MissingConfig) if !candidates.is_empty() => { Err(config::Error::MissingConfig) if !candidates.is_empty() => {
let pending = PathConfig::new(&candidates[0], None); let pending = PathConfig::new(&candidates[0], None);
cli::write_config(pending) cli::write_config(pending)?;
Ok(())
} }
Err(e) => Err(e), Err(e) => Err(e)?,
} }
} }

View File

@ -90,7 +90,7 @@ fn find_candidates(matches: &clap::ArgMatches) -> Result<Vec<ResPathBuf>, io::Er
}; };
let mut resolved = vec![]; let mut resolved = vec![];
for candidate in candidates { 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); resolved.push(r);
} }
} }

View File

@ -1,9 +1,51 @@
use regex::Regex; use regex::Regex;
use std::env; use serde::de;
use std::ffi::{OsStr, OsString}; 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::hash::{Hash, Hasher};
use std::io;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::result;
use std::str;
use std::{env, io};
// ========================================
// Error
// ========================================
pub type Result<T> = result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
IOError(io::Error),
VarError(VarError),
}
impl From<io::Error> for Error {
fn from(err: io::Error) -> Error {
Error::IOError(err)
}
}
impl From<VarError> 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 // 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<S>(&self, serializer: S) -> result::Result<S::Ok, S::Error>
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<E>(self, v: &str) -> result::Result<Self::Value, E>
where
E: de::Error,
{
resolve(&PathBuf::from(&v))
.map_err(|_| de::Error::custom(format!("Could not resolve path {}", v)))
}
fn visit_string<E>(self, v: String) -> result::Result<Self::Value, E>
where
E: de::Error,
{
resolve(&PathBuf::from(&v))
.map_err(|_| de::Error::custom(format!("Could not resolve path {}", v)))
}
fn visit_bytes<E>(self, v: &[u8]) -> result::Result<Self::Value, E>
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<E>(self, v: Vec<u8>) -> result::Result<Self::Value, E>
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<D>(deserializer: D) -> result::Result<Self, D::Error>
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 /// Returns an error if any found environment variables are not defined.
/// argument is not defined. pub fn expand_env(s: &str) -> Result<String> {
fn expand_env(s: &OsStr) -> Option<OsString> {
let re = Regex::new(r"\$(?P<env>[[:alnum:]]+)").unwrap(); let re = Regex::new(r"\$(?P<env>[[:alnum:]]+)").unwrap();
let lossy = s.to_string_lossy(); let mut path = s.to_owned();
let mut path = lossy.clone().to_string(); for caps in re.captures_iter(s) {
for caps in re.captures_iter(&lossy) { let evar = env::var(&caps["env"])?;
let evar = env::var(&caps["env"]).ok()?;
path = path.replace(&format!("${}", &caps["env"]), &evar); path = path.replace(&format!("${}", &caps["env"]), &evar);
} }
Some(path.into()) Ok(path)
} }
/// Attempt to resolve the provided path, returning a fully resolved path /// Attempt to resolve the provided path, returning a fully resolved path
/// instance. /// instance if successful.
pub fn resolve(path: &Path) -> Result<ResPathBuf> {
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 /// If the provided file does not exist but could potentially exist in the
/// future (e.g. for paths with environment variables defined), this will /// future (e.g. for paths with environment variables defined), this will
/// return a `None` instead of an error. /// return a `None` instead of an error.
pub fn resolve(path: &Path) -> io::Result<Option<ResPathBuf>> { pub fn soft_resolve(path: &Path) -> Result<Option<ResPathBuf>> {
let mut expanded = env::current_dir()?; match resolve(path) {
for comp in path.components() { Ok(resolved) => Ok(Some(resolved)),
match comp { Err(Error::IOError(e)) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Component::Prefix(_) => { Err(e @ Error::IOError(_)) => Err(e),
return Err(io::Error::new( // An ENV variable isn't defined yet, but we assume its possible it'll
io::ErrorKind::InvalidInput, // be defined in the future. Don't report as an error.
"We do not currently support Windows.", Err(Error::VarError(_)) => Ok(None),
))
}
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),
} }
} }