stynx-code-config 3.6.1

Configuration loading and settings management
Documentation
use crate::domain::config::Settings;
use std::path::Path;

pub fn load_config() -> Settings {
    let global = load_file(&global_settings_path());
    let project = load_file(&project_settings_path());
    merge(global, project)
}

fn global_settings_path() -> std::path::PathBuf {
    let home = super::platform::home_dir()
        .unwrap_or_else(|| std::path::PathBuf::from("."));
    home.join(".stynx").join("settings.json")
}

fn project_settings_path() -> std::path::PathBuf {
    std::env::current_dir()
        .unwrap_or_else(|_| std::path::PathBuf::from("."))
        .join(".stynx")
        .join("settings.json")
}

fn load_file(path: &Path) -> Settings {
    match std::fs::read_to_string(path) {
        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
        Err(_) => Settings::default(),
    }
}

fn merge(global: Settings, project: Settings) -> Settings {
    let mut allow = global.permissions.allow;
    allow.extend(project.permissions.allow);

    let mut deny = global.permissions.deny;
    deny.extend(project.permissions.deny);

    let mut pre_tool_use = global.hooks.pre_tool_use;
    pre_tool_use.extend(project.hooks.pre_tool_use);
    let mut post_tool_use = global.hooks.post_tool_use;
    post_tool_use.extend(project.hooks.post_tool_use);
    let mut stop = global.hooks.stop;
    stop.extend(project.hooks.stop);
    let mut session_start = global.hooks.session_start;
    session_start.extend(project.hooks.session_start);

    let mut interns = global.interns;
    for p in project.interns {
        if let Some(slot) = interns.iter_mut().find(|i| i.name == p.name) {
            *slot = p;
        } else {
            interns.push(p);
        }
    }

    Settings {
        permissions: crate::domain::config::PermissionSettings { allow, deny },
        model: project.model.or(global.model),
        hooks: crate::domain::config::HooksConfig { pre_tool_use, post_tool_use, stop, session_start },
        max_turns: project.max_turns.or(global.max_turns),
        max_tokens: project.max_tokens.or(global.max_tokens),
        effort: project.effort.or(global.effort),
        commit_attribution: project.commit_attribution || global.commit_attribution,
        interns,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::config::{HooksConfig, PermissionSettings, Settings};

    #[test]
    fn test_merge_concatenates_permissions() {
        let global = Settings {
            permissions: PermissionSettings {
                allow: vec!["read".into(), "glob".into()],
                deny: vec!["bash(rm -rf *)".into()],
            },
            model: Some("global-model".into()),
            ..Default::default()
        };
        let project = Settings {
            permissions: PermissionSettings {
                allow: vec!["grep".into()],
                deny: vec![],
            },
            model: None,
            ..Default::default()
        };
        let merged = merge(global, project);
        assert_eq!(merged.permissions.allow, vec!["read", "glob", "grep"]);
        assert_eq!(merged.permissions.deny, vec!["bash(rm -rf *)"]);
        assert_eq!(merged.model, Some("global-model".into()));
    }

    #[test]
    fn test_merge_project_model_overrides() {
        let global = Settings {
            model: Some("global-model".into()),
            ..Default::default()
        };
        let project = Settings {
            model: Some("project-model".into()),
            ..Default::default()
        };
        let merged = merge(global, project);
        assert_eq!(merged.model, Some("project-model".into()));
    }

    #[test]
    fn test_merge_effort_project_overrides_global() {
        let global = Settings {
            effort: Some("medium".into()),
            ..Default::default()
        };
        let project = Settings {
            effort: Some("high".into()),
            ..Default::default()
        };
        let merged = merge(global, project);
        assert_eq!(merged.effort, Some("high".into()));
    }

    #[test]
    fn test_merge_effort_falls_back_to_global() {
        let global = Settings {
            effort: Some("low".into()),
            ..Default::default()
        };
        let project = Settings::default();
        let merged = merge(global, project);
        assert_eq!(merged.effort, Some("low".into()));
    }

    #[test]
    fn test_merge_effort_none_when_both_unset() {
        let merged = merge(Settings::default(), Settings::default());
        assert_eq!(merged.effort, None);
    }
}