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 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 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 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"); }
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 assert_eq!(s.model.name, "from-env");
146 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 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}