fp-dotfiles-manager 0.2.5

Minimal, zero-dependency Chezmoi-based dotfiles manager
use crate::exec::ScanPolicy;
use crate::paths::concat_paths;
use std::io::BufRead;

pub struct Config {
    pub home: String,
    pub tracked_file: String,
    pub tracked_enc_file: String,
    pub bootstrap_dir: String,
    pub ssh_key_path: String,
    pub age_pass: String,
    pub repo_path: String,
    pub repo_url: String,
    pub pre_sync_hook: String,
    pub post_sync_hook: String,
    pub secret_scan: String,
}

fn clean_yaml_val(val: &str, home: &str) -> String {
    let mut cleaned = val.trim().to_string();
    if cleaned.starts_with('"') && cleaned.ends_with('"') {
        cleaned = cleaned[1..cleaned.len() - 1].to_string();
    } else if cleaned.starts_with('\'') && cleaned.ends_with('\'') {
        cleaned = cleaned[1..cleaned.len() - 1].to_string();
    }
    if cleaned.starts_with('~') {
        cleaned = cleaned.replacen('~', home, 1);
    }
    cleaned
}

impl Config {
    pub fn load() -> Self {
        let home = std::env::var("HOME").unwrap_or_default();

        // 1. Establish core defaults
        let mut tracked_file = concat_paths(&home, ".config/fp-dotfiles-manager/tracked");
        let mut tracked_enc_file =
            concat_paths(&home, ".config/fp-dotfiles-manager/tracked_encrypted");
        let mut bootstrap_dir = concat_paths(&home, ".config/fp-dotfiles-manager/bootstrap.d");
        let mut ssh_key_path = concat_paths(&home, ".config/fp-dotfiles-manager/ssh_gitlab");
        let mut age_pass = String::new();
        let mut repo_path = "fptbb/dotfiles".to_string();
        let mut repo_url = "git@gitlab.com:fptbb/dotfiles.git".to_string();
        let mut pre_sync_hook =
            concat_paths(&home, ".config/fp-dotfiles-manager/hooks/pre-sync.sh");
        let mut post_sync_hook =
            concat_paths(&home, ".config/fp-dotfiles-manager/hooks/post-sync.sh");
        let mut secret_scan = "enforce".to_string();

        // 2. Read from ~/.config/fp-dotfiles-manager/config.yml if it exists
        let config_file_path = concat_paths(&home, ".config/fp-dotfiles-manager/config.yml");
        if let Ok(file) = std::fs::File::open(&config_file_path) {
            let reader = std::io::BufReader::new(file);
            for line in reader.lines().flatten() {
                let trimmed = line.trim();
                if trimmed.is_empty() || trimmed.starts_with('#') {
                    continue;
                }
                if let Some(idx) = trimmed.find(':') {
                    let key = trimmed[..idx].trim();
                    let val = &trimmed[idx + 1..];
                    match key {
                        "tracked_file" => tracked_file = clean_yaml_val(val, &home),
                        "tracked_enc_file" => tracked_enc_file = clean_yaml_val(val, &home),
                        "bootstrap_dir" => bootstrap_dir = clean_yaml_val(val, &home),
                        "ssh_key_path" => ssh_key_path = clean_yaml_val(val, &home),
                        "age_pass" => age_pass = clean_yaml_val(val, &home),
                        "repo_path" => {
                            repo_path = clean_yaml_val(val, &home);
                            // If repo_url was not customized, update it to match new repo_path
                            repo_url = "git@gitlab.com:".to_owned() + &repo_path + ".git";
                        }
                        "repo_url" => repo_url = clean_yaml_val(val, &home),
                        "pre_sync_hook" => pre_sync_hook = clean_yaml_val(val, &home),
                        "post_sync_hook" => post_sync_hook = clean_yaml_val(val, &home),
                        "secret_scan" => {
                            secret_scan = if ScanPolicy::parse(val).is_some() {
                                clean_yaml_val(val, &home)
                            } else {
                                eprintln!(
                                    "Warning: ignoring invalid secret_scan value {:?} (expected off, warn, or enforce)",
                                    val.trim()
                                );
                                secret_scan
                            };
                        }
                        _ => {}
                    }
                }
            }
        }

        // 3. Override values via environment variables if present
        if let Ok(val) = std::env::var("DOTFILES_TRACKED") {
            tracked_file = clean_yaml_val(&val, &home);
        }
        if let Ok(val) = std::env::var("DOTFILES_TRACKED_ENC") {
            tracked_enc_file = clean_yaml_val(&val, &home);
        }
        if let Ok(val) = std::env::var("DOTFILES_BOOTSTRAP_DIR") {
            bootstrap_dir = clean_yaml_val(&val, &home);
        }
        if let Ok(val) = std::env::var("DOTFILES_SSH_KEY") {
            ssh_key_path = clean_yaml_val(&val, &home);
        }
        if let Ok(val) = std::env::var("DOTFILES_AGE_PASS") {
            age_pass = val;
        }
        if let Ok(val) = std::env::var("DOTFILES_REPO_PATH") {
            repo_path = val;
            repo_url = "git@gitlab.com:".to_owned() + &repo_path + ".git";
        }
        if let Ok(val) = std::env::var("DOTFILES_REPO_URL") {
            repo_url = clean_yaml_val(&val, &home);
        }
        if let Ok(val) = std::env::var("DOTFILES_PRE_SYNC") {
            pre_sync_hook = clean_yaml_val(&val, &home);
        }
        if let Ok(val) = std::env::var("DOTFILES_POST_SYNC") {
            post_sync_hook = clean_yaml_val(&val, &home);
        }
        if let Ok(val) = std::env::var("DOTFILES_SECRET_SCAN") {
            secret_scan = if ScanPolicy::parse(&val).is_some() {
                val
            } else {
                eprintln!(
                    "Warning: ignoring invalid DOTFILES_SECRET_SCAN value {:?} (expected off, warn, or enforce)",
                    val
                );
                secret_scan
            };
        }

        Self {
            home,
            tracked_file,
            tracked_enc_file,
            bootstrap_dir,
            ssh_key_path,
            age_pass,
            repo_path,
            repo_url,
            pre_sync_hook,
            post_sync_hook,
            secret_scan,
        }
    }

    /// The resolved secret-scan policy, falling back to `enforce` if the
    /// configured value is unusable.
    pub fn scan_policy(&self) -> ScanPolicy {
        ScanPolicy::parse(&self.secret_scan).unwrap_or(ScanPolicy::Enforce)
    }
}