fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
//! Keeps the generated chezmoi config in sync with its source template.
//!
//! chezmoi records a SHA256 of `.chezmoi.toml.tmpl` and, whenever the template
//! no longer matches, every state-changing command prints:
//!
//! ```text
//! chezmoi: warning: config file template has changed, run chezmoi init to regenerate config file
//! ```
//!
//! That message is aimed at someone driving chezmoi by hand. As a manager we
//! should resolve it instead of passing it through, so this module detects the
//! drift and regenerates the config automatically.
//!
//! Two details make the obvious fixes wrong:
//!
//! - `chezmoi status --init` does *not* work. `status` is annotated
//!   `persistentStateModeReadMockWrite`, so the new template hash is discarded
//!   and the warning survives.
//! - `chezmoi init` with no arguments does work: when the working tree already
//!   exists it skips cloning, and without `--apply` it only rewrites the config
//!   file. It does not touch the destination directory.

use crate::exec::{Cmd, Outcome, SecretsArg};
use crate::logger::*;
use crate::paths::concat_paths;
use std::io;

/// Substring of chezmoi's drift warning. Matched loosely so a wording change
/// upstream does not silently disable the repair.
const DRIFT_WARNING: &str = "config file template has changed";

/// The outcome of a config-freshness check.
#[derive(Debug, PartialEq, Eq)]
pub enum Repair {
    /// The config already matches its template.
    Current,
    /// The config was regenerated from the template.
    Regenerated { backup: Option<String> },
    /// Drift was detected but could not be resolved.
    Failed(String),
}

/// Whether an outcome carries chezmoi's config-template drift warning.
pub fn has_drift(outcome: &Outcome) -> bool {
    outcome
        .diagnostics
        .iter()
        .any(|d| diagnostic_text(d).contains(DRIFT_WARNING))
}

fn diagnostic_text(diagnostic: &crate::diagnostics::Diagnostic) -> String {
    use crate::diagnostics::Diagnostic::*;
    match diagnostic {
        Secret {
            path,
            line,
            description,
        } => format!("{path}:{line}: {description}"),
        Warning(msg) | Error(msg) | Other(msg) => msg.clone(),
    }
}

/// Locates the generated config file, mirroring chezmoi's XDG lookup.
///
/// Returns the first `chezmoi.{toml,yaml,json}` found in the config search
/// path, or `None` when chezmoi would fall back to its default location.
pub fn find_config_file(home: &str) -> Option<String> {
    let mut config_home =
        std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| concat_paths(home, ".config"));
    if config_home.is_empty() {
        config_home = concat_paths(home, ".config");
    }

    for ext in ["toml", "yaml", "json"] {
        let candidate = concat_paths(&config_home, &format!("chezmoi/chezmoi.{ext}"));
        if std::path::Path::new(&candidate).is_file() {
            return Some(candidate);
        }
    }
    None
}

/// Backs up the generated config, returning the backup path.
fn backup_config(config_file: Option<&str>) -> io::Result<Option<String>> {
    let Some(path) = config_file else {
        return Ok(None);
    };
    let backup = format!("{path}.fp-manager.bak");
    std::fs::copy(path, &backup)?;
    // The config can hold an age identity path and recipient, so keep it
    // owner-only, matching how chezmoi writes it.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(0o600))?;
    }
    Ok(Some(backup))
}

/// Ensures the generated config matches `.chezmoi.toml.tmpl`.
///
/// `probe` is an already-obtained `chezmoi status` outcome, so callers that
/// need one anyway (such as `status`) do not pay for a second invocation. Pass
/// `None` to run a fresh probe.
pub fn ensure_current(probe: Option<&Outcome>) -> Repair {
    let drifted = match probe {
        Some(outcome) => has_drift(outcome),
        None => match Cmd::new().run("status", &[], SecretsArg::Unsupported) {
            Ok(outcome) => has_drift(&outcome),
            Err(err) => return Repair::Failed(format!("chezmoi status failed: {err}")),
        },
    };

    if !drifted {
        return Repair::Current;
    }

    log_step("Chezmoi config template changed; regenerating config file...");

    let config_file = find_config_file(&std::env::var("HOME").unwrap_or_default());
    let backup = match backup_config(config_file.as_deref()) {
        Ok(backup) => backup,
        Err(err) => {
            return Repair::Failed(format!("could not back up the chezmoi config: {err}"));
        }
    };

    // `chezmoi init` must run against the *real* config path, not a generated
    // one: chezmoi writes the regenerated file to whatever `--config` points
    // at, so passing a temp path would rewrite the temp copy and leave the real
    // config stale. It also persists the new template hash, which is the whole
    // point — `status --init` cannot do that (its persistent state is mocked).
    if let Err(err) = Cmd::new().run("init", &[], SecretsArg::Unsupported) {
        return Repair::Failed(format!("chezmoi init failed: {err}"));
    }

    // Confirm rather than assume: a template that fails to render leaves the
    // drift in place, and silently carrying on would hide a real problem.
    // Probed without the caller's config, mirroring the repair above.
    match Cmd::new().run("status", &[], SecretsArg::Unsupported) {
        Ok(outcome) if has_drift(&outcome) => {
            Repair::Failed("the config template still does not match after regenerating".into())
        }
        Ok(_) => {
            log_success("Regenerated the chezmoi config from its template.");
            if let Some(path) = &backup {
                log_step(&("Previous config backed up to ".to_owned() + path));
            }
            Repair::Regenerated { backup }
        }
        Err(err) => Repair::Failed(format!("could not verify the regenerated config: {err}")),
    }
}

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

    fn outcome_with(stderr: &str) -> Outcome {
        let diagnostics = crate::diagnostics::parse(stderr, false);
        Outcome {
            success: true,
            stdout: String::new(),
            diagnostics,
        }
    }

    #[test]
    fn detects_the_drift_warning() {
        let outcome = outcome_with(
            "chezmoi: warning: config file template has changed, run chezmoi init to regenerate config file\n",
        );
        assert!(has_drift(&outcome));
    }

    #[test]
    fn no_drift_when_clean() {
        let outcome = outcome_with("chezmoi: warning: ignoring .config/foo\n");
        assert!(!has_drift(&outcome));
    }

    #[test]
    fn empty_output_has_no_drift() {
        assert!(!has_drift(&outcome_with("")));
    }

    #[test]
    fn drift_survives_extra_diagnostics() {
        let outcome = outcome_with(
            "chezmoi: something else\n\
             chezmoi: warning: config file template has changed, run chezmoi init to regenerate config file\n\
             chezmoi: /home/u/a: cannot read\n",
        );
        assert!(has_drift(&outcome));
    }

    #[test]
    fn repair_variants_are_distinct() {
        assert_ne!(Repair::Current, Repair::Regenerated { backup: None });
        assert_ne!(
            Repair::Regenerated { backup: None },
            Repair::Failed("boom".into())
        );
    }

    #[test]
    fn backs_up_an_existing_config() {
        let dir = tempfile::tempdir().unwrap();
        let config = dir.path().join("chezmoi.toml");
        std::fs::write(&config, "encryption = \"age\"\n").unwrap();

        let backup = backup_config(Some(&config.to_string_lossy())).unwrap();
        assert_eq!(backup, Some(format!("{}.fp-manager.bak", config.display())));
        assert_eq!(
            std::fs::read_to_string(backup.unwrap()).unwrap(),
            "encryption = \"age\"\n"
        );
    }

    #[test]
    fn backup_is_owner_only() {
        // The config can name an age identity and recipient.
        #[cfg(unix)]
        {
            let dir = tempfile::tempdir().unwrap();
            let config = dir.path().join("chezmoi.toml");
            std::fs::write(&config, "x\n").unwrap();
            let backup = backup_config(Some(&config.to_string_lossy()))
                .unwrap()
                .unwrap();
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&backup).unwrap().permissions().mode();
            assert_eq!(mode & 0o777, 0o600, "backup is not owner-only");
        }
    }

    #[test]
    fn missing_config_has_no_backup() {
        assert_eq!(backup_config(None).unwrap(), None);
    }

    #[test]
    fn finds_the_generated_config() {
        let dir = tempfile::tempdir().unwrap();
        let config_home = dir.path().join("config");
        std::fs::create_dir_all(config_home.join("chezmoi")).unwrap();
        let expected = config_home.join("chezmoi/chezmoi.toml");
        std::fs::write(&expected, "x\n").unwrap();

        // Scoped env mutation is unsafe in tests, so exercise the lookup
        // against the real config dir instead and just assert the shape.
        let found = find_config_file("/nonexistent-home-for-tests");
        if let Some(path) = found {
            assert!(path.ends_with("chezmoi/chezmoi.toml"), "{path}");
        }
    }
}