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_MAX_INPUT_TOKENS")
155 && let Ok(n) = v.parse()
156 {
157 cfg.reasoning.max_input_tokens = Some(n);
158 }
159 if let Some(v) = env_trimmed("AGENTIC_REASONING_MAX_COMPLETION_TOKENS")
160 && let Ok(n) = v.parse()
161 {
162 cfg.reasoning.max_completion_tokens = Some(n);
163 }
164 if let Some(v) = env_trimmed("AGENTIC_REASONING_EXECUTOR_TIMEOUT_SECS")
165 && let Ok(n) = v.parse()
166 {
167 cfg.reasoning.executor_timeout_secs = n;
168 }
169 if let Some(v) = env_trimmed("AGENTIC_REASONING_EMPTY_RESPONSE_NO_RETRY_AFTER_SECS")
170 && let Ok(n) = v.parse()
171 {
172 cfg.reasoning.empty_response_no_retry_after_secs = n;
173 }
174 if let Some(v) = env_trimmed("AGENTIC_REASONING_STREAM_HEARTBEAT_SECS")
175 && let Ok(n) = v.parse()
176 {
177 cfg.reasoning.stream_heartbeat_secs = n;
178 }
179
180 if let Some(v) = env_trimmed("AGENTIC_LOG_LEVEL") {
182 cfg.logging.level = v;
183 }
184 if let Some(v) = env_trimmed("AGENTIC_LOG_JSON") {
185 cfg.logging.json = v.to_lowercase() == "true" || v == "1";
186 }
187}
188
189fn env_trimmed(name: &str) -> Option<String> {
191 std::env::var(name)
192 .ok()
193 .map(|v| v.trim().to_string())
194 .filter(|v| !v.is_empty())
195}
196
197fn read_toml_table_or_empty(path: &Path) -> Result<toml::Value> {
199 if !path.exists() {
200 return Ok(toml::Value::Table(Default::default()));
201 }
202
203 let raw = std::fs::read_to_string(path)
204 .with_context(|| format!("Failed to read config file {}", path.display()))?;
205
206 let v: toml::Value =
207 toml::from_str(&raw).with_context(|| format!("Invalid TOML in {}", path.display()))?;
208
209 match v {
210 toml::Value::Table(_) => Ok(v),
211 _ => anyhow::bail!("Config root must be a TOML table: {}", path.display()),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::test_support::EnvGuard;
219 use serial_test::serial;
220 use tempfile::TempDir;
221
222 const CONFIG_DIR_TEST_VAR: &str = "__AGENTIC_CONFIG_DIR_FOR_TESTS";
224
225 #[test]
226 #[serial]
227 fn test_load_no_files_returns_defaults() {
228 let temp = TempDir::new().unwrap();
229 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
230
231 let loaded = load_merged(temp.path()).unwrap();
232
233 assert_eq!(
235 loaded.config.services.anthropic.base_url,
236 "https://api.anthropic.com"
237 );
238 assert_eq!(loaded.config.orchestrator.session_deadline_secs, 3600);
239 assert!(loaded.warnings.is_empty());
240 }
241
242 #[test]
243 #[serial]
244 fn test_load_local_only() {
245 let temp = TempDir::new().unwrap();
246 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
247
248 let local_path = temp.path().join(LOCAL_FILE);
249 std::fs::write(
250 &local_path,
251 r"
252[orchestrator]
253session_deadline_secs = 7200
254",
255 )
256 .unwrap();
257
258 let loaded = load_merged(temp.path()).unwrap();
259 assert_eq!(loaded.config.orchestrator.session_deadline_secs, 7200);
260 assert_eq!(loaded.config.orchestrator.inactivity_timeout_secs, 300);
262 }
263
264 #[test]
265 #[serial]
266 fn test_local_overrides_global() {
267 let temp = TempDir::new().unwrap();
268
269 let global_base = temp.path().join("global_config");
271 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, &global_base);
272
273 let global_dir = global_base.join(GLOBAL_DIR);
275 std::fs::create_dir_all(&global_dir).unwrap();
276 std::fs::write(
277 global_dir.join(GLOBAL_FILE),
278 r#"
279[subagents]
280locator_model = "global-model"
281analyzer_model = "global-analyzer"
282"#,
283 )
284 .unwrap();
285
286 let local_dir = temp.path().join("local_repo");
288 std::fs::create_dir_all(&local_dir).unwrap();
289 std::fs::write(
290 local_dir.join(LOCAL_FILE),
291 r#"
292[subagents]
293locator_model = "local-model"
294"#,
295 )
296 .unwrap();
297
298 let loaded = load_merged(&local_dir).unwrap();
299 assert_eq!(loaded.config.subagents.locator_model, "local-model");
301 assert_eq!(loaded.config.subagents.analyzer_model, "global-analyzer");
303 }
304
305 #[test]
306 #[serial]
307 fn test_env_overrides_files() {
308 let temp = TempDir::new().unwrap();
309 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
310 std::fs::write(
311 temp.path().join(LOCAL_FILE),
312 r#"
313[reasoning]
314optimizer_model = "file-model"
315"#,
316 )
317 .unwrap();
318
319 let _env_guard = EnvGuard::set("AGENTIC_REASONING_OPTIMIZER_MODEL", "env-model");
321
322 let loaded = load_merged(temp.path()).unwrap();
323 assert_eq!(loaded.config.reasoning.optimizer_model, "env-model");
324 }
325
326 #[test]
327 #[serial]
328 fn test_reasoning_defaults_include_streaming_recovery_fields() {
329 let temp = TempDir::new().unwrap();
330 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
331
332 let loaded = load_merged(temp.path()).unwrap();
333
334 assert_eq!(loaded.config.reasoning.executor_timeout_secs, 2700);
335 assert_eq!(
336 loaded.config.reasoning.empty_response_no_retry_after_secs,
337 600
338 );
339 assert_eq!(loaded.config.reasoning.stream_heartbeat_secs, 30);
340 }
341
342 #[test]
343 #[serial]
344 fn test_reasoning_streaming_env_overrides_apply() {
345 let temp = TempDir::new().unwrap();
346 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
347 let _g1 = EnvGuard::set("AGENTIC_REASONING_EXECUTOR_TIMEOUT_SECS", "123");
348 let _g2 = EnvGuard::set("AGENTIC_REASONING_EMPTY_RESPONSE_NO_RETRY_AFTER_SECS", "45");
349 let _g3 = EnvGuard::set("AGENTIC_REASONING_STREAM_HEARTBEAT_SECS", "9");
350
351 let loaded = load_merged(temp.path()).unwrap();
352
353 assert_eq!(loaded.config.reasoning.executor_timeout_secs, 123);
354 assert_eq!(
355 loaded.config.reasoning.empty_response_no_retry_after_secs,
356 45
357 );
358 assert_eq!(loaded.config.reasoning.stream_heartbeat_secs, 9);
359 }
360
361 #[test]
362 #[serial]
363 fn test_reasoning_token_limit_toml_is_ignored_without_warnings() {
364 let temp = TempDir::new().unwrap();
365 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
366
367 std::fs::write(
368 temp.path().join(LOCAL_FILE),
369 r"
370[reasoning]
371token_limit = 12345
372",
373 )
374 .unwrap();
375
376 let loaded = load_merged(temp.path()).unwrap();
377 assert_eq!(loaded.config.reasoning.max_input_tokens, None);
378 assert_eq!(loaded.config.reasoning.max_completion_tokens, Some(128_000));
379 assert!(loaded.warnings.is_empty());
380 }
381
382 #[test]
383 #[serial]
384 fn test_reasoning_max_input_tokens_wins_over_token_limit_in_same_toml_layer() {
385 let temp = TempDir::new().unwrap();
386 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
387
388 std::fs::write(
389 temp.path().join(LOCAL_FILE),
390 r"
391[reasoning]
392max_input_tokens = 111
393token_limit = 222
394",
395 )
396 .unwrap();
397
398 let loaded = load_merged(temp.path()).unwrap();
399 assert_eq!(loaded.config.reasoning.max_input_tokens, Some(111));
400 assert!(loaded.warnings.is_empty());
401 }
402
403 #[test]
404 #[serial]
405 fn test_deprecated_env_token_limit_is_ignored_and_new_env_still_wins() {
406 let temp = TempDir::new().unwrap();
407 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
408
409 {
410 let _g_old = EnvGuard::set("AGENTIC_REASONING_TOKEN_LIMIT", "222");
411
412 let loaded = load_merged(temp.path()).unwrap();
413 assert_eq!(loaded.config.reasoning.max_input_tokens, None);
414 assert!(loaded.warnings.is_empty());
415 }
416
417 let _g_old = EnvGuard::set("AGENTIC_REASONING_TOKEN_LIMIT", "222");
418 let _g_new = EnvGuard::set("AGENTIC_REASONING_MAX_INPUT_TOKENS", "111");
419
420 let loaded = load_merged(temp.path()).unwrap();
421 assert_eq!(loaded.config.reasoning.max_input_tokens, Some(111));
422 assert!(loaded.warnings.is_empty());
423 }
424
425 #[test]
426 #[serial]
427 fn test_env_trimmed_ignores_whitespace() {
428 let _g1 = EnvGuard::set("TEST_AGENTIC_TRIM", " value ");
430 let result = env_trimmed("TEST_AGENTIC_TRIM");
431 assert_eq!(result, Some("value".to_string()));
432
433 let _g2 = EnvGuard::set("TEST_AGENTIC_EMPTY", " ");
434 let result = env_trimmed("TEST_AGENTIC_EMPTY");
435 assert_eq!(result, None);
436 }
437
438 #[test]
439 #[serial]
440 fn test_invalid_toml_errors() {
441 let temp = TempDir::new().unwrap();
442 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
443 std::fs::write(temp.path().join(LOCAL_FILE), "not valid toml [[[").unwrap();
444
445 let result = load_merged(temp.path());
446 assert!(result.is_err());
447 assert!(result.unwrap_err().to_string().contains("Invalid TOML"));
448 }
449
450 #[test]
451 #[serial]
452 fn test_local_value_overrides_struct_default() {
453 let temp = TempDir::new().unwrap();
454 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
455
456 std::fs::write(
458 temp.path().join(LOCAL_FILE),
459 r"
460[web_retrieval]
461request_timeout_secs = 60
462",
463 )
464 .unwrap();
465
466 let loaded = load_merged(temp.path()).unwrap();
467 assert_eq!(loaded.config.web_retrieval.request_timeout_secs, 60);
468 }
469
470 #[test]
471 #[serial]
472 fn test_paths_are_set() {
473 let temp = TempDir::new().unwrap();
474 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
475
476 let loaded = load_merged(temp.path()).unwrap();
477
478 assert_eq!(loaded.paths.local, temp.path().join(LOCAL_FILE));
479 assert_eq!(
481 loaded.paths.global,
482 temp.path().join(GLOBAL_DIR).join(GLOBAL_FILE)
483 );
484 }
485
486 #[test]
487 #[serial]
488 fn test_warns_on_unknown_top_level_key() {
489 let temp = TempDir::new().unwrap();
490 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
491
492 std::fs::write(
493 temp.path().join(LOCAL_FILE),
494 r#"
495typo = 1
496unknown_section = "value"
497"#,
498 )
499 .unwrap();
500
501 let loaded = load_merged(temp.path()).unwrap();
502 assert!(
503 loaded
504 .warnings
505 .iter()
506 .any(|w| w.code == "config.unknown_top_level_key" && w.message.contains("typo"))
507 );
508 assert!(
509 loaded
510 .warnings
511 .iter()
512 .any(|w| w.code == "config.unknown_top_level_key"
513 && w.message.contains("unknown_section"))
514 );
515 }
516
517 #[test]
518 #[serial]
519 fn test_warns_on_deprecated_thoughts_section() {
520 let temp = TempDir::new().unwrap();
521 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
522
523 std::fs::write(
524 temp.path().join(LOCAL_FILE),
525 r"
526[thoughts]
527mount_dirs = {}
528",
529 )
530 .unwrap();
531
532 let loaded = load_merged(temp.path()).unwrap();
533 assert!(
534 loaded
535 .warnings
536 .iter()
537 .any(|w| w.code == "config.deprecated.thoughts")
538 );
539 assert!(loaded
541 .warnings
542 .iter()
543 .any(|w| w.code == "config.unknown_top_level_key" && w.message.contains("thoughts")));
544 }
545}