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 if let Some(v) = env_trimmed("AGENTIC_SERVICES_LINEAR_BASE_URL") {
133 cfg.services.linear.base_url = v;
134 }
135 if let Some(v) = env_trimmed("AGENTIC_SERVICES_GITHUB_BASE_URL") {
136 cfg.services.github.base_url = v;
137 }
138
139 if let Some(v) = env_trimmed("AGENTIC_SUBAGENTS_LOCATOR_MODEL") {
141 cfg.subagents.locator_model = v;
142 }
143 if let Some(v) = env_trimmed("AGENTIC_SUBAGENTS_ANALYZER_MODEL") {
144 cfg.subagents.analyzer_model = v;
145 }
146 if let Some(v) = env_trimmed("AGENTIC_SUBAGENTS_RUNTIME_TIMEOUT_SECS")
147 && let Ok(n) = v.parse()
148 {
149 cfg.subagents.runtime_timeout_secs = n;
150 }
151
152 if let Some(v) = env_trimmed("AGENTIC_REASONING_OPTIMIZER_MODEL") {
154 cfg.reasoning.optimizer_model = v;
155 }
156 if let Some(v) = env_trimmed("AGENTIC_REASONING_EXECUTOR_MODEL") {
157 cfg.reasoning.executor_model = v;
158 }
159 if let Some(v) = env_trimmed("AGENTIC_REASONING_EFFORT") {
160 cfg.reasoning.reasoning_effort = Some(v);
161 }
162 if let Some(v) = env_trimmed("AGENTIC_REASONING_API_BASE_URL") {
163 cfg.reasoning.api_base_url = Some(v);
164 }
165 if let Some(v) = env_trimmed("AGENTIC_REASONING_MAX_INPUT_TOKENS")
166 && let Ok(n) = v.parse()
167 {
168 cfg.reasoning.max_input_tokens = Some(n);
169 }
170 if let Some(v) = env_trimmed("AGENTIC_REASONING_MAX_COMPLETION_TOKENS")
171 && let Ok(n) = v.parse()
172 {
173 cfg.reasoning.max_completion_tokens = Some(n);
174 }
175 if let Some(v) = env_trimmed("AGENTIC_REASONING_EXECUTOR_TIMEOUT_SECS")
176 && let Ok(n) = v.parse()
177 {
178 cfg.reasoning.executor_timeout_secs = n;
179 }
180 if let Some(v) = env_trimmed("AGENTIC_REASONING_EMPTY_RESPONSE_NO_RETRY_AFTER_SECS")
181 && let Ok(n) = v.parse()
182 {
183 cfg.reasoning.empty_response_no_retry_after_secs = n;
184 }
185 if let Some(v) = env_trimmed("AGENTIC_REASONING_STREAM_HEARTBEAT_SECS")
186 && let Ok(n) = v.parse()
187 {
188 cfg.reasoning.stream_heartbeat_secs = n;
189 }
190
191 if let Some(v) = env_trimmed("AGENTIC_CLI_TOOLS_JUST_EXECUTE_TIMEOUT_SECS")
193 && let Ok(n) = v.parse()
194 {
195 cfg.cli_tools.just_execute_timeout_secs = n;
196 }
197 if let Some(v) = env_trimmed("AGENTIC_CLI_TOOLS_JUST_SEARCH_TIMEOUT_SECS")
198 && let Ok(n) = v.parse()
199 {
200 cfg.cli_tools.just_search_timeout_secs = n;
201 }
202
203 if let Some(v) = env_trimmed("AGENTIC_SERVICES_LINEAR_CONNECT_TIMEOUT_SECS")
205 && let Ok(n) = v.parse()
206 {
207 cfg.services.linear.connect_timeout_secs = n;
208 }
209 if let Some(v) = env_trimmed("AGENTIC_SERVICES_LINEAR_REQUEST_TIMEOUT_SECS")
210 && let Ok(n) = v.parse()
211 {
212 cfg.services.linear.request_timeout_secs = n;
213 }
214 if let Some(v) = env_trimmed("AGENTIC_SERVICES_GITHUB_TOTAL_TIMEOUT_SECS")
215 && let Ok(n) = v.parse()
216 {
217 cfg.services.github.total_timeout_secs = n;
218 }
219
220 if let Some(v) = env_trimmed("AGENTIC_REVIEW_RUN_TIMEOUT_SECS")
222 && let Ok(n) = v.parse()
223 {
224 cfg.review.run_timeout_secs = n;
225 }
226 if let Some(v) = env_trimmed("AGENTIC_THOUGHTS_ADD_REFERENCE_TIMEOUT_SECS")
227 && let Ok(n) = v.parse()
228 {
229 cfg.thoughts.add_reference_timeout_secs = n;
230 }
231
232 if let Some(v) = env_trimmed("AGENTIC_LOG_LEVEL") {
234 cfg.logging.level = v;
235 }
236 if let Some(v) = env_trimmed("AGENTIC_LOG_JSON") {
237 cfg.logging.json = v.to_lowercase() == "true" || v == "1";
238 }
239}
240
241fn env_trimmed(name: &str) -> Option<String> {
243 std::env::var(name)
244 .ok()
245 .map(|v| v.trim().to_string())
246 .filter(|v| !v.is_empty())
247}
248
249fn read_toml_table_or_empty(path: &Path) -> Result<toml::Value> {
251 if !path.exists() {
252 return Ok(toml::Value::Table(Default::default()));
253 }
254
255 let raw = std::fs::read_to_string(path)
256 .with_context(|| format!("Failed to read config file {}", path.display()))?;
257
258 let v: toml::Value =
259 toml::from_str(&raw).with_context(|| format!("Invalid TOML in {}", path.display()))?;
260
261 match v {
262 toml::Value::Table(_) => Ok(v),
263 _ => anyhow::bail!("Config root must be a TOML table: {}", path.display()),
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::test_support::EnvGuard;
271 use serial_test::serial;
272 use tempfile::TempDir;
273
274 const CONFIG_DIR_TEST_VAR: &str = "__AGENTIC_CONFIG_DIR_FOR_TESTS";
276
277 #[test]
278 #[serial]
279 fn test_load_no_files_returns_defaults() {
280 let temp = TempDir::new().unwrap();
281 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
282
283 let loaded = load_merged(temp.path()).unwrap();
284
285 assert_eq!(
287 loaded.config.services.anthropic.base_url,
288 "https://api.anthropic.com"
289 );
290 assert_eq!(loaded.config.orchestrator.session_deadline_secs, 3600);
291 assert_eq!(loaded.config.subagents.runtime_timeout_secs, 3600);
292 assert_eq!(loaded.config.cli_tools.just_execute_timeout_secs, 1800);
293 assert_eq!(loaded.config.review.run_timeout_secs, 1800);
294 assert!(loaded.warnings.is_empty());
295 }
296
297 #[test]
298 #[serial]
299 fn test_load_local_only() {
300 let temp = TempDir::new().unwrap();
301 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
302
303 let local_path = temp.path().join(LOCAL_FILE);
304 std::fs::write(
305 &local_path,
306 r"
307[orchestrator]
308session_deadline_secs = 7200
309",
310 )
311 .unwrap();
312
313 let loaded = load_merged(temp.path()).unwrap();
314 assert_eq!(loaded.config.orchestrator.session_deadline_secs, 7200);
315 assert_eq!(loaded.config.orchestrator.inactivity_timeout_secs, 300);
317 }
318
319 #[test]
320 #[serial]
321 fn test_local_overrides_global() {
322 let temp = TempDir::new().unwrap();
323
324 let global_base = temp.path().join("global_config");
326 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, &global_base);
327
328 let global_dir = global_base.join(GLOBAL_DIR);
330 std::fs::create_dir_all(&global_dir).unwrap();
331 std::fs::write(
332 global_dir.join(GLOBAL_FILE),
333 r#"
334[subagents]
335locator_model = "global-model"
336analyzer_model = "global-analyzer"
337"#,
338 )
339 .unwrap();
340
341 let local_dir = temp.path().join("local_repo");
343 std::fs::create_dir_all(&local_dir).unwrap();
344 std::fs::write(
345 local_dir.join(LOCAL_FILE),
346 r#"
347[subagents]
348locator_model = "local-model"
349"#,
350 )
351 .unwrap();
352
353 let loaded = load_merged(&local_dir).unwrap();
354 assert_eq!(loaded.config.subagents.locator_model, "local-model");
356 assert_eq!(loaded.config.subagents.analyzer_model, "global-analyzer");
358 }
359
360 #[test]
361 #[serial]
362 fn test_env_overrides_files() {
363 let temp = TempDir::new().unwrap();
364 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
365 std::fs::write(
366 temp.path().join(LOCAL_FILE),
367 r#"
368[reasoning]
369optimizer_model = "file-model"
370"#,
371 )
372 .unwrap();
373
374 let _env_guard = EnvGuard::set("AGENTIC_REASONING_OPTIMIZER_MODEL", "env-model");
376
377 let loaded = load_merged(temp.path()).unwrap();
378 assert_eq!(loaded.config.reasoning.optimizer_model, "env-model");
379 }
380
381 #[test]
382 #[serial]
383 fn test_reasoning_defaults_include_streaming_recovery_fields() {
384 let temp = TempDir::new().unwrap();
385 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
386
387 let loaded = load_merged(temp.path()).unwrap();
388
389 assert_eq!(loaded.config.reasoning.executor_timeout_secs, 2700);
390 assert_eq!(
391 loaded.config.reasoning.empty_response_no_retry_after_secs,
392 600
393 );
394 assert_eq!(loaded.config.reasoning.stream_heartbeat_secs, 30);
395 }
396
397 #[test]
398 #[serial]
399 fn test_reasoning_streaming_env_overrides_apply() {
400 let temp = TempDir::new().unwrap();
401 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
402 let _g1 = EnvGuard::set("AGENTIC_REASONING_EXECUTOR_TIMEOUT_SECS", "123");
403 let _g2 = EnvGuard::set("AGENTIC_REASONING_EMPTY_RESPONSE_NO_RETRY_AFTER_SECS", "45");
404 let _g3 = EnvGuard::set("AGENTIC_REASONING_STREAM_HEARTBEAT_SECS", "9");
405
406 let loaded = load_merged(temp.path()).unwrap();
407
408 assert_eq!(loaded.config.reasoning.executor_timeout_secs, 123);
409 assert_eq!(
410 loaded.config.reasoning.empty_response_no_retry_after_secs,
411 45
412 );
413 assert_eq!(loaded.config.reasoning.stream_heartbeat_secs, 9);
414 }
415
416 #[test]
417 #[serial]
418 fn test_timeout_env_overrides_apply() {
419 let temp = TempDir::new().unwrap();
420 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
421 let _g1 = EnvGuard::set("AGENTIC_SUBAGENTS_RUNTIME_TIMEOUT_SECS", "123");
422 let _g2 = EnvGuard::set("AGENTIC_CLI_TOOLS_JUST_EXECUTE_TIMEOUT_SECS", "456");
423 let _g3 = EnvGuard::set("AGENTIC_CLI_TOOLS_JUST_SEARCH_TIMEOUT_SECS", "0");
424 let _g4 = EnvGuard::set("AGENTIC_SERVICES_LINEAR_CONNECT_TIMEOUT_SECS", "11");
425 let _g5 = EnvGuard::set("AGENTIC_SERVICES_LINEAR_REQUEST_TIMEOUT_SECS", "22");
426 let _g6 = EnvGuard::set("AGENTIC_SERVICES_GITHUB_TOTAL_TIMEOUT_SECS", "33");
427 let _g7 = EnvGuard::set("AGENTIC_REVIEW_RUN_TIMEOUT_SECS", "44");
428 let _g8 = EnvGuard::set("AGENTIC_THOUGHTS_ADD_REFERENCE_TIMEOUT_SECS", "55");
429
430 let loaded = load_merged(temp.path()).unwrap();
431
432 assert_eq!(loaded.config.subagents.runtime_timeout_secs, 123);
433 assert_eq!(loaded.config.cli_tools.just_execute_timeout_secs, 456);
434 assert_eq!(loaded.config.cli_tools.just_search_timeout_secs, 0);
435 assert_eq!(loaded.config.services.linear.connect_timeout_secs, 11);
436 assert_eq!(loaded.config.services.linear.request_timeout_secs, 22);
437 assert_eq!(loaded.config.services.github.total_timeout_secs, 33);
438 assert_eq!(loaded.config.review.run_timeout_secs, 44);
439 assert_eq!(loaded.config.thoughts.add_reference_timeout_secs, 55);
440 }
441
442 #[test]
443 #[serial]
444 fn test_reasoning_token_limit_toml_is_ignored_without_warnings() {
445 let temp = TempDir::new().unwrap();
446 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
447
448 std::fs::write(
449 temp.path().join(LOCAL_FILE),
450 r"
451[reasoning]
452token_limit = 12345
453",
454 )
455 .unwrap();
456
457 let loaded = load_merged(temp.path()).unwrap();
458 assert_eq!(loaded.config.reasoning.max_input_tokens, None);
459 assert_eq!(loaded.config.reasoning.max_completion_tokens, Some(128_000));
460 assert!(loaded.warnings.is_empty());
461 }
462
463 #[test]
464 #[serial]
465 fn test_reasoning_max_input_tokens_wins_over_token_limit_in_same_toml_layer() {
466 let temp = TempDir::new().unwrap();
467 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
468
469 std::fs::write(
470 temp.path().join(LOCAL_FILE),
471 r"
472[reasoning]
473max_input_tokens = 111
474token_limit = 222
475",
476 )
477 .unwrap();
478
479 let loaded = load_merged(temp.path()).unwrap();
480 assert_eq!(loaded.config.reasoning.max_input_tokens, Some(111));
481 assert!(loaded.warnings.is_empty());
482 }
483
484 #[test]
485 #[serial]
486 fn test_deprecated_env_token_limit_is_ignored_and_new_env_still_wins() {
487 let temp = TempDir::new().unwrap();
488 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
489
490 {
491 let _g_old = EnvGuard::set("AGENTIC_REASONING_TOKEN_LIMIT", "222");
492
493 let loaded = load_merged(temp.path()).unwrap();
494 assert_eq!(loaded.config.reasoning.max_input_tokens, None);
495 assert!(loaded.warnings.is_empty());
496 }
497
498 let _g_old = EnvGuard::set("AGENTIC_REASONING_TOKEN_LIMIT", "222");
499 let _g_new = EnvGuard::set("AGENTIC_REASONING_MAX_INPUT_TOKENS", "111");
500
501 let loaded = load_merged(temp.path()).unwrap();
502 assert_eq!(loaded.config.reasoning.max_input_tokens, Some(111));
503 assert!(loaded.warnings.is_empty());
504 }
505
506 #[test]
507 #[serial]
508 fn test_env_trimmed_ignores_whitespace() {
509 let _g1 = EnvGuard::set("TEST_AGENTIC_TRIM", " value ");
511 let result = env_trimmed("TEST_AGENTIC_TRIM");
512 assert_eq!(result, Some("value".to_string()));
513
514 let _g2 = EnvGuard::set("TEST_AGENTIC_EMPTY", " ");
515 let result = env_trimmed("TEST_AGENTIC_EMPTY");
516 assert_eq!(result, None);
517 }
518
519 #[test]
520 #[serial]
521 fn test_invalid_toml_errors() {
522 let temp = TempDir::new().unwrap();
523 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
524 std::fs::write(temp.path().join(LOCAL_FILE), "not valid toml [[[").unwrap();
525
526 let result = load_merged(temp.path());
527 assert!(result.is_err());
528 assert!(result.unwrap_err().to_string().contains("Invalid TOML"));
529 }
530
531 #[test]
532 #[serial]
533 fn test_local_value_overrides_struct_default() {
534 let temp = TempDir::new().unwrap();
535 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
536
537 std::fs::write(
539 temp.path().join(LOCAL_FILE),
540 r"
541[web_retrieval]
542request_timeout_secs = 60
543",
544 )
545 .unwrap();
546
547 let loaded = load_merged(temp.path()).unwrap();
548 assert_eq!(loaded.config.web_retrieval.request_timeout_secs, 60);
549 }
550
551 #[test]
552 #[serial]
553 fn test_paths_are_set() {
554 let temp = TempDir::new().unwrap();
555 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
556
557 let loaded = load_merged(temp.path()).unwrap();
558
559 assert_eq!(loaded.paths.local, temp.path().join(LOCAL_FILE));
560 assert_eq!(
562 loaded.paths.global,
563 temp.path().join(GLOBAL_DIR).join(GLOBAL_FILE)
564 );
565 }
566
567 #[test]
568 #[serial]
569 fn test_warns_on_unknown_top_level_key() {
570 let temp = TempDir::new().unwrap();
571 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
572
573 std::fs::write(
574 temp.path().join(LOCAL_FILE),
575 r#"
576typo = 1
577unknown_section = "value"
578"#,
579 )
580 .unwrap();
581
582 let loaded = load_merged(temp.path()).unwrap();
583 assert!(
584 loaded
585 .warnings
586 .iter()
587 .any(|w| w.code == "config.unknown_top_level_key" && w.message.contains("typo"))
588 );
589 assert!(
590 loaded
591 .warnings
592 .iter()
593 .any(|w| w.code == "config.unknown_top_level_key"
594 && w.message.contains("unknown_section"))
595 );
596 }
597
598 #[test]
599 #[serial]
600 fn test_warns_on_deprecated_thoughts_section() {
601 let temp = TempDir::new().unwrap();
602 let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, temp.path());
603
604 std::fs::write(
605 temp.path().join(LOCAL_FILE),
606 r"
607[thoughts]
608mount_dirs = {}
609",
610 )
611 .unwrap();
612
613 let loaded = load_merged(temp.path()).unwrap();
614 assert!(
615 loaded
616 .warnings
617 .iter()
618 .any(|w| w.code == "config.deprecated.thoughts.mount_dirs")
619 );
620 assert!(
621 !loaded
622 .warnings
623 .iter()
624 .any(|w| w.code == "config.unknown_top_level_key" && w.message.contains("thoughts"))
625 );
626 }
627}