1mod schema;
13
14pub use schema::*;
15
16use crate::error::ConfigError;
17use std::path::{Path, PathBuf};
18
19static LOADING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
21
22impl Config {
23 pub fn load() -> Result<Config, ConfigError> {
25 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 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 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 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 if let Ok(url) = std::env::var("AGENT_CODE_API_BASE_URL") {
69 config.api.base_url = url;
70 }
71
72 if let Ok(model) = std::env::var("AGENT_CODE_MODEL") {
74 config.api.model = model;
75 }
76
77 Ok(config)
78 }
79}
80
81pub(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
118fn 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
147fn 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
168fn user_config_path() -> Option<PathBuf> {
170 dirs::config_dir().map(|d| d.join("agent-code").join("config.toml"))
171}
172
173fn find_project_config() -> Option<PathBuf> {
175 let cwd = std::env::current_dir().ok()?;
176 find_config_in_ancestors(&cwd)
177}
178
179pub 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 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 #[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 assert_eq!(cfg.api.model, "llama3:70b");
280 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 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 #[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 use super::*;
421 use std::fs;
422 use tempfile::TempDir;
423
424 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 #[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); assert!(!cfg.features.prompt_caching); assert!(cfg.features.commit_attribution); }
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 #[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 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 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}