1use std::path::Path;
4
5use crate::error::{AppError, Result};
6use crate::paths::agent_dir;
7use crate::settings::{CliOverrides, Settings};
8
9pub fn load(cli: &CliOverrides) -> Result<Settings> {
11 load_with(&agent_dir(), cli, |name| std::env::var(name).ok())
12}
13
14pub 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 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 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 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"); }
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 assert_eq!(s.model.name, "from-env");
210 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 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}