sennit 0.9.0

A dotfiles manager that keeps symlink semantics, and adds templating and drift detection
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;

/// 配置したあとに走らせる処理。
///
/// 「設定を置いたら、それを取り込むために何かを実行する」という関係は
/// これまで表現できず、シェルスクリプトに置くしかなかった。結果として
/// 毎回無条件に走り、依存関係もどこにも書かれていなかった。
///
/// 実例: .config/bat/themes/ を配置しても、bat cache --build を踏まないと
/// テーマ名が解決できず、delta まで既定色に落ちる。
#[derive(Debug, Deserialize)]
pub struct Hook {
    /// このパスが変わったときだけ走らせる。空なら毎回走る。
    #[serde(default, rename = "when-changed")]
    pub when_changed: Vec<String>,
    pub run: String,
    /// 実行するディレクトリ(リポジトリルートからの相対)。既定はルート。
    #[serde(default)]
    pub cwd: Option<String>,
}

/// 監視対象の現在の指紋。内容とパスの両方を見る。
pub fn fingerprint(root: &Path, paths: &[String]) -> String {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};

    let mut entries: Vec<(String, u64)> = Vec::new();
    for rel in paths {
        collect(&root.join(rel), root, &mut entries, 0);
    }
    entries.sort();

    let mut h = DefaultHasher::new();
    entries.hash(&mut h);
    format!("{:016x}", h.finish())
}

fn collect(path: &Path, root: &Path, out: &mut Vec<(String, u64)>, depth: usize) {
    if depth > 8 {
        return;
    }
    let Ok(meta) = std::fs::symlink_metadata(path) else {
        return;
    };
    if meta.is_dir() {
        let Ok(entries) = std::fs::read_dir(path) else {
            return;
        };
        for e in entries.filter_map(|e| e.ok()) {
            collect(&e.path(), root, out, depth + 1);
        }
        return;
    }
    // 内容そのものではなくサイズと更新時刻で見る。設定は小さいが、
    // フォントの tmTheme のように大きいものもあるため。
    let key = path
        .strip_prefix(root)
        .unwrap_or(path)
        .display()
        .to_string();
    let stamp = meta
        .modified()
        .ok()
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_secs())
        .unwrap_or(0);
    out.push((key, stamp ^ (meta.len() << 20)));
}

/// 変化したフックだけを走らせ、新しい指紋を返す。
pub fn run_all(
    root: &Path,
    hooks: &BTreeMap<String, Hook>,
    previous: &BTreeMap<String, String>,
    dry_run: bool,
) -> Result<BTreeMap<String, String>> {
    let mut next = previous.clone();

    for (name, hook) in hooks {
        let fp = fingerprint(root, &hook.when_changed);
        // when-changed が空なら毎回走らせる
        let changed = hook.when_changed.is_empty() || previous.get(name) != Some(&fp);
        if !changed {
            continue;
        }

        println!("  {:>8}  {name}", "hook");
        if dry_run {
            continue;
        }

        let dir = match &hook.cwd {
            Some(c) => root.join(c),
            None => root.to_path_buf(),
        };
        let status = Command::new("sh")
            .arg("-c")
            .arg(&hook.run)
            .current_dir(&dir)
            .status()
            .with_context(|| format!("hook `{name}`: failed to run"))?;
        if !status.success() {
            bail!("hook `{name}` failed: {}", hook.run);
        }
        next.insert(name.clone(), fp);
    }
    Ok(next)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn hook(when: &[&str]) -> Hook {
        Hook {
            when_changed: when.iter().map(|s| s.to_string()).collect(),
            run: "true".into(),
            cwd: None,
        }
    }

    /// 監視対象が無いフックは毎回走る。「常に実行」を表現する手段が要る。
    #[test]
    fn a_hook_without_watches_always_runs() {
        let dir = std::env::temp_dir();
        let mut hooks = BTreeMap::new();
        hooks.insert("always".to_string(), hook(&[]));
        let seen = BTreeMap::new();
        // 走ったかどうかは戻り値では分からないので、指紋が入らないことで見る
        let next = run_all(&dir, &hooks, &seen, true).unwrap();
        assert!(next.is_empty());
    }

    /// 同じ内容なら指紋も同じ。ここが揺れるとフックが毎回走ってしまう。
    #[test]
    fn fingerprint_is_stable_for_unchanged_content() {
        let dir = std::env::temp_dir().join("sennit-fp-test");
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("a"), "x").unwrap();
        let a = fingerprint(&dir, &["a".into()]);
        let b = fingerprint(&dir, &["a".into()]);
        assert_eq!(a, b);
        std::fs::remove_dir_all(&dir).ok();
    }

    /// 存在しないパスでも落ちない。宣言だけ先に書くことがある。
    #[test]
    fn missing_paths_do_not_panic() {
        let fp = fingerprint(Path::new("/nonexistent"), &["nope".into()]);
        assert!(!fp.is_empty());
    }
}