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