1use crate::merge::deep_merge;
12use crate::types::AgenticConfig;
13use crate::validation::AdvisoryWarning;
14use anyhow::Context;
15use anyhow::Result;
16use std::path::Path;
17use std::path::PathBuf;
18
19pub const LOCAL_FILE: &str = "agentic.toml";
21
22pub const GLOBAL_DIR: &str = "agentic";
24
25pub const GLOBAL_FILE: &str = "agentic.toml";
27
28#[derive(Debug, Clone)]
30pub struct AgenticConfigPaths {
31 pub local: PathBuf,
33
34 pub global: PathBuf,
36}
37
38#[derive(Debug)]
40pub struct LoadedAgenticConfig {
41 pub config: AgenticConfig,
43
44 pub warnings: Vec<AdvisoryWarning>,
46
47 pub paths: AgenticConfigPaths,
49}
50
51pub fn global_config_path() -> Result<PathBuf> {
61 let base = crate::paths::xdg_config_home()?;
62 Ok(base.join(GLOBAL_DIR).join(GLOBAL_FILE))
63}
64
65pub fn local_config_path(local_dir: &Path) -> PathBuf {
67 local_dir.join(LOCAL_FILE)
68}
69
70pub fn load_merged(local_dir: &Path) -> Result<LoadedAgenticConfig> {
78 let global_path = global_config_path()?;
79 let local_path = local_config_path(local_dir);
80
81 let mut warnings = Vec::new();
83
84 let global_v = read_toml_table_or_empty(&global_path)?;
86 let local_v = read_toml_table_or_empty(&local_path)?;
87
88 let merged = deep_merge(global_v, local_v);
90
91 warnings.extend(crate::validation::detect_unknown_top_level_keys_toml(
93 &merged,
94 ));
95
96 warnings.extend(crate::validation::detect_deprecated_keys_toml(&merged));
98
99 let cfg: AgenticConfig = {
101 let deserializer = merged;
102 serde_path_to_error::deserialize(deserializer)
103 .with_context(|| "Failed to deserialize merged agentic config")?
104 };
105
106 let mut cfg = cfg;
108 apply_env_overrides(&mut cfg);
109
110 warnings.extend(crate::validation::validate(&cfg));
112
113 Ok(LoadedAgenticConfig {
114 config: cfg,
115 warnings,
116 paths: AgenticConfigPaths {
117 local: local_path,
118 global: global_path,
119 },
120 })
121}
122
123fn apply_env_overrides(cfg: &mut AgenticConfig) {
125 if let Some(v) = env_trimmed("ANTHROPIC_BASE_URL") {
127 cfg.services.anthropic.base_url = v;
128 }
129 if let Some(v) = env_trimmed("EXA_BASE_URL") {
130 cfg.services.exa.base_url = v;
131 }
132
133 if let Some(v) = env_trimmed("AGENTIC_SUBAGENTS_LOCATOR_MODEL") {
135 cfg.subagents.locator_model = v;
136 }
137 if let Some(v) = env_trimmed("AGENTIC_SUBAGENTS_ANALYZER_MODEL") {
138 cfg.subagents.analyzer_model = v;
139 }
140
141 if let Some(v) = env_trimmed("AGENTIC_REASONING_OPTIMIZER_MODEL") {
143 cfg.reasoning.optimizer_model = v;
144 }
145 if let Some(v) = env_trimmed("AGENTIC_REASONING_EXECUTOR_MODEL") {
146 cfg.reasoning.executor_model = v;
147 }
148 if let Some(v) = env_trimmed("AGENTIC_REASONING_EFFORT") {
149 cfg.reasoning.reasoning_effort = Some(v);
150 }
151 if let Some(v) = env_trimmed("AGENTIC_REASONING_API_BASE_URL") {
152 cfg.reasoning.api_base_url = Some(v);
153 }
154 if let Some(v) = env_trimmed("AGENTIC_REASONING_TOKEN_LIMIT")
155 && let Ok(n) = v.parse()
156 {
157 cfg.reasoning.token_limit = Some(n);
158 }
159
160 if let Some(v) = env_trimmed("AGENTIC_LOG_LEVEL") {
162 cfg.logging.level = v;
163 }
164 if let Some(v) = env_trimmed("AGENTIC_LOG_JSON") {
165 cfg.logging.json = v.to_lowercase() == "true" || v == "1";
166 }
167}
168
169fn env_trimmed(name: &str) -> Option<String> {
171 std::env::var(name)
172 .ok()
173 .map(|v| v.trim().to_string())
174 .filter(|v| !v.is_empty())
175}
176
177fn read_toml_table_or_empty(path: &Path) -> Result<toml::Value> {
179 if !path.exists() {
180 return Ok(toml::Value::Table(Default::default()));
181 }
182
183 let raw = std::fs::read_to_string(path)
184 .with_context(|| format!("Failed to read config file {}", path.display()))?;
185
186 let v: toml::Value =
187 toml::from_str(&raw).with_context(|| format!("Invalid TOML in {}", path.display()))?;
188
189 match v {
190 toml::Value::Table(_) => Ok(v),
191 _ => anyhow::bail!("Config root must be a TOML table: {}", path.display()),
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::test_support::EnvGuard;
199 use serial_test::serial;
200 use tempfile::TempDir;
201
202 const CONFIG_DIR_TEST_VAR: &str = "__AGENTIC_CONFIG_DIR_FOR_TESTS";
204
205 #[test]
206 #[serial]
207 fn test_load_no_files_returns_defaults() {
208 let temp = TempDir::new().unwrap();
209 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
210
211 let loaded = load_merged(temp.path()).unwrap();
212
213 assert_eq!(
215 loaded.config.services.anthropic.base_url,
216 "https://api.anthropic.com"
217 );
218 assert_eq!(loaded.config.orchestrator.session_deadline_secs, 3600);
219 assert!(loaded.warnings.is_empty());
220 }
221
222 #[test]
223 #[serial]
224 fn test_load_local_only() {
225 let temp = TempDir::new().unwrap();
226 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
227
228 let local_path = temp.path().join(LOCAL_FILE);
229 std::fs::write(
230 &local_path,
231 r"
232[orchestrator]
233session_deadline_secs = 7200
234",
235 )
236 .unwrap();
237
238 let loaded = load_merged(temp.path()).unwrap();
239 assert_eq!(loaded.config.orchestrator.session_deadline_secs, 7200);
240 assert_eq!(loaded.config.orchestrator.inactivity_timeout_secs, 300);
242 }
243
244 #[test]
245 #[serial]
246 fn test_local_overrides_global() {
247 let temp = TempDir::new().unwrap();
248
249 let global_base = temp.path().join("global_config");
251 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, &global_base);
252
253 let global_dir = global_base.join(GLOBAL_DIR);
255 std::fs::create_dir_all(&global_dir).unwrap();
256 std::fs::write(
257 global_dir.join(GLOBAL_FILE),
258 r#"
259[subagents]
260locator_model = "global-model"
261analyzer_model = "global-analyzer"
262"#,
263 )
264 .unwrap();
265
266 let local_dir = temp.path().join("local_repo");
268 std::fs::create_dir_all(&local_dir).unwrap();
269 std::fs::write(
270 local_dir.join(LOCAL_FILE),
271 r#"
272[subagents]
273locator_model = "local-model"
274"#,
275 )
276 .unwrap();
277
278 let loaded = load_merged(&local_dir).unwrap();
279 assert_eq!(loaded.config.subagents.locator_model, "local-model");
281 assert_eq!(loaded.config.subagents.analyzer_model, "global-analyzer");
283 }
284
285 #[test]
286 #[serial]
287 fn test_env_overrides_files() {
288 let temp = TempDir::new().unwrap();
289 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
290 std::fs::write(
291 temp.path().join(LOCAL_FILE),
292 r#"
293[reasoning]
294optimizer_model = "file-model"
295"#,
296 )
297 .unwrap();
298
299 let _env_guard = EnvGuard::set("AGENTIC_REASONING_OPTIMIZER_MODEL", "env-model");
301
302 let loaded = load_merged(temp.path()).unwrap();
303 assert_eq!(loaded.config.reasoning.optimizer_model, "env-model");
304 }
305
306 #[test]
307 #[serial]
308 fn test_env_trimmed_ignores_whitespace() {
309 let _g1 = EnvGuard::set("TEST_AGENTIC_TRIM", " value ");
311 let result = env_trimmed("TEST_AGENTIC_TRIM");
312 assert_eq!(result, Some("value".to_string()));
313
314 let _g2 = EnvGuard::set("TEST_AGENTIC_EMPTY", " ");
315 let result = env_trimmed("TEST_AGENTIC_EMPTY");
316 assert_eq!(result, None);
317 }
318
319 #[test]
320 #[serial]
321 fn test_invalid_toml_errors() {
322 let temp = TempDir::new().unwrap();
323 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
324 std::fs::write(temp.path().join(LOCAL_FILE), "not valid toml [[[").unwrap();
325
326 let result = load_merged(temp.path());
327 assert!(result.is_err());
328 assert!(result.unwrap_err().to_string().contains("Invalid TOML"));
329 }
330
331 #[test]
332 #[serial]
333 fn test_local_value_overrides_struct_default() {
334 let temp = TempDir::new().unwrap();
335 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
336
337 std::fs::write(
339 temp.path().join(LOCAL_FILE),
340 r"
341[web_retrieval]
342request_timeout_secs = 60
343",
344 )
345 .unwrap();
346
347 let loaded = load_merged(temp.path()).unwrap();
348 assert_eq!(loaded.config.web_retrieval.request_timeout_secs, 60);
349 }
350
351 #[test]
352 #[serial]
353 fn test_paths_are_set() {
354 let temp = TempDir::new().unwrap();
355 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
356
357 let loaded = load_merged(temp.path()).unwrap();
358
359 assert_eq!(loaded.paths.local, temp.path().join(LOCAL_FILE));
360 assert_eq!(
362 loaded.paths.global,
363 temp.path().join(GLOBAL_DIR).join(GLOBAL_FILE)
364 );
365 }
366
367 #[test]
368 #[serial]
369 fn test_warns_on_unknown_top_level_key() {
370 let temp = TempDir::new().unwrap();
371 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
372
373 std::fs::write(
374 temp.path().join(LOCAL_FILE),
375 r#"
376typo = 1
377unknown_section = "value"
378"#,
379 )
380 .unwrap();
381
382 let loaded = load_merged(temp.path()).unwrap();
383 assert!(
384 loaded
385 .warnings
386 .iter()
387 .any(|w| w.code == "config.unknown_top_level_key" && w.message.contains("typo"))
388 );
389 assert!(
390 loaded
391 .warnings
392 .iter()
393 .any(|w| w.code == "config.unknown_top_level_key"
394 && w.message.contains("unknown_section"))
395 );
396 }
397
398 #[test]
399 #[serial]
400 fn test_warns_on_deprecated_thoughts_section() {
401 let temp = TempDir::new().unwrap();
402 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
403
404 std::fs::write(
405 temp.path().join(LOCAL_FILE),
406 r"
407[thoughts]
408mount_dirs = {}
409",
410 )
411 .unwrap();
412
413 let loaded = load_merged(temp.path()).unwrap();
414 assert!(
415 loaded
416 .warnings
417 .iter()
418 .any(|w| w.code == "config.deprecated.thoughts")
419 );
420 assert!(loaded
422 .warnings
423 .iter()
424 .any(|w| w.code == "config.unknown_top_level_key" && w.message.contains("thoughts")));
425 }
426}