use crate::exec::{Cmd, Outcome, SecretsArg};
use crate::logger::*;
use crate::paths::concat_paths;
use std::io;
const DRIFT_WARNING: &str = "config file template has changed";
#[derive(Debug, PartialEq, Eq)]
pub enum Repair {
Current,
Regenerated { backup: Option<String> },
Failed(String),
}
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(),
}
}
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
}
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)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&backup, std::fs::Permissions::from_mode(0o600))?;
}
Ok(Some(backup))
}
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}"));
}
};
if let Err(err) = Cmd::new().run("init", &[], SecretsArg::Unsupported) {
return Repair::Failed(format!("chezmoi init failed: {err}"));
}
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() {
#[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();
let found = find_config_file("/nonexistent-home-for-tests");
if let Some(path) = found {
assert!(path.ends_with("chezmoi/chezmoi.toml"), "{path}");
}
}
}