Skip to main content

agent_code_lib/config/
mod.rs

1//! Configuration system.
2//!
3//! Configuration is loaded from multiple sources with the following
4//! priority (highest to lowest):
5//!
6//! 1. CLI flags and environment variables
7//! 2. Project-local settings (`.agent/settings.toml`)
8//! 3. User settings (`~/.config/agent-code/config.toml`)
9//!
10//! Each layer is merged into the final `Config` struct.
11
12mod schema;
13
14pub use schema::*;
15
16use crate::error::ConfigError;
17use std::path::{Path, PathBuf};
18
19/// Re-entrancy guard to prevent Config::load → log → Config::load cycles.
20static LOADING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
21
22impl Config {
23    /// Load configuration from all sources, merging by priority.
24    pub fn load() -> Result<Config, ConfigError> {
25        // Re-entrancy guard.
26        if LOADING.swap(true, std::sync::atomic::Ordering::SeqCst) {
27            return Ok(Config::default());
28        }
29        let result = Self::load_inner();
30        LOADING.store(false, std::sync::atomic::Ordering::SeqCst);
31        result
32    }
33
34    fn load_inner() -> Result<Config, ConfigError> {
35        let mut layers: Vec<String> = Vec::new();
36
37        // Layer 1: User-level config (lowest priority file).
38        if let Some(path) = user_config_path()
39            && path.exists()
40        {
41            layers.push(
42                std::fs::read_to_string(&path)
43                    .map_err(|e| ConfigError::FileError(format!("{path:?}: {e}")))?,
44            );
45        }
46
47        // Layer 2: Project-level config (overrides user config).
48        if let Some(path) = find_project_config() {
49            layers.push(
50                std::fs::read_to_string(&path)
51                    .map_err(|e| ConfigError::FileError(format!("{path:?}: {e}")))?,
52            );
53        }
54
55        let layer_refs: Vec<&str> = layers.iter().map(String::as_str).collect();
56        let mut config = merge_layer_contents(&layer_refs)?;
57
58        // Layer 3: Environment variables override file-based config.
59        // API key from env always wins over config files, because users
60        // expect `OPENAI_API_KEY=x agent` to use key x, even if a
61        // stale key exists in config.toml.
62        let env_api_key = resolve_api_key_from_env();
63        if env_api_key.is_some() {
64            config.api.api_key = env_api_key;
65        }
66
67        // Base URL from env overrides file config.
68        if let Ok(url) = std::env::var("AGENT_CODE_API_BASE_URL") {
69            config.api.base_url = url;
70        }
71
72        // Model from env overrides file config.
73        if let Ok(model) = std::env::var("AGENT_CODE_MODEL") {
74            config.api.model = model;
75        }
76
77        Ok(config)
78    }
79}
80
81/// Merge a sequence of TOML config layers (lowest → highest priority) into a
82/// typed `Config`. Layers are merged at the raw `toml::Value` level *before*
83/// typed deserialization so that `#[serde(default)]` cannot synthesize
84/// placeholder sections that clobber real values from lower layers
85/// (see issue #101). The final `try_into` runs exactly once, so defaults
86/// only fill fields nobody set in any layer.
87///
88/// `permissions.rules` has extend-semantics (layers concatenate rather than
89/// replace), implemented by pulling each layer's rules aside and splicing
90/// them back after the recursive merge.
91pub(crate) fn merge_layer_contents(layers: &[&str]) -> Result<Config, ConfigError> {
92    let mut merged = toml::Value::Table(toml::value::Table::new());
93    let mut all_rules: Vec<toml::Value> = Vec::new();
94
95    for content in layers {
96        if content.is_empty() {
97            continue;
98        }
99        let value: toml::Value = toml::from_str(content)?;
100        collect_permission_rules(&value, &mut all_rules);
101        merge_toml_values(&mut merged, &value);
102    }
103
104    if !all_rules.is_empty()
105        && let toml::Value::Table(root) = &mut merged
106    {
107        let perms = root
108            .entry("permissions".to_string())
109            .or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
110        if let toml::Value::Table(pt) = perms {
111            pt.insert("rules".to_string(), toml::Value::Array(all_rules));
112        }
113    }
114
115    Ok(merged.try_into()?)
116}
117
118/// Recursively merge `overlay` into `base`. Tables merge key-by-key; any
119/// non-table value in `overlay` replaces the value in `base`. Adapted from
120/// openai/codex's `merge_toml_values`.
121fn merge_toml_values(base: &mut toml::Value, overlay: &toml::Value) {
122    if let toml::Value::Table(overlay_table) = overlay
123        && let toml::Value::Table(base_table) = base
124    {
125        for (key, value) in overlay_table {
126            if let Some(existing) = base_table.get_mut(key) {
127                merge_toml_values(existing, value);
128            } else {
129                base_table.insert(key.clone(), value.clone());
130            }
131        }
132    } else {
133        *base = overlay.clone();
134    }
135}
136
137fn collect_permission_rules(value: &toml::Value, out: &mut Vec<toml::Value>) {
138    if let Some(rules) = value
139        .get("permissions")
140        .and_then(|p| p.get("rules"))
141        .and_then(|r| r.as_array())
142    {
143        out.extend(rules.iter().cloned());
144    }
145}
146
147/// Resolve API key from environment variables.
148///
149/// Checks each provider's env var in priority order. Returns the first
150/// one found, or None if no API key is set in the environment.
151fn resolve_api_key_from_env() -> Option<String> {
152    std::env::var("AGENT_CODE_API_KEY")
153        .or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
154        .or_else(|_| std::env::var("OPENAI_API_KEY"))
155        .or_else(|_| std::env::var("XAI_API_KEY"))
156        .or_else(|_| std::env::var("GOOGLE_API_KEY"))
157        .or_else(|_| std::env::var("DEEPSEEK_API_KEY"))
158        .or_else(|_| std::env::var("GROQ_API_KEY"))
159        .or_else(|_| std::env::var("MISTRAL_API_KEY"))
160        .or_else(|_| std::env::var("ZHIPU_API_KEY"))
161        .or_else(|_| std::env::var("TOGETHER_API_KEY"))
162        .or_else(|_| std::env::var("OPENROUTER_API_KEY"))
163        .or_else(|_| std::env::var("COHERE_API_KEY"))
164        .or_else(|_| std::env::var("PERPLEXITY_API_KEY"))
165        .ok()
166}
167
168/// Returns the user-level config file path.
169fn user_config_path() -> Option<PathBuf> {
170    dirs::config_dir().map(|d| d.join("agent-code").join("config.toml"))
171}
172
173/// Walk up from the current directory to find `.agent/settings.toml`.
174fn find_project_config() -> Option<PathBuf> {
175    let cwd = std::env::current_dir().ok()?;
176    find_config_in_ancestors(&cwd)
177}
178
179/// Watch config files for changes and reload when modified.
180/// Returns a handle that can be dropped to stop watching.
181pub fn watch_config(
182    on_reload: impl Fn(Config) + Send + 'static,
183) -> Option<std::thread::JoinHandle<()>> {
184    let user_path = user_config_path()?;
185    let project_path = find_project_config();
186
187    // Get initial mtimes.
188    let user_mtime = std::fs::metadata(&user_path)
189        .ok()
190        .and_then(|m| m.modified().ok());
191    let project_mtime = project_path
192        .as_ref()
193        .and_then(|p| std::fs::metadata(p).ok())
194        .and_then(|m| m.modified().ok());
195
196    Some(std::thread::spawn(move || {
197        let mut last_user = user_mtime;
198        let mut last_project = project_mtime;
199
200        loop {
201            std::thread::sleep(std::time::Duration::from_secs(5));
202
203            let cur_user = std::fs::metadata(&user_path)
204                .ok()
205                .and_then(|m| m.modified().ok());
206            let cur_project = project_path
207                .as_ref()
208                .and_then(|p| std::fs::metadata(p).ok())
209                .and_then(|m| m.modified().ok());
210
211            let changed = cur_user != last_user || cur_project != last_project;
212
213            if changed {
214                if let Ok(config) = Config::load() {
215                    tracing::info!("Config reloaded (file change detected)");
216                    on_reload(config);
217                }
218                last_user = cur_user;
219                last_project = cur_project;
220            }
221        }
222    }))
223}
224
225fn find_config_in_ancestors(start: &Path) -> Option<PathBuf> {
226    let mut dir = start.to_path_buf();
227    loop {
228        let candidate = dir.join(".agent").join("settings.toml");
229        if candidate.exists() {
230            return Some(candidate);
231        }
232        if !dir.pop() {
233            return None;
234        }
235    }
236}
237
238#[cfg(test)]
239mod merge_tests {
240    use super::*;
241
242    fn merge_layers(user: &str, project: &str) -> Config {
243        merge_layer_contents(&[user, project]).unwrap()
244    }
245
246    // ---- Issue #101: project config without [api] must not clobber user api ----
247
248    #[test]
249    fn project_without_api_section_preserves_user_base_url_and_model() {
250        let user = r#"
251[api]
252base_url = "http://localhost:11434/v1"
253model = "gemma4:26b"
254"#;
255        let project = r#"
256[mcp_servers.my-server]
257command = "/usr/local/bin/my-mcp"
258args = []
259"#;
260        let cfg = merge_layers(user, project);
261        assert_eq!(cfg.api.base_url, "http://localhost:11434/v1");
262        assert_eq!(cfg.api.model, "gemma4:26b");
263        assert!(cfg.mcp_servers.contains_key("my-server"));
264    }
265
266    #[test]
267    fn project_partial_api_only_overrides_specified_fields() {
268        let user = r#"
269[api]
270base_url = "http://localhost:11434/v1"
271model = "gemma4:26b"
272"#;
273        let project = r#"
274[api]
275model = "llama3:70b"
276"#;
277        let cfg = merge_layers(user, project);
278        // Project overrides model.
279        assert_eq!(cfg.api.model, "llama3:70b");
280        // base_url is inherited from user, not clobbered by default.
281        assert_eq!(cfg.api.base_url, "http://localhost:11434/v1");
282    }
283
284    #[test]
285    fn project_without_ui_section_preserves_user_theme() {
286        let user = r#"
287[ui]
288theme = "solarized"
289edit_mode = "vi"
290"#;
291        let project = r#"
292[mcp_servers.foo]
293command = "x"
294"#;
295        let cfg = merge_layers(user, project);
296        assert_eq!(cfg.ui.theme, "solarized");
297        assert_eq!(cfg.ui.edit_mode, "vi");
298    }
299
300    #[test]
301    fn project_without_features_preserves_user_feature_flags() {
302        let user = r#"
303[features]
304token_budget = false
305prompt_caching = false
306"#;
307        let project = "";
308        let cfg = merge_layers(user, project);
309        assert!(!cfg.features.token_budget);
310        assert!(!cfg.features.prompt_caching);
311        // Unspecified flags fall back to their struct default (true).
312        assert!(cfg.features.commit_attribution);
313    }
314
315    #[test]
316    fn permission_rules_extend_across_layers() {
317        let user = r#"
318[[permissions.rules]]
319tool = "Read"
320action = "allow"
321
322[[permissions.rules]]
323tool = "Bash"
324pattern = "rm -rf *"
325action = "deny"
326"#;
327        let project = r#"
328[[permissions.rules]]
329tool = "Write"
330action = "ask"
331"#;
332        let cfg = merge_layers(user, project);
333        assert_eq!(cfg.permissions.rules.len(), 3);
334        assert_eq!(cfg.permissions.rules[0].tool, "Read");
335        assert_eq!(cfg.permissions.rules[1].tool, "Bash");
336        assert_eq!(cfg.permissions.rules[2].tool, "Write");
337    }
338
339    #[test]
340    fn mcp_servers_merge_by_name_project_overrides_user() {
341        let user = r#"
342[mcp_servers.alpha]
343command = "user-alpha"
344
345[mcp_servers.beta]
346command = "user-beta"
347"#;
348        let project = r#"
349[mcp_servers.beta]
350command = "project-beta"
351
352[mcp_servers.gamma]
353command = "project-gamma"
354"#;
355        let cfg = merge_layers(user, project);
356        assert_eq!(
357            cfg.mcp_servers["alpha"].command.as_deref(),
358            Some("user-alpha")
359        );
360        assert_eq!(
361            cfg.mcp_servers["beta"].command.as_deref(),
362            Some("project-beta")
363        );
364        assert_eq!(
365            cfg.mcp_servers["gamma"].command.as_deref(),
366            Some("project-gamma")
367        );
368    }
369
370    #[test]
371    fn no_layers_yields_default_config() {
372        let cfg = merge_layers("", "");
373        assert_eq!(cfg.api.model, "gpt-5.4");
374        assert_eq!(cfg.permissions.default_mode, PermissionMode::Ask);
375    }
376
377    // ---- merge_toml_values primitive ----
378
379    #[test]
380    fn merge_toml_values_recursive_table_merge() {
381        let mut base: toml::Value = toml::from_str(
382            r#"
383[api]
384base_url = "http://a"
385model = "m1"
386"#,
387        )
388        .unwrap();
389        let overlay: toml::Value = toml::from_str(
390            r#"
391[api]
392model = "m2"
393"#,
394        )
395        .unwrap();
396        merge_toml_values(&mut base, &overlay);
397        let api = base.get("api").unwrap();
398        assert_eq!(api.get("base_url").unwrap().as_str(), Some("http://a"));
399        assert_eq!(api.get("model").unwrap().as_str(), Some("m2"));
400    }
401
402    #[test]
403    fn merge_toml_values_overlay_replaces_non_table() {
404        let mut base = toml::Value::String("old".into());
405        let overlay = toml::Value::String("new".into());
406        merge_toml_values(&mut base, &overlay);
407        assert_eq!(base.as_str(), Some("new"));
408    }
409}
410
411#[cfg(test)]
412mod e2e_tests {
413    //! End-to-end tests that write real TOML files to a temp directory and
414    //! drive the full file-reading + merge pipeline. These cover everything
415    //! `Config::load` does except the XDG path resolution and env overrides,
416    //! both of which would require process-global mutation to test.
417    //!
418    //! Also covers `find_config_in_ancestors` directly against a tempdir tree.
419
420    use super::*;
421    use std::fs;
422    use tempfile::TempDir;
423
424    /// Write user + project files and drive the full load pipeline the way
425    /// `load_inner` does: read each file to a String, then merge.
426    fn load_from_files(user_toml: Option<&str>, project_toml: Option<&str>) -> Config {
427        let dir = TempDir::new().unwrap();
428        let mut layers: Vec<String> = Vec::new();
429
430        if let Some(body) = user_toml {
431            let path = dir.path().join("user.toml");
432            fs::write(&path, body).unwrap();
433            layers.push(fs::read_to_string(&path).unwrap());
434        }
435        if let Some(body) = project_toml {
436            let path = dir.path().join("project.toml");
437            fs::write(&path, body).unwrap();
438            layers.push(fs::read_to_string(&path).unwrap());
439        }
440
441        let refs: Vec<&str> = layers.iter().map(String::as_str).collect();
442        merge_layer_contents(&refs).unwrap()
443    }
444
445    // ---- Issue #101 reproduction, through real files ----
446
447    #[test]
448    fn e2e_issue_101_ollama_user_preserved_when_project_has_only_mcp_servers() {
449        let user = r#"
450[api]
451base_url = "http://localhost:11434/v1"
452model = "gemma4:26b"
453api_key = "ollama"
454"#;
455        let project = r#"
456[mcp_servers.my-server]
457command = "/usr/local/bin/my-mcp"
458args = []
459"#;
460        let cfg = load_from_files(Some(user), Some(project));
461        assert_eq!(cfg.api.base_url, "http://localhost:11434/v1");
462        assert_eq!(cfg.api.model, "gemma4:26b");
463        assert_eq!(cfg.api.api_key.as_deref(), Some("ollama"));
464        assert_eq!(
465            cfg.mcp_servers["my-server"].command.as_deref(),
466            Some("/usr/local/bin/my-mcp")
467        );
468    }
469
470    #[test]
471    fn e2e_only_user_config_exists() {
472        let user = r#"
473[api]
474base_url = "http://example.com/v1"
475model = "custom"
476"#;
477        let cfg = load_from_files(Some(user), None);
478        assert_eq!(cfg.api.base_url, "http://example.com/v1");
479        assert_eq!(cfg.api.model, "custom");
480    }
481
482    #[test]
483    fn e2e_only_project_config_exists() {
484        let project = r#"
485[api]
486base_url = "http://proj.example.com/v1"
487model = "proj-model"
488"#;
489        let cfg = load_from_files(None, Some(project));
490        assert_eq!(cfg.api.base_url, "http://proj.example.com/v1");
491        assert_eq!(cfg.api.model, "proj-model");
492    }
493
494    #[test]
495    fn e2e_no_config_files_yields_defaults() {
496        let cfg = load_from_files(None, None);
497        assert_eq!(cfg.api.model, "gpt-5.4");
498        assert_eq!(cfg.permissions.default_mode, PermissionMode::Ask);
499        assert!(cfg.ui.markdown);
500    }
501
502    #[test]
503    fn e2e_project_overrides_model_keeps_user_base_url() {
504        let user = r#"
505[api]
506base_url = "http://ollama.local/v1"
507model = "gemma4:26b"
508"#;
509        let project = r#"
510[api]
511model = "llama3:70b"
512"#;
513        let cfg = load_from_files(Some(user), Some(project));
514        assert_eq!(cfg.api.base_url, "http://ollama.local/v1");
515        assert_eq!(cfg.api.model, "llama3:70b");
516    }
517
518    #[test]
519    fn e2e_project_overrides_single_ui_field_keeps_others() {
520        let user = r#"
521[ui]
522theme = "solarized"
523edit_mode = "vi"
524markdown = false
525"#;
526        let project = r#"
527[ui]
528theme = "light"
529"#;
530        let cfg = load_from_files(Some(user), Some(project));
531        assert_eq!(cfg.ui.theme, "light");
532        assert_eq!(cfg.ui.edit_mode, "vi");
533        assert!(!cfg.ui.markdown);
534    }
535
536    #[test]
537    fn e2e_permission_rules_concatenate_across_layers() {
538        let user = r#"
539[[permissions.rules]]
540tool = "Read"
541action = "allow"
542
543[[permissions.rules]]
544tool = "Bash"
545pattern = "rm -rf /"
546action = "deny"
547"#;
548        let project = r#"
549[[permissions.rules]]
550tool = "Write"
551action = "ask"
552"#;
553        let cfg = load_from_files(Some(user), Some(project));
554        assert_eq!(cfg.permissions.rules.len(), 3);
555        let tools: Vec<&str> = cfg
556            .permissions
557            .rules
558            .iter()
559            .map(|r| r.tool.as_str())
560            .collect();
561        assert_eq!(tools, vec!["Read", "Bash", "Write"]);
562    }
563
564    #[test]
565    fn e2e_mcp_servers_union_by_name() {
566        let user = r#"
567[mcp_servers.alpha]
568command = "user-alpha"
569
570[mcp_servers.beta]
571command = "user-beta"
572"#;
573        let project = r#"
574[mcp_servers.beta]
575command = "project-beta"
576
577[mcp_servers.gamma]
578command = "project-gamma"
579"#;
580        let cfg = load_from_files(Some(user), Some(project));
581        assert_eq!(cfg.mcp_servers.len(), 3);
582        assert_eq!(
583            cfg.mcp_servers["alpha"].command.as_deref(),
584            Some("user-alpha")
585        );
586        assert_eq!(
587            cfg.mcp_servers["beta"].command.as_deref(),
588            Some("project-beta")
589        );
590        assert_eq!(
591            cfg.mcp_servers["gamma"].command.as_deref(),
592            Some("project-gamma")
593        );
594    }
595
596    #[test]
597    fn e2e_feature_flags_partial_override() {
598        let user = r#"
599[features]
600token_budget = false
601prompt_caching = false
602"#;
603        let project = r#"
604[features]
605token_budget = true
606"#;
607        let cfg = load_from_files(Some(user), Some(project));
608        assert!(cfg.features.token_budget); // project flipped it
609        assert!(!cfg.features.prompt_caching); // user value preserved
610        assert!(cfg.features.commit_attribution); // struct default
611    }
612
613    #[test]
614    fn e2e_malformed_toml_is_surfaced_as_parse_error() {
615        let bad = "this is = = not valid toml\n[[[";
616        let dir = TempDir::new().unwrap();
617        let path = dir.path().join("bad.toml");
618        fs::write(&path, bad).unwrap();
619        let content = fs::read_to_string(&path).unwrap();
620        let err = merge_layer_contents(&[&content]).unwrap_err();
621        assert!(matches!(err, ConfigError::ParseError(_)));
622    }
623
624    // ---- find_config_in_ancestors over a real directory tree ----
625
626    #[test]
627    fn e2e_find_project_config_walks_up_from_nested_dir() {
628        let root = TempDir::new().unwrap();
629        let project_root = root.path().join("myproj");
630        let nested = project_root.join("crates").join("deep").join("src");
631        fs::create_dir_all(&nested).unwrap();
632        fs::create_dir_all(project_root.join(".agent")).unwrap();
633        let settings = project_root.join(".agent").join("settings.toml");
634        fs::write(&settings, "[api]\nmodel = \"from-ancestor\"\n").unwrap();
635
636        let found = find_config_in_ancestors(&nested).unwrap();
637        assert_eq!(found, settings);
638    }
639
640    #[test]
641    fn e2e_find_project_config_returns_none_when_absent() {
642        let root = TempDir::new().unwrap();
643        let nested = root.path().join("a").join("b").join("c");
644        fs::create_dir_all(&nested).unwrap();
645        // No `.agent/settings.toml` anywhere.
646        // The walk may still hit a real `.agent/settings.toml` in a parent of
647        // the tempdir root (unlikely but possible on dev machines). Guard by
648        // checking the result is either None or outside our tempdir.
649        if let Some(path) = find_config_in_ancestors(&nested) {
650            assert!(
651                !path.starts_with(root.path()),
652                "unexpected settings.toml inside tempdir: {path:?}"
653            );
654        }
655    }
656
657    #[test]
658    fn e2e_find_project_config_stops_at_first_match() {
659        let root = TempDir::new().unwrap();
660        // Two levels, both with .agent/settings.toml. The inner one should win.
661        let outer = root.path().join("outer");
662        let inner = outer.join("inner");
663        fs::create_dir_all(inner.join(".agent")).unwrap();
664        fs::create_dir_all(outer.join(".agent")).unwrap();
665        let inner_settings = inner.join(".agent").join("settings.toml");
666        let outer_settings = outer.join(".agent").join("settings.toml");
667        fs::write(&inner_settings, "[api]\nmodel = \"inner\"\n").unwrap();
668        fs::write(&outer_settings, "[api]\nmodel = \"outer\"\n").unwrap();
669
670        let found = find_config_in_ancestors(&inner).unwrap();
671        assert_eq!(found, inner_settings);
672    }
673}