Skip to main content

capo_agent/settings/
load.rs

1//! `Settings::load` — overlay default → settings.json → env → CLI.
2
3use std::path::Path;
4
5use crate::error::{AppError, Result};
6use crate::paths::agent_dir;
7use crate::settings::{CliOverrides, Settings};
8
9/// Convenience: load using real env (`std::env::var`) and the default agent dir.
10pub fn load(cli: &CliOverrides) -> Result<Settings> {
11    load_with(&agent_dir(), cli, |name| std::env::var(name).ok())
12}
13
14/// Load `Settings`, overlaying file (if present) → env → CLI.
15///
16/// `env_lookup` is a closure so tests can inject deterministic env values
17/// without mutating the process environment.
18pub fn load_with<F>(agent_dir: &Path, cli: &CliOverrides, env_lookup: F) -> Result<Settings>
19where
20    F: Fn(&str) -> Option<String>,
21{
22    let mut settings = Settings::default();
23
24    // 1. File layer: ~/.capo/agent/settings.json
25    let file_path = agent_dir.join("settings.json");
26    if file_path.exists() {
27        let raw = std::fs::read_to_string(&file_path).map_err(|err| {
28            AppError::Config(format!("failed to read {}: {err}", file_path.display()))
29        })?;
30        let mut merged = serde_json::to_value(Settings::default())
31            .map_err(|err| AppError::Config(format!("failed to encode defaults: {err}")))?;
32        let file_value: serde_json::Value = serde_json::from_str(&raw).map_err(|err| {
33            AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
34        })?;
35        merge_json(&mut merged, file_value);
36        settings = serde_json::from_value(merged).map_err(|err| {
37            AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
38        })?;
39    }
40
41    // 2. Env layer (matches M2 mappings + room to grow)
42    if let Some(name) = env_lookup("CAPO_MODEL_NAME") {
43        settings.model.name = name.trim().to_string();
44    }
45    if let Some(provider) = env_lookup("CAPO_MODEL_PROVIDER") {
46        settings.model.provider = provider.trim().to_string();
47    }
48    if let Some(max_tokens) = env_lookup("CAPO_MODEL_MAX_TOKENS") {
49        let parsed: u32 = max_tokens.trim().parse().map_err(|_| {
50            AppError::Config(format!("CAPO_MODEL_MAX_TOKENS not a u32: {max_tokens}"))
51        })?;
52        settings.model.max_tokens = parsed;
53    }
54    if let Some(base_url) = env_lookup("CAPO_ANTHROPIC_BASE_URL") {
55        settings.anthropic.base_url = base_url.trim().to_string();
56    }
57
58    // 3. CLI layer (highest precedence)
59    if let Some(model) = &cli.model {
60        settings.model.name = model.clone();
61    }
62
63    Ok(settings)
64}
65
66fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
67    match (base, overlay) {
68        (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
69            for (key, value) in overlay {
70                match base.get_mut(&key) {
71                    Some(existing) => merge_json(existing, value),
72                    None => {
73                        base.insert(key, value);
74                    }
75                }
76            }
77        }
78        (slot, value) => *slot = value,
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use std::collections::HashMap;
86    use tempfile::TempDir;
87
88    fn temp_dir() -> TempDir {
89        match tempfile::tempdir() {
90            Ok(dir) => dir,
91            Err(err) => panic!("tempdir failed: {err}"),
92        }
93    }
94
95    fn lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
96        move |name| map.get(name).map(|v| (*v).to_string())
97    }
98
99    #[test]
100    fn missing_file_returns_defaults_with_env_overlay() {
101        let dir = temp_dir();
102        let env = HashMap::from([("CAPO_MODEL_NAME", "claude-opus-4-7")]);
103        let s = match Settings::load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
104            Ok(settings) => settings,
105            Err(err) => panic!("load failed: {err}"),
106        };
107        assert_eq!(s.model.name, "claude-opus-4-7");
108        assert_eq!(s.model.provider, "anthropic"); // default
109    }
110
111    #[test]
112    fn partial_nested_file_values_overlay_defaults() {
113        let dir = temp_dir();
114        let path = dir.path().join("settings.json");
115        if let Err(err) = std::fs::write(&path, r#"{ "model": { "name": "from-file" } }"#) {
116            panic!("write failed: {err}");
117        }
118
119        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
120            Ok(settings) => settings,
121            Err(err) => panic!("load failed: {err}"),
122        };
123        assert_eq!(s.model.name, "from-file");
124        assert_eq!(s.model.provider, "anthropic");
125        assert_eq!(s.model.max_tokens, 8192);
126    }
127
128    #[test]
129    fn env_overlays_file_overlays_default() {
130        let dir = temp_dir();
131        let path = dir.path().join("settings.json");
132        if let Err(err) = std::fs::write(
133            &path,
134            r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
135        ) {
136            panic!("write failed: {err}");
137        }
138
139        let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
140        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
141            Ok(settings) => settings,
142            Err(err) => panic!("load failed: {err}"),
143        };
144        // Env wins over file
145        assert_eq!(s.model.name, "from-env");
146        // File value still applies where env didn't override
147        assert_eq!(s.model.max_tokens, 4096);
148    }
149
150    #[test]
151    fn cli_overlays_env_overlays_file() {
152        let dir = temp_dir();
153        let path = dir.path().join("settings.json");
154        if let Err(err) = std::fs::write(
155            &path,
156            r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
157        ) {
158            panic!("write failed: {err}");
159        }
160
161        let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
162        let cli = CliOverrides {
163            model: Some("from-cli".into()),
164        };
165        let s = match load_with(dir.path(), &cli, lookup(env)) {
166            Ok(settings) => settings,
167            Err(err) => panic!("load failed: {err}"),
168        };
169        assert_eq!(s.model.name, "from-cli");
170    }
171
172    #[test]
173    fn anthropic_base_url_loads_from_settings_json() {
174        let dir = temp_dir();
175        let path = dir.path().join("settings.json");
176        if let Err(err) = std::fs::write(
177            &path,
178            r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
179        ) {
180            panic!("write failed: {err}");
181        }
182
183        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
184            Ok(settings) => settings,
185            Err(err) => panic!("load failed: {err}"),
186        };
187        assert_eq!(
188            s.anthropic.base_url,
189            "https://from-file.example.com/anthropic"
190        );
191        // Other sections still default.
192        assert_eq!(s.model.provider, "anthropic");
193    }
194
195    #[test]
196    fn anthropic_base_url_env_overlays_settings_json() {
197        let dir = temp_dir();
198        let path = dir.path().join("settings.json");
199        if let Err(err) = std::fs::write(
200            &path,
201            r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
202        ) {
203            panic!("write failed: {err}");
204        }
205
206        let env = HashMap::from([(
207            "CAPO_ANTHROPIC_BASE_URL",
208            "https://from-env.example.com/anthropic",
209        )]);
210        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
211            Ok(settings) => settings,
212            Err(err) => panic!("load failed: {err}"),
213        };
214        assert_eq!(
215            s.anthropic.base_url,
216            "https://from-env.example.com/anthropic"
217        );
218    }
219
220    #[test]
221    fn capo_anthropic_base_url_env_overlays_default() {
222        let dir = temp_dir();
223        let env = HashMap::from([(
224            "CAPO_ANTHROPIC_BASE_URL",
225            "https://proxy.example.com/anthropic",
226        )]);
227        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
228            Ok(settings) => settings,
229            Err(err) => panic!("load failed: {err}"),
230        };
231        assert_eq!(s.anthropic.base_url, "https://proxy.example.com/anthropic");
232    }
233
234    #[test]
235    fn malformed_json_returns_config_error_with_path() {
236        let dir = temp_dir();
237        let path = dir.path().join("settings.json");
238        if let Err(err) = std::fs::write(&path, "{not json}") {
239            panic!("write failed: {err}");
240        }
241
242        let err = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
243            Ok(_) => panic!("must fail on malformed json"),
244            Err(err) => err,
245        };
246        let msg = format!("{err}");
247        assert!(msg.contains("settings.json"), "{msg}");
248    }
249}