cockup 0.2.3

Yet another backup tool for various configurations
use crate::ingredients::{Mode, Resolver};
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,
    #[serde(deserialize_with = "shell_command")]
    pub command: String,
    #[serde(default, deserialize_with = "shell_check")]
    pub check: Option<String>,
    #[serde(default)]
    pub cwd: Option<PathBuf>,
    #[serde(default)]
    pub output: bool,
    #[serde(default)]
    pub timeout: Option<u64>,
    #[serde(default, deserialize_with = "environment")]
    pub env: BTreeMap<String, String>,
}
fn shell_text<E: serde::de::Error>(value: serde_yaml_ng::Value) -> std::result::Result<String, E> {
    match value {
        serde_yaml_ng::Value::String(text) if !text.trim().is_empty() => Ok(text),
        serde_yaml_ng::Value::Sequence(_) => Err(E::custom(
            "command/check arrays are no longer supported; use a shell string executed by /bin/sh -cu",
        )),
        _ => Err(E::custom("command/check must be a nonempty shell string")),
    }
}
fn shell_command<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<String, D::Error> {
    shell_text(serde_yaml_ng::Value::deserialize(d)?)
}
fn shell_check<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<String>, D::Error> {
    Option::<serde_yaml_ng::Value>::deserialize(d)?
        .map(shell_text)
        .transpose()
}
#[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 {
    #[serde(default)]
    file: Option<PathBuf>,
    #[serde(default)]
    ingredient: Option<String>,
    #[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 all_mut(&mut self) -> impl Iterator<Item = &mut Hook> {
        self.pre_backup
            .iter_mut()
            .chain(&mut self.post_backup)
            .chain(&mut self.pre_restore)
            .chain(&mut self.post_restore)
    }
    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 {
    Reference,
    Dereference,
    Prompt,
}
#[derive(Deserialize)]
pub struct Config {
    #[serde(default)]
    pub symlinks: Option<Symlinks>,
    #[serde(default, deserialize_with = "null_default")]
    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,
    #[serde(skip)]
    pub ingredients: BTreeMap<String, String>,
}
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 destination(path: &Path) -> Result<PathBuf> {
    let path = fs::canonicalize(path).map_err(|error| format!("{}: {error}", path.display()))?;
    let source = fs::read_to_string(&path).map_err(|error| error.to_string())?;
    let cfg: Config =
        serde_yaml_ng::from_str(&source).map_err(|error| format!("{}: {error}", path.display()))?;
    if cfg.destination.as_os_str().is_empty() {
        return Err(format!("{}: destination is required", path.display()));
    }
    absolute(&cfg.destination, path.parent().unwrap())
}

pub fn load(path: &Path, mode: Mode) -> Result<Config> {
    fn read(
        path: &Path,
        override_source: Option<&str>,
        stack: &mut HashSet<PathBuf>,
        inherited: Option<(Symlinks, bool)>,
        wrap: &Path,
        resolver: &mut Resolver,
    ) -> Result<Config> {
        let path = if override_source.is_some() {
            path.to_path_buf()
        } else {
            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 = match override_source {
            Some(source) => source.to_string(),
            None => 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();
        if inherited.is_none() && cfg.destination.as_os_str().is_empty() {
            return Err(format!(
                "{}: destination is required to run this config\n=> configs without it are include-only ingredients",
                path.display()
            ));
        }
        if !cfg.destination.as_os_str().is_empty() {
            cfg.destination = absolute(&cfg.destination, &cfg.directory)?;
        }
        let (symlinks, metadata) = match inherited {
            Some(settings) => settings,
            None => (
                cfg.symlinks
                    .ok_or_else(|| format!("{}: symlinks is required", path.display()))?,
                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
            .rules
            .iter_mut()
            .flat_map(|r| r.on_start.iter_mut().chain(&mut r.on_end))
            .chain(cfg.hooks.all_mut())
        {
            if let Some(cwd) = &h.cwd {
                if cwd.as_os_str().is_empty() {
                    return Err(format!("Hook `{}` cwd must not be empty", h.name));
                }
                h.cwd = Some(absolute(cwd, &cfg.directory)?);
            }
            if h.name.is_empty() {
                return Err("Hook name must not be empty".into());
            }
        }
        let mut rules = Vec::new();
        let mut hooks = Hooks::default();
        for include in &cfg.include {
            if include.file.is_some() == include.ingredient.is_some() {
                return Err(format!(
                    "{}: include must specify exactly one of file or ingredient",
                    path.display()
                ));
            }
            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_path, child_source) = if let Some(file) = &include.file {
                (absolute(file, &cfg.directory)?, None)
            } else {
                let (path, source) = resolver.resolve(include.ingredient.as_deref().unwrap())?;
                (path, Some(source))
            };
            let child = read(
                &child_path,
                child_source.as_deref(),
                stack,
                Some((
                    include.symlinks.unwrap_or(symlinks),
                    include.metadata.unwrap_or(metadata),
                )),
                &prefix,
                resolver,
            )?;
            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)
    }
    let mut resolver = Resolver::new(mode);
    let mut cfg = read(
        path,
        None,
        &mut HashSet::new(),
        None,
        Path::new(""),
        &mut resolver,
    )?;
    cfg.ingredients = resolver.used;
    Ok(cfg)
}