capo-agent 0.6.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
//! `Settings::load` — overlay default → settings.json → env → CLI.

use std::path::Path;

use crate::error::{AppError, Result};
use crate::paths::agent_dir;
use crate::settings::{CliOverrides, Settings};

/// Convenience: load using real env (`std::env::var`) and the default agent dir.
pub fn load(cli: &CliOverrides) -> Result<Settings> {
    load_with(&agent_dir(), cli, |name| std::env::var(name).ok())
}

/// Load `Settings`, overlaying file (if present) → env → CLI.
///
/// `env_lookup` is a closure so tests can inject deterministic env values
/// without mutating the process environment.
pub fn load_with<F>(agent_dir: &Path, cli: &CliOverrides, env_lookup: F) -> Result<Settings>
where
    F: Fn(&str) -> Option<String>,
{
    let mut settings = Settings::default();

    // 1. File layer: ~/.capo/agent/settings.json
    let file_path = agent_dir.join("settings.json");
    if file_path.exists() {
        let raw = std::fs::read_to_string(&file_path).map_err(|err| {
            AppError::Config(format!("failed to read {}: {err}", file_path.display()))
        })?;
        let mut merged = serde_json::to_value(Settings::default())
            .map_err(|err| AppError::Config(format!("failed to encode defaults: {err}")))?;
        let file_value: serde_json::Value = serde_json::from_str(&raw).map_err(|err| {
            AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
        })?;
        merge_json(&mut merged, file_value);
        settings = serde_json::from_value(merged).map_err(|err| {
            AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
        })?;
    }

    // 2. Env layer (matches M2 mappings + room to grow)
    if let Some(name) = env_lookup("CAPO_MODEL_NAME") {
        settings.model.name = name.trim().to_string();
    }
    if let Some(provider) = env_lookup("CAPO_MODEL_PROVIDER") {
        settings.model.provider = provider.trim().to_string();
    }
    if let Some(max_tokens) = env_lookup("CAPO_MODEL_MAX_TOKENS") {
        let parsed: u32 = max_tokens.trim().parse().map_err(|_| {
            AppError::Config(format!("CAPO_MODEL_MAX_TOKENS not a u32: {max_tokens}"))
        })?;
        settings.model.max_tokens = parsed;
    }
    if let Some(base_url) = env_lookup("CAPO_ANTHROPIC_BASE_URL") {
        settings.anthropic.base_url = base_url.trim().to_string();
    }

    // 3. CLI layer (highest precedence)
    if let Some(model) = &cli.model {
        settings.model.name = model.clone();
    }

    Ok(settings)
}

fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
    match (base, overlay) {
        (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
            for (key, value) in overlay {
                match base.get_mut(&key) {
                    Some(existing) => merge_json(existing, value),
                    None => {
                        base.insert(key, value);
                    }
                }
            }
        }
        (slot, value) => *slot = value,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use tempfile::TempDir;

    fn temp_dir() -> TempDir {
        match tempfile::tempdir() {
            Ok(dir) => dir,
            Err(err) => panic!("tempdir failed: {err}"),
        }
    }

    fn lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
        move |name| map.get(name).map(|v| (*v).to_string())
    }

    #[test]
    fn missing_file_returns_defaults_with_env_overlay() {
        let dir = temp_dir();
        let env = HashMap::from([("CAPO_MODEL_NAME", "claude-opus-4-7")]);
        let s = match Settings::load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
            Ok(settings) => settings,
            Err(err) => panic!("load failed: {err}"),
        };
        assert_eq!(s.model.name, "claude-opus-4-7");
        assert_eq!(s.model.provider, "anthropic"); // default
    }

    #[test]
    fn partial_nested_file_values_overlay_defaults() {
        let dir = temp_dir();
        let path = dir.path().join("settings.json");
        if let Err(err) = std::fs::write(&path, r#"{ "model": { "name": "from-file" } }"#) {
            panic!("write failed: {err}");
        }

        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
            Ok(settings) => settings,
            Err(err) => panic!("load failed: {err}"),
        };
        assert_eq!(s.model.name, "from-file");
        assert_eq!(s.model.provider, "anthropic");
        assert_eq!(s.model.max_tokens, 8192);
    }

    #[test]
    fn env_overlays_file_overlays_default() {
        let dir = temp_dir();
        let path = dir.path().join("settings.json");
        if let Err(err) = std::fs::write(
            &path,
            r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
        ) {
            panic!("write failed: {err}");
        }

        let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
            Ok(settings) => settings,
            Err(err) => panic!("load failed: {err}"),
        };
        // Env wins over file
        assert_eq!(s.model.name, "from-env");
        // File value still applies where env didn't override
        assert_eq!(s.model.max_tokens, 4096);
    }

    #[test]
    fn cli_overlays_env_overlays_file() {
        let dir = temp_dir();
        let path = dir.path().join("settings.json");
        if let Err(err) = std::fs::write(
            &path,
            r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
        ) {
            panic!("write failed: {err}");
        }

        let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
        let cli = CliOverrides {
            model: Some("from-cli".into()),
        };
        let s = match load_with(dir.path(), &cli, lookup(env)) {
            Ok(settings) => settings,
            Err(err) => panic!("load failed: {err}"),
        };
        assert_eq!(s.model.name, "from-cli");
    }

    #[test]
    fn anthropic_base_url_loads_from_settings_json() {
        let dir = temp_dir();
        let path = dir.path().join("settings.json");
        if let Err(err) = std::fs::write(
            &path,
            r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
        ) {
            panic!("write failed: {err}");
        }

        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
            Ok(settings) => settings,
            Err(err) => panic!("load failed: {err}"),
        };
        assert_eq!(
            s.anthropic.base_url,
            "https://from-file.example.com/anthropic"
        );
        // Other sections still default.
        assert_eq!(s.model.provider, "anthropic");
    }

    #[test]
    fn anthropic_base_url_env_overlays_settings_json() {
        let dir = temp_dir();
        let path = dir.path().join("settings.json");
        if let Err(err) = std::fs::write(
            &path,
            r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
        ) {
            panic!("write failed: {err}");
        }

        let env = HashMap::from([(
            "CAPO_ANTHROPIC_BASE_URL",
            "https://from-env.example.com/anthropic",
        )]);
        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
            Ok(settings) => settings,
            Err(err) => panic!("load failed: {err}"),
        };
        assert_eq!(
            s.anthropic.base_url,
            "https://from-env.example.com/anthropic"
        );
    }

    #[test]
    fn capo_anthropic_base_url_env_overlays_default() {
        let dir = temp_dir();
        let env = HashMap::from([(
            "CAPO_ANTHROPIC_BASE_URL",
            "https://proxy.example.com/anthropic",
        )]);
        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
            Ok(settings) => settings,
            Err(err) => panic!("load failed: {err}"),
        };
        assert_eq!(s.anthropic.base_url, "https://proxy.example.com/anthropic");
    }

    #[test]
    fn malformed_json_returns_config_error_with_path() {
        let dir = temp_dir();
        let path = dir.path().join("settings.json");
        if let Err(err) = std::fs::write(&path, "{not json}") {
            panic!("write failed: {err}");
        }

        let err = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
            Ok(_) => panic!("must fail on malformed json"),
            Err(err) => err,
        };
        let msg = format!("{err}");
        assert!(msg.contains("settings.json"), "{msg}");
    }
}