iforgor 0.3.3

The CLI tool for all those commands you forget about
Documentation
use {
    serde::{Deserialize, Serialize},
    std::path::Path,
};

use crate::{
    command::{AfterRun, Shell},
    discover,
    on_disk::OnDisk,
};

const CONFIG_FILE: &str = ".config.toml";

/// Configuration loaded from `.iforgor/.config.toml`.
/// When merging multiple configs, project-level overrides global (closest wins).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_shell: Option<Shell>,

    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default_after_run: Option<AfterRun>,
}

impl Config {
    /// Load and merge configs from all discovered `.iforgor/` dirs.
    /// Closest (most specific) folder wins for each field.
    pub fn load_merged(start_dir: &Path) -> Self {
        let dirs = discover::discover_iforgor_dirs(start_dir);
        let mut merged = Config::default();

        // dirs are ordered closest-first, so first non-None wins.
        for dir in &dirs {
            let config_path = dir.join(CONFIG_FILE);
            match OnDisk::<Config>::open(config_path.clone()) {
                Ok(config) => {
                    if merged.default_shell.is_none() {
                        merged.default_shell = config.default_shell.clone();
                    }
                    if merged.default_after_run.is_none() {
                        merged.default_after_run = config.default_after_run.clone();
                    }
                }
                Err(e) if config_path.exists() => {
                    eprintln!("Warning: failed to parse {}: {e}", config_path.display());
                }
                _ => {}
            }
        }

        merged
    }
}

#[cfg(test)]
mod tests {
    use {super::*, std::fs};

    #[test]
    fn parse_empty_config() {
        let config: Config = toml::from_str("").unwrap();
        assert!(config.default_shell.is_none());
        assert!(config.default_after_run.is_none());
    }

    #[test]
    fn parse_default_shell() {
        let config: Config = toml::from_str(r#"default_shell = "bash""#).unwrap();
        assert!(config.default_shell.is_some());
    }

    #[test]
    fn parse_default_after_run() {
        let config: Config = toml::from_str(r#"default_after_run = "wait""#).unwrap();
        assert!(matches!(config.default_after_run, Some(AfterRun::Wait)));
    }

    #[test]
    fn parse_full_config() {
        let config: Config = toml::from_str(
            r#"
            default_shell = "zsh"
            default_after_run = { Delay = 3 }
            "#,
        )
        .unwrap();
        assert!(config.default_shell.is_some());
        assert!(matches!(config.default_after_run, Some(AfterRun::Delay(3))));
    }

    #[test]
    fn load_merged_closest_wins() {
        let tmp = tempfile::tempdir().unwrap();

        // Create nested .iforgor dirs: project/.iforgor and project/sub/.iforgor
        let project_iforgor = tmp.path().join(".iforgor");
        let sub_iforgor = tmp.path().join("sub").join(".iforgor");
        fs::create_dir_all(&project_iforgor).unwrap();
        fs::create_dir_all(&sub_iforgor).unwrap();

        // Project-level: shell=bash, after_run=wait
        fs::write(
            project_iforgor.join(".config.toml"),
            r#"default_shell = "bash"
default_after_run = "wait""#,
        )
        .unwrap();

        // Sub-level (closer): shell=zsh only
        fs::write(sub_iforgor.join(".config.toml"), r#"default_shell = "zsh""#).unwrap();

        let config = Config::load_merged(&tmp.path().join("sub"));
        // Closest wins for shell
        assert!(matches!(
            config.default_shell,
            Some(Shell::Predefined(crate::command::PredefinedShell::Zsh))
        ));
        // Falls back to project-level for after_run
        assert!(matches!(config.default_after_run, Some(AfterRun::Wait)));
    }
}