Skip to main content

agentic_config/
loader.rs

1//! Configuration loader with two-layer merge and env overrides.
2//!
3//! The loading process:
4//! 1. Read global config from `~/.config/agentic/agentic.toml`
5//! 2. Read local config from `./agentic.toml`
6//! 3. Deep merge at TOML Value level (tables merge, arrays/scalars replace)
7//! 4. Deserialize once into typed `AgenticConfig`
8//! 5. Apply env var overrides (highest precedence)
9//! 6. Run advisory validation
10
11use 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
19/// Filename for local config (TOML format).
20pub const LOCAL_FILE: &str = "agentic.toml";
21
22/// Directory name under `config_dir` for global config.
23pub const GLOBAL_DIR: &str = "agentic";
24
25/// Filename for global config (TOML format).
26pub const GLOBAL_FILE: &str = "agentic.toml";
27
28/// Resolved paths for config files.
29#[derive(Debug, Clone)]
30pub struct AgenticConfigPaths {
31    /// Path to local config (./agentic.toml).
32    pub local: PathBuf,
33
34    /// Path to global config (~/.config/agentic/agentic.toml).
35    pub global: PathBuf,
36}
37
38/// Result of loading configuration.
39#[derive(Debug)]
40pub struct LoadedAgenticConfig {
41    /// The loaded and merged configuration.
42    pub config: AgenticConfig,
43
44    /// Advisory warnings from validation.
45    pub warnings: Vec<AdvisoryWarning>,
46
47    /// Resolved config file paths.
48    pub paths: AgenticConfigPaths,
49}
50
51/// Get the global config file path.
52///
53/// Returns `~/.config/agentic/agentic.toml` on Unix-like systems.
54///
55/// # Test Hook
56///
57/// Set `__AGENTIC_CONFIG_DIR_FOR_TESTS` to override the base config directory.
58/// This is handled by [`crate::paths::xdg_config_home()`] which implements XDG
59/// path resolution with test hook support.
60pub 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
65/// Get the local config file path for a given directory.
66pub fn local_config_path(local_dir: &Path) -> PathBuf {
67    local_dir.join(LOCAL_FILE)
68}
69
70/// Load and merge configuration from global and local files.
71///
72/// # Precedence (lowest to highest)
73/// 1. Default values
74/// 2. Global config (`~/.config/agentic/agentic.toml`)
75/// 3. Local config (`./agentic.toml`)
76/// 4. Environment variables
77pub 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    // Collect warnings
82    let mut warnings = Vec::new();
83
84    // Read configs as TOML Values
85    let global_v = read_toml_table_or_empty(&global_path)?;
86    let local_v = read_toml_table_or_empty(&local_path)?;
87
88    // Merge: global as base, local as patch
89    let merged = deep_merge(global_v, local_v);
90
91    // Detect unknown top-level keys
92    warnings.extend(crate::validation::detect_unknown_top_level_keys_toml(
93        &merged,
94    ));
95
96    // Detect deprecated keys from merged config (before deserialization)
97    warnings.extend(crate::validation::detect_deprecated_keys_toml(&merged));
98
99    // Deserialize to typed config using serde_path_to_error for better error messages
100    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    // Apply env var overrides (highest precedence)
107    let mut cfg = cfg;
108    apply_env_overrides(&mut cfg);
109
110    // Run advisory validation and add to warnings
111    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
123/// Apply environment variable overrides to the config.
124fn apply_env_overrides(cfg: &mut AgenticConfig) {
125    // --- Service URLs ---
126    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    // --- Subagents model overrides ---
134    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    // --- Reasoning model overrides ---
142    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    // --- Logging overrides ---
181    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
189/// Helper to read and normalize an env var (trim + filter empty).
190fn 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
197/// Read a TOML file as a Value, returning empty table if file doesn't exist.
198fn 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    /// Env var name for test isolation of global config path.
223    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        // Should get default values
234        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        // Other fields should be defaults
261        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        // Point global config to our temp directory
270        let global_base = temp.path().join("global_config");
271        let _guard = EnvGuard::set(CONFIG_DIR_TEST_VAR, &global_base);
272
273        // Create global config
274        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        // Create local config that overrides locator_model but not analyzer_model
287        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        // Local wins for locator_model
300        assert_eq!(loaded.config.subagents.locator_model, "local-model");
301        // Global value preserved for analyzer_model
302        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        // Set env var via EnvGuard (RAII cleanup)
320        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        // Use EnvGuard for RAII cleanup
429        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        // Create local config that overrides a nested value
457        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        // Global path should point to our isolated temp dir
480        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        // Also warns as unknown key
540        assert!(loaded
541            .warnings
542            .iter()
543            .any(|w| w.code == "config.unknown_top_level_key" && w.message.contains("thoughts")));
544    }
545}