cockup 0.2.0

Yet another backup tool for various configurations
use serde::{Deserialize, Deserializer};
use std::{
    collections::{BTreeMap, HashSet},
    fs,
    path::{Path, PathBuf},
};

type Result<T> = std::result::Result<T, String>;
fn default_true() -> bool {
    true
}
fn null_default<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
where
    D: Deserializer<'de>,
    T: Deserialize<'de> + Default,
{
    Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
}
fn environment<'de, D: Deserializer<'de>>(
    d: D,
) -> std::result::Result<BTreeMap<String, String>, D::Error> {
    let values =
        Option::<BTreeMap<String, serde_yaml_ng::Value>>::deserialize(d)?.unwrap_or_default();
    values
        .into_iter()
        .map(|(key, value)| {
            let text = match value {
                serde_yaml_ng::Value::String(s) => s,
                serde_yaml_ng::Value::Number(n) => n.to_string(),
                serde_yaml_ng::Value::Bool(b) => b.to_string(),
                _ => {
                    return Err(serde::de::Error::custom(
                        "hook env values must be strings, numbers, or booleans",
                    ));
                }
            };
            Ok((key, text))
        })
        .collect()
}
#[derive(Clone, Deserialize)]
pub struct Hook {
    pub name: String,
    pub command: Vec<String>,
    #[serde(default)]
    pub check: Option<Vec<String>>,
    #[serde(default)]
    pub output: bool,
    #[serde(default)]
    pub timeout: Option<u64>,
    #[serde(default, deserialize_with = "environment")]
    pub env: BTreeMap<String, String>,
}
#[derive(Deserialize)]
pub struct Rule {
    #[serde(rename = "from")]
    pub src: PathBuf,
    pub to: PathBuf,
    #[serde(default)]
    pub symlinks: Option<Symlinks>,
    #[serde(default)]
    pub metadata: Option<bool>,
    #[serde(default, deserialize_with = "null_default")]
    pub targets: Vec<String>,
    #[serde(default, rename = "on-start", deserialize_with = "null_default")]
    pub on_start: Vec<Hook>,
    #[serde(default, rename = "on-end", deserialize_with = "null_default")]
    pub on_end: Vec<Hook>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Include {
    file: PathBuf,
    #[serde(default)]
    wrap: Option<PathBuf>,
    #[serde(default)]
    symlinks: Option<Symlinks>,
    #[serde(default)]
    metadata: Option<bool>,
}
#[derive(Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct Hooks {
    #[serde(default, deserialize_with = "null_default")]
    pub pre_backup: Vec<Hook>,
    #[serde(default, deserialize_with = "null_default")]
    pub post_backup: Vec<Hook>,
    #[serde(default, deserialize_with = "null_default")]
    pub pre_restore: Vec<Hook>,
    #[serde(default, deserialize_with = "null_default")]
    pub post_restore: Vec<Hook>,
}
impl Hooks {
    fn append(&mut self, other: Self) {
        self.pre_backup.extend(other.pre_backup);
        self.post_backup.extend(other.post_backup);
        self.pre_restore.extend(other.pre_restore);
        self.post_restore.extend(other.post_restore);
    }
    pub fn all(&self) -> impl Iterator<Item = &Hook> {
        self.pre_backup
            .iter()
            .chain(&self.post_backup)
            .chain(&self.pre_restore)
            .chain(&self.post_restore)
    }
}
#[derive(Clone, Copy, Deserialize, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Symlinks {
    Referece,
    Dereference,
    Prompt,
}
#[derive(Deserialize)]
pub struct Config {
    pub symlinks: Symlinks,
    pub destination: PathBuf,
    pub rules: Vec<Rule>,
    #[serde(default, deserialize_with = "null_default")]
    pub hooks: Hooks,
    #[serde(default)]
    pub clean: bool,
    #[serde(default = "default_true")]
    pub metadata: bool,
    #[serde(default, deserialize_with = "null_default")]
    include: Vec<Include>,
    #[serde(skip)]
    pub directory: PathBuf,
}
impl Config {
    pub fn all_hooks(&self) -> impl Iterator<Item = &Hook> {
        self.rules
            .iter()
            .flat_map(|r| r.on_start.iter().chain(&r.on_end))
            .chain(self.hooks.all())
    }
}
pub fn absolute(path: &Path, base: &Path) -> Result<PathBuf> {
    let path = if path == Path::new("~") || path.starts_with("~/") {
        let home = std::env::var_os("HOME").ok_or("HOME is not set")?;
        PathBuf::from(home).join(path.strip_prefix("~").unwrap())
    } else {
        path.to_path_buf()
    };
    Ok(if path.is_absolute() {
        path
    } else {
        base.join(path)
    })
}
pub fn load(path: &Path) -> Result<Config> {
    fn read(
        path: &Path,
        stack: &mut HashSet<PathBuf>,
        inherited: Option<(Symlinks, bool)>,
        wrap: &Path,
    ) -> Result<Config> {
        let path = fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display()))?;
        if !stack.insert(path.clone()) {
            return Err(format!("Include cycle at {}", path.display()));
        }
        let source = fs::read_to_string(&path).map_err(|e| e.to_string())?;
        let mut cfg: Config =
            serde_yaml_ng::from_str(&source).map_err(|e| format!("{}: {e}", path.display()))?;
        cfg.directory = path.parent().unwrap().to_path_buf();
        cfg.destination = absolute(&cfg.destination, &cfg.directory)?;
        let (symlinks, metadata) = inherited.unwrap_or((cfg.symlinks, cfg.metadata));
        for (index, rule) in cfg.rules.iter_mut().enumerate() {
            for (field, pattern) in std::iter::once(("from", rule.src.to_string_lossy())).chain(
                rule.targets
                    .iter()
                    .map(|target| ("targets", target.as_str().into())),
            ) {
                glob::Pattern::new(&pattern).map_err(|error| {
                    format!(
                        "{}: rule {}: invalid {field} glob {pattern:?}: {error}",
                        path.display(),
                        index + 1
                    )
                })?;
            }
            rule.src = absolute(&rule.src, &cfg.directory)?;
            for relative in
                std::iter::once(rule.to.as_path()).chain(rule.targets.iter().map(Path::new))
            {
                if relative.is_absolute()
                    || relative
                        .components()
                        .any(|c| matches!(c, std::path::Component::ParentDir))
                {
                    return Err("Rule targets and to must be relative paths without '..'".into());
                }
            }
            rule.to = wrap.join(&rule.to);
            rule.symlinks = Some(rule.symlinks.unwrap_or(symlinks));
            rule.metadata = Some(rule.metadata.unwrap_or(metadata));
        }
        for h in cfg.all_hooks() {
            if h.name.is_empty()
                || h.command.is_empty()
                || h.command[0].is_empty()
                || h.check
                    .as_ref()
                    .is_some_and(|c| c.is_empty() || c[0].is_empty())
            {
                return Err("Hook name and command/check executable must not be empty".into());
            }
        }
        let mut rules = Vec::new();
        let mut hooks = Hooks::default();
        for include in &cfg.include {
            let prefix = if let Some(folder) = &include.wrap {
                if folder.as_os_str().is_empty()
                    || folder.is_absolute()
                    || folder
                        .components()
                        .any(|c| matches!(c, std::path::Component::ParentDir))
                    || folder == Path::new(".")
                {
                    return Err(format!(
                        "{}: include wrap must be a non-empty relative path without '..'",
                        path.display()
                    ));
                }
                wrap.join(folder)
            } else {
                wrap.to_path_buf()
            };
            let child = read(
                &absolute(&include.file, &cfg.directory)?,
                stack,
                Some((
                    include.symlinks.unwrap_or(symlinks),
                    include.metadata.unwrap_or(metadata),
                )),
                &prefix,
            )?;
            rules.extend(child.rules);
            hooks.append(child.hooks);
        }
        rules.extend(cfg.rules);
        hooks.append(cfg.hooks);
        cfg.rules = rules;
        cfg.hooks = hooks;
        stack.remove(&path);
        Ok(cfg)
    }
    read(path, &mut HashSet::new(), None, Path::new(""))
}