Skip to main content

capo_agent/settings/
load.rs

1//! `Settings::load` — overlay default → settings.toml/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.toml (preferred) or legacy settings.json.
25    let toml_path = agent_dir.join("settings.toml");
26    let json_path = agent_dir.join("settings.json");
27    let file_path = if toml_path.exists() {
28        Some(toml_path)
29    } else if json_path.exists() {
30        Some(json_path)
31    } else {
32        None
33    };
34    if let Some(file_path) = file_path {
35        let raw = std::fs::read_to_string(&file_path).map_err(|err| {
36            AppError::Config(format!("failed to read {}: {err}", file_path.display()))
37        })?;
38        let mut merged = serde_json::to_value(Settings::default())
39            .map_err(|err| AppError::Config(format!("failed to encode defaults: {err}")))?;
40        let file_value: serde_json::Value =
41            if file_path.extension().and_then(|s| s.to_str()) == Some("toml") {
42                toml::from_str(&raw).map_err(|err| {
43                    AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
44                })?
45            } else {
46                serde_json::from_str(&raw).map_err(|err| {
47                    AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
48                })?
49            };
50        merge_json(&mut merged, file_value);
51        settings = serde_json::from_value(merged).map_err(|err| {
52            AppError::Config(format!("failed to parse {}: {err}", file_path.display()))
53        })?;
54    }
55
56    // 2. Env layer (matches M2 mappings + room to grow)
57    if let Some(name) = env_lookup("CAPO_MODEL_NAME") {
58        settings.model.name = name.trim().to_string();
59    }
60    if let Some(provider) = env_lookup("CAPO_MODEL_PROVIDER") {
61        settings.model.provider = provider.trim().to_string();
62    }
63    if let Some(max_tokens) = env_lookup("CAPO_MODEL_MAX_TOKENS") {
64        let parsed: u32 = max_tokens.trim().parse().map_err(|_| {
65            AppError::Config(format!("CAPO_MODEL_MAX_TOKENS not a u32: {max_tokens}"))
66        })?;
67        settings.model.max_tokens = parsed;
68    }
69    if let Some(base_url) = env_lookup("CAPO_ANTHROPIC_BASE_URL") {
70        settings.anthropic.base_url = base_url.trim().to_string();
71    }
72
73    // 3. CLI layer (highest precedence)
74    if let Some(model) = &cli.model {
75        settings.model.name = model.clone();
76    }
77
78    Ok(settings)
79}
80
81fn merge_json(base: &mut serde_json::Value, overlay: serde_json::Value) {
82    match (base, overlay) {
83        (serde_json::Value::Object(base), serde_json::Value::Object(overlay)) => {
84            for (key, value) in overlay {
85                match base.get_mut(&key) {
86                    Some(existing) => merge_json(existing, value),
87                    None => {
88                        base.insert(key, value);
89                    }
90                }
91            }
92        }
93        (slot, value) => *slot = value,
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::collections::HashMap;
101    use tempfile::TempDir;
102
103    fn temp_dir() -> TempDir {
104        match tempfile::tempdir() {
105            Ok(dir) => dir,
106            Err(err) => panic!("tempdir failed: {err}"),
107        }
108    }
109
110    fn lookup(map: HashMap<&'static str, &'static str>) -> impl Fn(&str) -> Option<String> {
111        move |name| map.get(name).map(|v| (*v).to_string())
112    }
113
114    #[test]
115    fn missing_file_returns_defaults_with_env_overlay() {
116        let dir = temp_dir();
117        let env = HashMap::from([("CAPO_MODEL_NAME", "claude-opus-4-7")]);
118        let s = match Settings::load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
119            Ok(settings) => settings,
120            Err(err) => panic!("load failed: {err}"),
121        };
122        assert_eq!(s.model.name, "claude-opus-4-7");
123        assert_eq!(s.model.provider, "anthropic"); // default
124    }
125
126    #[test]
127    fn partial_nested_file_values_overlay_defaults() {
128        let dir = temp_dir();
129        let path = dir.path().join("settings.json");
130        if let Err(err) = std::fs::write(&path, r#"{ "model": { "name": "from-file" } }"#) {
131            panic!("write failed: {err}");
132        }
133
134        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
135            Ok(settings) => settings,
136            Err(err) => panic!("load failed: {err}"),
137        };
138        assert_eq!(s.model.name, "from-file");
139        assert_eq!(s.model.provider, "anthropic");
140        assert_eq!(s.model.max_tokens, 8192);
141    }
142
143    #[test]
144    fn partial_nested_toml_values_overlay_defaults() {
145        let dir = temp_dir();
146        let path = dir.path().join("settings.toml");
147        if let Err(err) = std::fs::write(
148            &path,
149            r#"
150[model]
151name = "from-toml"
152"#,
153        ) {
154            panic!("write failed: {err}");
155        }
156
157        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
158            Ok(settings) => settings,
159            Err(err) => panic!("load failed: {err}"),
160        };
161        assert_eq!(s.model.name, "from-toml");
162        assert_eq!(s.model.provider, "anthropic");
163        assert_eq!(s.model.max_tokens, 8192);
164    }
165
166    #[test]
167    fn settings_toml_is_preferred_over_legacy_json() {
168        let dir = temp_dir();
169        if let Err(err) = std::fs::write(
170            dir.path().join("settings.json"),
171            r#"{ "model": { "name": "from-json" } }"#,
172        ) {
173            panic!("write json failed: {err}");
174        }
175        if let Err(err) = std::fs::write(
176            dir.path().join("settings.toml"),
177            r#"
178[model]
179name = "from-toml"
180"#,
181        ) {
182            panic!("write toml failed: {err}");
183        }
184
185        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
186            Ok(settings) => settings,
187            Err(err) => panic!("load failed: {err}"),
188        };
189        assert_eq!(s.model.name, "from-toml");
190    }
191
192    #[test]
193    fn env_overlays_file_overlays_default() {
194        let dir = temp_dir();
195        let path = dir.path().join("settings.json");
196        if let Err(err) = std::fs::write(
197            &path,
198            r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
199        ) {
200            panic!("write failed: {err}");
201        }
202
203        let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
204        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
205            Ok(settings) => settings,
206            Err(err) => panic!("load failed: {err}"),
207        };
208        // Env wins over file
209        assert_eq!(s.model.name, "from-env");
210        // File value still applies where env didn't override
211        assert_eq!(s.model.max_tokens, 4096);
212    }
213
214    #[test]
215    fn cli_overlays_env_overlays_file() {
216        let dir = temp_dir();
217        let path = dir.path().join("settings.json");
218        if let Err(err) = std::fs::write(
219            &path,
220            r#"{ "model": { "provider": "anthropic", "name": "from-file", "max_tokens": 4096 } }"#,
221        ) {
222            panic!("write failed: {err}");
223        }
224
225        let env = HashMap::from([("CAPO_MODEL_NAME", "from-env")]);
226        let cli = CliOverrides {
227            model: Some("from-cli".into()),
228        };
229        let s = match load_with(dir.path(), &cli, lookup(env)) {
230            Ok(settings) => settings,
231            Err(err) => panic!("load failed: {err}"),
232        };
233        assert_eq!(s.model.name, "from-cli");
234    }
235
236    #[test]
237    fn anthropic_base_url_loads_from_settings_json() {
238        let dir = temp_dir();
239        let path = dir.path().join("settings.json");
240        if let Err(err) = std::fs::write(
241            &path,
242            r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
243        ) {
244            panic!("write failed: {err}");
245        }
246
247        let s = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
248            Ok(settings) => settings,
249            Err(err) => panic!("load failed: {err}"),
250        };
251        assert_eq!(
252            s.anthropic.base_url,
253            "https://from-file.example.com/anthropic"
254        );
255        // Other sections still default.
256        assert_eq!(s.model.provider, "anthropic");
257    }
258
259    #[test]
260    fn anthropic_base_url_env_overlays_settings_json() {
261        let dir = temp_dir();
262        let path = dir.path().join("settings.json");
263        if let Err(err) = std::fs::write(
264            &path,
265            r#"{ "anthropic": { "base_url": "https://from-file.example.com/anthropic" } }"#,
266        ) {
267            panic!("write failed: {err}");
268        }
269
270        let env = HashMap::from([(
271            "CAPO_ANTHROPIC_BASE_URL",
272            "https://from-env.example.com/anthropic",
273        )]);
274        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
275            Ok(settings) => settings,
276            Err(err) => panic!("load failed: {err}"),
277        };
278        assert_eq!(
279            s.anthropic.base_url,
280            "https://from-env.example.com/anthropic"
281        );
282    }
283
284    #[test]
285    fn capo_anthropic_base_url_env_overlays_default() {
286        let dir = temp_dir();
287        let env = HashMap::from([(
288            "CAPO_ANTHROPIC_BASE_URL",
289            "https://proxy.example.com/anthropic",
290        )]);
291        let s = match load_with(dir.path(), &CliOverrides::default(), lookup(env)) {
292            Ok(settings) => settings,
293            Err(err) => panic!("load failed: {err}"),
294        };
295        assert_eq!(s.anthropic.base_url, "https://proxy.example.com/anthropic");
296    }
297
298    #[test]
299    fn malformed_json_returns_config_error_with_path() {
300        let dir = temp_dir();
301        let path = dir.path().join("settings.json");
302        if let Err(err) = std::fs::write(&path, "{not json}") {
303            panic!("write failed: {err}");
304        }
305
306        let err = match load_with(dir.path(), &CliOverrides::default(), |_| None) {
307            Ok(_) => panic!("must fail on malformed json"),
308            Err(err) => err,
309        };
310        let msg = format!("{err}");
311        assert!(msg.contains("settings.json"), "{msg}");
312    }
313}