1pub mod instructions;
2pub mod memory;
3pub mod prompt_sections;
4pub mod provider;
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12use atomcode_telemetry::TelemetryConfig;
13use provider::ProviderConfig;
14
15#[allow(clippy::needless_raw_string_hashes)]
22pub const WINDOWS_RULES: &str = r##"\
23
24## WINDOWS PLATFORM RULES:
25
26- Bash runs via cmd.exe, NOT WSL. Use Windows syntax: dir (not ls), where (not which), type (not cat).
27- Path separators: use \\ in commands. Example: cd src\\components
28- Install tools: use winget, choco, or direct download. NOT apt/brew.
29- Check tools: where <tool_name> (not which).
30- PowerShell: for complex scripts, use powershell -Command "..."
31- Virtual environments: check for Scripts\\ subdirectory (not bin/)"##;
32
33pub const MACOS_RULES: &str = "";
35
36pub const LINUX_RULES: &str = "";
38
39pub fn platform_rules() -> &'static str {
41 if cfg!(target_os = "windows") {
42 WINDOWS_RULES
43 } else if cfg!(target_os = "macos") {
44 MACOS_RULES
45 } else {
46 LINUX_RULES
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(default)]
55pub struct SubAgentConfig {
56 pub enabled: bool,
59 pub initial_turns: usize,
62 pub max_turns: usize,
64 pub max_concurrent: usize,
66 pub timeout_secs: u64,
68}
69
70impl Default for SubAgentConfig {
71 fn default() -> Self {
72 Self {
73 enabled: true,
74 initial_turns: 4,
75 max_turns: 12,
76 max_concurrent: 3,
77 timeout_secs: 300,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Config {
84 pub default_provider: String,
85 pub default_workdir: Option<String>,
87 pub providers: HashMap<String, ProviderConfig>,
88 #[serde(default, skip_serializing)]
96 pub datalog: DatalogConfig,
97 #[serde(default, skip_serializing)]
100 pub notifications: NotificationConfig,
101 #[serde(default = "default_true")]
107 pub auto_update: bool,
108 #[serde(default, skip_serializing)]
113 pub telemetry: TelemetryConfig,
114 #[serde(default)]
116 pub lsp: LspConfig,
117 #[serde(default)]
120 pub auto_commit: bool,
121 #[serde(default)]
124 pub subagent: SubAgentConfig,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub vision_preprocessor_provider: Option<String>,
134 #[serde(default)]
138 pub language: Option<crate::locale::Locale>,
139 #[serde(default)]
144 pub ui: UiConfig,
145 #[serde(default)]
152 pub plugin: PluginConfig,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct PluginConfig {
159 #[serde(default = "default_true")]
168 pub auto_install_default_skills: bool,
169 #[serde(default = "default_true")]
177 pub auto_update_marketplaces: bool,
178}
179
180impl Default for PluginConfig {
181 fn default() -> Self {
182 Self {
183 auto_install_default_skills: true,
184 auto_update_marketplaces: true,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192pub struct UiConfig {
193 #[serde(default)]
199 pub theme: UiTheme,
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
210#[serde(rename_all = "lowercase")]
211pub enum UiTheme {
212 #[default]
213 Auto,
214 Dark,
215 Light,
216}
217
218impl Config {
219 pub fn can_handle_attached_images(&self) -> bool {
226 let active_accepts = self
227 .providers
228 .get(&self.default_provider)
229 .map(|p| p.accepts_images())
230 .unwrap_or(false);
231 if active_accepts {
232 return true;
233 }
234 let vp_key = match self.vision_preprocessor_provider.as_deref() {
235 Some(k) if !k.is_empty() => k,
236 _ => return false,
237 };
238 self.providers.contains_key(vp_key)
239 }
240}
241
242impl Default for Config {
243 fn default() -> Self {
244 Self {
245 default_provider: String::new(),
246 default_workdir: None,
247 providers: HashMap::new(),
248 datalog: Default::default(),
249 notifications: Default::default(),
250 auto_update: true,
251 telemetry: Default::default(),
252 lsp: Default::default(),
253 auto_commit: false,
254 subagent: Default::default(),
255 vision_preprocessor_provider: None,
256 language: None,
257 ui: UiConfig::default(),
258 plugin: PluginConfig::default(),
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct DatalogConfig {
266 #[serde(default = "default_true")]
268 pub enabled: bool,
269 #[serde(default)]
277 pub dir: Option<String>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct NotificationConfig {
283 #[serde(default = "default_true")]
285 pub enabled: bool,
286 #[serde(default = "default_notification_min_duration_secs")]
288 pub min_duration_secs: u64,
289 #[serde(default = "default_true")]
291 pub terminal: bool,
292 #[serde(default = "default_true")]
294 pub system: bool,
295 #[serde(default = "default_true")]
297 pub bell: bool,
298 #[serde(default = "default_true")]
300 pub background_only: bool,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct LspConfig {
314 #[serde(default)]
316 pub enabled: bool,
317 #[serde(default)]
322 pub auto_detect: bool,
323 #[serde(default)]
325 pub servers: std::collections::HashMap<String, crate::lsp::registry::LspServerConfig>,
326 #[serde(default = "default_diagnostics_settle_delay_ms")]
330 pub diagnostics_settle_delay_ms: u64,
331}
332
333fn default_diagnostics_settle_delay_ms() -> u64 {
334 150
335}
336
337impl Default for LspConfig {
338 fn default() -> Self {
339 Self {
340 enabled: false,
341 auto_detect: false,
342 servers: Default::default(),
343 diagnostics_settle_delay_ms: default_diagnostics_settle_delay_ms(),
344 }
345 }
346}
347
348fn migrate_legacy_lsp_default(cfg: &mut Config) {
370 let looks_auto_written = cfg.lsp.enabled
371 && cfg.lsp.auto_detect
372 && cfg.lsp.diagnostics_settle_delay_ms == 150
373 && cfg.lsp.servers.is_empty();
374 if looks_auto_written {
375 cfg.lsp = LspConfig::default();
376 }
377}
378
379fn default_true() -> bool {
380 true
381}
382fn default_notification_min_duration_secs() -> u64 {
383 8
384}
385
386impl Default for DatalogConfig {
387 fn default() -> Self {
388 Self {
389 enabled: true,
390 dir: Some("~/.atomcode/datalog".to_string()),
395 }
396 }
397}
398
399impl Default for NotificationConfig {
400 fn default() -> Self {
401 Self {
402 enabled: true,
403 min_duration_secs: default_notification_min_duration_secs(),
404 terminal: true,
405 system: true,
406 bell: true,
407 background_only: true,
408 }
409 }
410}
411
412fn render_datalog_section(cfg: &DatalogConfig) -> String {
418 let mut out = String::new();
419 out.push_str("\n# Per-turn datalog. Each turn writes a markdown summary; each LLM\n");
420 out.push_str("# round writes a JSON request/response pair under `<dir>/<project>/llm/`.\n");
421 out.push_str("# A per-project subdirectory is always appended under `dir` so multiple\n");
422 out.push_str("# projects never share a bucket.\n");
423 out.push_str("# - enabled = false -> disable logging entirely\n");
424 out.push_str("# - dir = \"~/.atomcode/datalog\" -> default (follows $HOME, ignores /cd)\n");
425 out.push_str("# - dir = \"/abs/path\" -> absolute, fixed (unaffected by /cd)\n");
426 out.push_str("# - dir = \"rel/path\" -> joined with current working_dir, follows /cd\n");
427 out.push_str("[datalog]\n");
428 out.push_str(&format!("enabled = {}\n", cfg.enabled));
429 let dir_value = cfg.dir.as_deref().unwrap_or("~/.atomcode/datalog");
430 let escaped = dir_value.replace('\\', "\\\\").replace('"', "\\\"");
431 out.push_str(&format!("dir = \"{}\"\n", escaped));
432 out
433}
434
435fn render_notifications_section(cfg: &NotificationConfig) -> String {
436 let mut out = String::new();
437 out.push_str("\n# Long-running task completion notifications.\n");
438 out.push_str("# Strategy: terminal-native notifications first (kitty / WezTerm / iTerm2),\n");
439 out.push_str(
440 "# then OS-native fallback when available (macOS osascript, Linux notify-send).\n",
441 );
442 out.push_str("# Windows mainly relies on BEL + terminal attention/taskbar flash.\n");
443 out.push_str("# `background_only` is best-effort: focus-aware terminal protocols honor it,\n");
444 out.push_str("# while some OS fallbacks may still notify even if AtomCode is focused.\n");
445 out.push_str("[notifications]\n");
446 out.push_str(&format!("enabled = {}\n", cfg.enabled));
447 out.push_str(&format!("min_duration_secs = {}\n", cfg.min_duration_secs));
448 out.push_str(&format!("terminal = {}\n", cfg.terminal));
449 out.push_str(&format!("system = {}\n", cfg.system));
450 out.push_str(&format!("bell = {}\n", cfg.bell));
451 out.push_str(&format!("background_only = {}\n", cfg.background_only));
452 out
453}
454
455fn render_telemetry_section(cfg: &TelemetryConfig) -> String {
456 if cfg.enabled.is_none() && cfg.endpoint.is_none() {
457 return String::new();
458 }
459
460 let mut out = String::new();
461 out.push_str("\n# Anonymous telemetry. Omit `enabled` for the default enabled behavior.\n");
462 out.push_str("# Set `enabled = false` to opt out persistently.\n");
463 out.push_str("[telemetry]\n");
464 if let Some(enabled) = cfg.enabled {
465 out.push_str(&format!("enabled = {}\n", enabled));
466 }
467 if let Some(endpoint) = cfg.endpoint.as_deref() {
468 let escaped = endpoint.replace('\\', "\\\\").replace('"', "\\\"");
469 out.push_str(&format!("endpoint = \"{}\"\n", escaped));
470 }
471 out
472}
473
474fn render_instructions_section() -> String {
477 let mut out = String::new();
478 out.push_str("\n# Project instructions — customize AI behavior via Markdown files.\n");
479 out.push_str("# AtomCode loads instructions from three levels (low → high priority):\n");
480 out.push_str("#\n");
481 out.push_str("# 1. ~/.atomcode/ATOMCODE.md (global — your personal defaults)\n");
482 out.push_str(
483 "# 2. <project>/.atomcode.md (project — team-shared, commit to git)\n",
484 );
485 out.push_str("# or <project>/ATOMCODE.md\n");
486 out.push_str("# or <project>/CLAUDE.md / claude.md (Claude Code compat)\n");
487 out.push_str(
488 "# 3. <project>/.atomcode.user.md (user — personal per-project, .gitignore)\n",
489 );
490 out.push_str("#\n");
491 out.push_str("# Higher priority files appear later in the prompt (recency effect).\n");
492 out.push_str(
493 "# Use /status to see which files are loaded. Use /init to generate a template.\n",
494 );
495 out.push_str("#\n");
496 out.push_str("# Example ~/.atomcode/ATOMCODE.md:\n");
497 out.push_str("# ## Global Preferences\n");
498 out.push_str("# - Reply in Chinese\n");
499 out.push_str("# - Don't add AI co-author tags to commits\n");
500 out.push_str("#\n");
501 out.push_str("# Example <project>/.atomcode.md:\n");
502 out.push_str("# ## Project Rules\n");
503 out.push_str("# - This is a Rust workspace with 5 crates\n");
504 out.push_str("# - Use anyhow::Result for error handling\n");
505 out.push_str("# - All public APIs must have doc comments\n");
506 out
507}
508
509fn render_hooks_json_section() -> String {
510 let mut out = String::new();
511 out.push_str("\n# Lifecycle hooks — configure in separate JSON files:\n");
512 out.push_str("# ~/.atomcode/hooks.json (global hooks)\n");
513 out.push_str("# <project>/.hooks.json (project hooks, override global by name)\n");
514 out.push_str("#\n");
515 out.push_str("# Example hooks.json:\n");
516 out.push_str("# {\n");
517 out.push_str("# \"hooks\": {\n");
518 out.push_str("# \"audit-all\": {\n");
519 out.push_str("# \"event\": \"pre_tool_use\",\n");
520 out.push_str("# \"command\": \"echo \\\"$(date) $ATOMCODE_TOOL_NAME\\\" >> ~/.atomcode/audit.log\"\n");
521 out.push_str("# },\n");
522 out.push_str("# \"block-rm\": {\n");
523 out.push_str("# \"event\": \"pre_tool_use\",\n");
524 out.push_str("# \"matcher\": \"bash\",\n");
525 out.push_str("# \"command\": \"your-safety-check.sh\",\n");
526 out.push_str("# \"timeout_ms\": 5000\n");
527 out.push_str("# }\n");
528 out.push_str("# }\n");
529 out.push_str("# }\n");
530 out.push_str("#\n");
531 out.push_str("# Events: pre_tool_use, post_tool_use, session_start, session_end\n");
532 out.push_str("# Env vars: ATOMCODE_HOOK_EVENT, ATOMCODE_TOOL_NAME, ATOMCODE_HOOK_CONTEXT\n");
533 out.push_str("# PreToolUse stdout: {\"action\":\"allow\"} or {\"action\":\"block\",\"reason\":\"...\"}\n");
534 out
535}
536
537impl Config {
538 pub fn default_context_window(&self) -> usize {
543 self.providers
544 .get(&self.default_provider)
545 .map(|p| p.context_window)
546 .unwrap_or(128_000)
547 }
548
549 pub fn load(path: &Path) -> Result<Self> {
550 let content = std::fs::read_to_string(path)
551 .with_context(|| format!("Failed to read config: {}", path.display()))?;
552 let mut config: Config = toml::from_str(&content)
553 .with_context(|| format!("Failed to parse config: {}", path.display()))?;
554 migrate_legacy_lsp_default(&mut config);
555 Ok(config)
556 }
557
558 pub fn save(&self, path: &Path) -> Result<()> {
559 if let Some(parent) = path.parent() {
560 std::fs::create_dir_all(parent)?;
561 }
562 let mut persistent = self.clone();
564 persistent.providers.retain(|_, v| !v.ephemeral);
565 if !self
567 .providers
568 .get(&self.default_provider)
569 .map(|p| !p.ephemeral)
570 .unwrap_or(true)
571 {
572 if let Ok(disk) = Config::load(path) {
574 persistent.default_provider = disk.default_provider;
575 }
576 }
577 let mut content = toml::to_string_pretty(&persistent)?;
578 content.push_str(&render_datalog_section(&self.datalog));
579 content.push_str(&render_notifications_section(&self.notifications));
580 content.push_str(&render_telemetry_section(&self.telemetry));
581 content.push_str(&render_instructions_section());
582 content.push_str(&render_hooks_json_section());
583 std::fs::write(path, content)?;
584 Ok(())
585 }
586
587 pub fn active_provider(&self, override_name: Option<&str>) -> Result<&ProviderConfig> {
588 let name: &str = override_name
595 .filter(|s| !s.is_empty())
596 .unwrap_or(&self.default_provider);
597 let fallback = || {
598 self.providers
599 .keys()
600 .min()
601 .map(String::as_str)
602 .ok_or_else(|| {
603 anyhow::anyhow!("No providers configured — run /codingplan or /provider")
604 })
605 };
606 let name: &str = if name.is_empty() {
607 fallback()?
608 } else {
609 name
610 };
611 match self.providers.get(name) {
612 Some(p) => Ok(p),
613 None => {
614 let fallback_name = fallback()?;
617 Ok(self.providers.get(fallback_name).unwrap())
620 }
621 }
622 }
623
624 fn resolve_config_dir(env_atomcode_home: Option<String>, home: Option<PathBuf>) -> PathBuf {
627 if let Some(p) = env_atomcode_home {
628 return PathBuf::from(p);
629 }
630 home.unwrap_or_else(|| PathBuf::from(".")).join(".atomcode")
631 }
632
633 pub fn config_dir() -> PathBuf {
634 Self::resolve_config_dir(
635 std::env::var("ATOMCODE_HOME")
636 .ok()
637 .filter(|s| !s.is_empty()),
638 crate::tool::real_home_dir(),
639 )
640 }
641
642 pub fn default_path() -> PathBuf {
643 Self::config_dir().join("config.toml")
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[test]
657 fn lsp_config_defaults_to_disabled_opt_in() {
658 let cfg = LspConfig::default();
659 assert!(!cfg.enabled, "LSP enabled must default to false");
660 assert!(
661 !cfg.auto_detect,
662 "LSP auto_detect must default to false even if enabled flips on"
663 );
664 }
665
666 #[test]
672 fn migrate_resets_auto_written_lsp_to_disabled() {
673 let mut cfg = blank_config_with_lsp(LspConfig {
674 enabled: true,
675 auto_detect: true,
676 servers: Default::default(),
677 diagnostics_settle_delay_ms: 150,
678 });
679 migrate_legacy_lsp_default(&mut cfg);
680 assert!(!cfg.lsp.enabled, "auto-written shape must reset to disabled");
681 assert!(!cfg.lsp.auto_detect);
682 }
683
684 #[test]
688 fn migrate_keeps_user_customised_lsp_intact() {
689 let mut servers = std::collections::HashMap::new();
691 servers.insert(
692 "rs".to_string(),
693 crate::lsp::registry::LspServerConfig {
694 command: "my-custom-rust-ls".to_string(),
695 args: vec![],
696 root_markers: vec![],
697 },
698 );
699 let mut cfg = blank_config_with_lsp(LspConfig {
700 enabled: true,
701 auto_detect: true,
702 servers,
703 diagnostics_settle_delay_ms: 150,
704 });
705 migrate_legacy_lsp_default(&mut cfg);
706 assert!(cfg.lsp.enabled, "custom servers means user opt-in; keep");
707
708 let mut cfg2 = blank_config_with_lsp(LspConfig {
710 enabled: true,
711 auto_detect: true,
712 servers: Default::default(),
713 diagnostics_settle_delay_ms: 500,
714 });
715 migrate_legacy_lsp_default(&mut cfg2);
716 assert!(cfg2.lsp.enabled, "non-default delay means user tuned; keep");
717
718 let mut cfg3 = blank_config_with_lsp(LspConfig {
721 enabled: true,
722 auto_detect: false,
723 servers: Default::default(),
724 diagnostics_settle_delay_ms: 150,
725 });
726 migrate_legacy_lsp_default(&mut cfg3);
727 assert!(cfg3.lsp.enabled, "auto_detect=false means user picked manual; keep");
728 }
729
730 #[test]
734 fn migrate_noop_on_already_disabled() {
735 let mut cfg = blank_config_with_lsp(LspConfig::default());
736 migrate_legacy_lsp_default(&mut cfg);
737 assert!(!cfg.lsp.enabled);
738 assert!(!cfg.lsp.auto_detect);
739 }
740
741 fn blank_config_with_lsp(lsp: LspConfig) -> Config {
742 Config {
743 default_provider: "x".into(),
744 default_workdir: None,
745 providers: Default::default(),
746 datalog: Default::default(),
747 auto_update: true,
748 notifications: Default::default(),
749 telemetry: Default::default(),
750 lsp,
751 auto_commit: false,
752 subagent: Default::default(),
753 vision_preprocessor_provider: None,
754 language: None,
755 ui: Default::default(),
756 plugin: Default::default(),
757 }
758 }
759
760 #[test]
764 fn lsp_section_omitted_in_toml_yields_disabled() {
765 let toml_str = r#"
766 default_provider = "claude"
767
768 [providers.claude]
769 type = "claude"
770 api_key = "sk-ant-test"
771 model = "claude-opus-4-6"
772 "#;
773 let cfg: Config = toml::from_str(toml_str).expect("config parses");
774 assert!(!cfg.lsp.enabled, "missing [lsp] must keep LSP off");
775 assert!(!cfg.lsp.auto_detect);
776 }
777
778 #[test]
779 fn test_resolve_config_dir_uses_env_when_set() {
780 let result = Config::resolve_config_dir(
781 Some("/tmp/custom-atomcode-home".to_string()),
782 Some(PathBuf::from("/Users/foo")),
783 );
784 assert_eq!(result, PathBuf::from("/tmp/custom-atomcode-home"));
785 }
786
787 #[test]
788 fn test_resolve_config_dir_falls_back_to_home() {
789 let result = Config::resolve_config_dir(None, Some(PathBuf::from("/Users/foo")));
790 assert_eq!(result, PathBuf::from("/Users/foo/.atomcode"));
791 }
792
793 #[test]
794 fn test_resolve_config_dir_falls_back_to_dot_when_no_home() {
795 let result = Config::resolve_config_dir(None, None);
796 assert_eq!(result, PathBuf::from("./.atomcode"));
797 }
798
799 #[test]
800 fn test_parse_minimal_config() {
801 let toml_str = r#"
802 default_provider = "claude"
803
804 [providers.claude]
805 type = "claude"
806 api_key = "sk-ant-test"
807 model = "claude-opus-4-6"
808 "#;
809 let config: Config = toml::from_str(toml_str).unwrap();
810 assert_eq!(config.default_provider, "claude");
811 assert_eq!(config.providers.len(), 1);
812 let p = &config.providers["claude"];
813 assert_eq!(p.provider_type, "claude");
814 assert_eq!(p.api_key.as_deref(), Some("sk-ant-test"));
815 assert_eq!(p.model, "claude-opus-4-6");
816 }
817
818 #[test]
819 fn test_parse_multi_provider_config() {
820 let toml_str = r#"
821 default_provider = "openai"
822
823 [providers.claude]
824 type = "claude"
825 api_key = "sk-ant-test"
826 model = "claude-opus-4-6"
827
828 [providers.openai]
829 type = "openai"
830 api_key = "sk-test"
831 model = "gpt-4o"
832 base_url = "https://api.openai.com/v1"
833
834 [providers.ollama]
835 type = "ollama"
836 model = "llama3"
837 base_url = "http://localhost:11434"
838 "#;
839 let config: Config = toml::from_str(toml_str).unwrap();
840 assert_eq!(config.default_provider, "openai");
841 assert_eq!(config.providers.len(), 3);
842 assert_eq!(
843 config.providers["ollama"].base_url.as_deref(),
844 Some("http://localhost:11434")
845 );
846 assert!(config.providers["ollama"].api_key.is_none());
847 }
848
849 #[test]
850 fn test_get_active_provider_config() {
851 let toml_str = r#"
852 default_provider = "claude"
853
854 [providers.claude]
855 type = "claude"
856 api_key = "sk-ant-test"
857 model = "claude-opus-4-6"
858 "#;
859 let config: Config = toml::from_str(toml_str).unwrap();
860 let provider = config.active_provider(None).unwrap();
861 assert_eq!(provider.model, "claude-opus-4-6");
862 }
863
864 #[test]
865 fn render_datalog_section_default_emits_active_dir() {
866 let rendered = render_datalog_section(&DatalogConfig::default());
867 assert!(rendered.contains("[datalog]"));
868 assert!(rendered.contains("enabled = true"));
869 assert!(
870 rendered.contains("\ndir = \"~/.atomcode/datalog\"\n"),
871 "default must emit the resolved dir as a real, uncommented value: {}",
872 rendered
873 );
874 }
875
876 #[test]
877 fn render_datalog_section_unset_dir_still_shows_default() {
878 let cfg = DatalogConfig {
882 enabled: true,
883 dir: None,
884 };
885 let rendered = render_datalog_section(&cfg);
886 assert!(rendered.contains("\ndir = \"~/.atomcode/datalog\"\n"));
887 }
888
889 #[test]
890 fn render_datalog_section_with_dir_emits_real_value() {
891 let cfg = DatalogConfig {
892 enabled: false,
893 dir: Some("~/.atomcode/logs".to_string()),
894 };
895 let rendered = render_datalog_section(&cfg);
896 assert!(rendered.contains("enabled = false"));
897 assert!(rendered.contains("dir = \"~/.atomcode/logs\""));
898 }
899
900 #[test]
901 fn saved_config_roundtrips_datalog() {
902 let tmp = std::env::temp_dir().join(format!("atomcode_cfg_rt_{}.toml", std::process::id()));
903 let mut cfg = Config {
904 default_provider: "p".to_string(),
905 default_workdir: None,
906 providers: HashMap::new(),
907 datalog: DatalogConfig {
908 enabled: false,
909 dir: Some("/var/log/ac".to_string()),
910 },
911 notifications: NotificationConfig::default(),
912 auto_update: true,
913 telemetry: Default::default(),
914 lsp: Default::default(),
915 auto_commit: false,
916 subagent: Default::default(),
917 vision_preprocessor_provider: None,
918 language: None,
919 ui: Default::default(),
920 plugin: Default::default(),
921 };
922 cfg.providers.insert(
923 "p".to_string(),
924 ProviderConfig {
925 provider_type: "openai".to_string(),
926 api_key: Some("k".to_string()),
927 model: "m".to_string(),
928 base_url: None,
929 system_prompt: None,
930 user_agent: None,
931 context_window: 16000,
932 max_tokens: None,
933 thinking_type: None,
934 thinking_keep: None,
935 reasoning_history: None,
936 thinking_enabled: None,
937 thinking_budget: None,
938 skip_tls_verify: false,
939 ephemeral: false,
940
941},
942 );
943 cfg.save(&tmp).unwrap();
944 let text = std::fs::read_to_string(&tmp).unwrap();
945 assert!(text.contains("[datalog]"));
946 assert!(text.contains("enabled = false"));
947 assert!(text.contains("dir = \"/var/log/ac\""));
948 let reloaded = Config::load(&tmp).unwrap();
949 assert!(!reloaded.datalog.enabled);
950 assert_eq!(reloaded.datalog.dir.as_deref(), Some("/var/log/ac"));
951 assert!(reloaded.notifications.enabled);
952 let _ = std::fs::remove_file(&tmp);
953 }
954
955 #[test]
956 fn render_notifications_section_emits_defaults() {
957 let rendered = render_notifications_section(&NotificationConfig::default());
958 assert!(rendered.contains("[notifications]"));
959 assert!(rendered.contains("enabled = true"));
960 assert!(rendered.contains("min_duration_secs = 8"));
961 assert!(rendered.contains("background_only = true"));
962 }
963
964 #[test]
965 fn test_override_provider() {
966 let toml_str = r#"
967 default_provider = "claude"
968
969 [providers.claude]
970 type = "claude"
971 api_key = "sk-ant-test"
972 model = "claude-opus-4-6"
973
974 [providers.openai]
975 type = "openai"
976 api_key = "sk-test"
977 model = "gpt-4o"
978 "#;
979 let config: Config = toml::from_str(toml_str).unwrap();
980 let provider = config.active_provider(Some("openai")).unwrap();
981 assert_eq!(provider.model, "gpt-4o");
982 }
983
984 #[test]
985 fn active_provider_falls_back_when_default_is_empty() {
986 let toml_str = r#"
991 default_provider = ""
992
993 [providers.zeta]
994 type = "openai"
995 api_key = "sk-z"
996 model = "gpt-4o"
997
998 [providers.alpha]
999 type = "claude"
1000 api_key = "sk-a"
1001 model = "claude-opus-4-6"
1002 "#;
1003 let config: Config = toml::from_str(toml_str).unwrap();
1004 let provider = config.active_provider(None).unwrap();
1005 assert_eq!(provider.model, "claude-opus-4-6");
1006 }
1007
1008 #[test]
1009 fn active_provider_ignores_empty_override() {
1010 let toml_str = r#"
1011 default_provider = "claude"
1012
1013 [providers.claude]
1014 type = "claude"
1015 api_key = "sk-ant-test"
1016 model = "claude-opus-4-6"
1017 "#;
1018 let config: Config = toml::from_str(toml_str).unwrap();
1019 let provider = config.active_provider(Some("")).unwrap();
1020 assert_eq!(provider.model, "claude-opus-4-6");
1021 }
1022
1023 #[test]
1024 fn active_provider_errors_with_no_providers_and_empty_default() {
1025 let toml_str = r#"
1026 default_provider = ""
1027 [providers]
1028 "#;
1029 let config: Config = toml::from_str(toml_str).unwrap();
1030 let err = config.active_provider(None).unwrap_err();
1031 assert!(
1032 err.to_string().contains("No providers configured"),
1033 "unexpected error: {err}"
1034 );
1035 }
1036 #[test]
1037 fn active_provider_falls_back_when_default_points_to_deleted_provider() {
1038 let toml_str = r#"
1044 default_provider = "AtomGit-Qwen"
1045
1046 [providers.openai]
1047 type = "openai"
1048 api_key = "sk-test"
1049 model = "gpt-4o"
1050
1051 [providers.claude]
1052 type = "claude"
1053 api_key = "sk-a"
1054 model = "claude-opus-4-6"
1055 "#;
1056 let config: Config = toml::from_str(toml_str).unwrap();
1057 let provider = config.active_provider(None).unwrap();
1058 assert_eq!(provider.model, "claude-opus-4-6");
1060 }
1061
1062 #[test]
1063 fn active_provider_falls_back_when_override_points_to_deleted_provider() {
1064 let toml_str = r#"
1066 default_provider = "openai"
1067
1068 [providers.openai]
1069 type = "openai"
1070 api_key = "sk-test"
1071 model = "gpt-4o"
1072
1073 [providers.claude]
1074 type = "claude"
1075 api_key = "sk-a"
1076 model = "claude-opus-4-6"
1077 "#;
1078 let config: Config = toml::from_str(toml_str).unwrap();
1079 let provider = config.active_provider(Some("nonexistent")).unwrap();
1080 assert_eq!(provider.model, "claude-opus-4-6");
1082 }
1083
1084 #[test]
1085 fn active_provider_errors_when_default_deleted_and_no_other_providers() {
1086 let toml_str = r#"
1089 default_provider = "deleted"
1090 [providers]
1091 "#;
1092 let config: Config = toml::from_str(toml_str).unwrap();
1093 let err = config.active_provider(None).unwrap_err();
1094 assert!(
1095 err.to_string().contains("No providers configured"),
1096 "unexpected error: {err}"
1097 );
1098 }
1099
1100 #[test]
1101 fn vision_preprocessor_provider_defaults_to_none() {
1102 let toml_str = r#"
1106 default_provider = "claude"
1107 [providers.claude]
1108 type = "claude"
1109 model = "claude-sonnet-4-5"
1110 api_key = "sk-test"
1111 "#;
1112 let cfg: Config = toml::from_str(toml_str).expect("parse minimal config");
1113 assert_eq!(cfg.vision_preprocessor_provider, None);
1114 }
1115
1116 #[test]
1117 fn saved_config_roundtrips_language() {
1118 let tmp = tempfile::NamedTempFile::new().unwrap();
1119 let mut cfg = Config {
1120 default_provider: "p".to_string(),
1121 default_workdir: None,
1122 providers: HashMap::new(),
1123 datalog: DatalogConfig::default(),
1124 notifications: NotificationConfig::default(),
1125 auto_update: true,
1126 telemetry: Default::default(),
1127 lsp: Default::default(),
1128 auto_commit: false,
1129 subagent: Default::default(),
1130 vision_preprocessor_provider: None,
1131 language: Some(crate::locale::Locale::ZhCn),
1132 ui: Default::default(),
1133 plugin: Default::default(),
1134 };
1135 cfg.providers.insert(
1136 "p".to_string(),
1137 ProviderConfig {
1138 provider_type: "openai".to_string(),
1139 api_key: Some("k".to_string()),
1140 model: "m".to_string(),
1141 base_url: None,
1142 system_prompt: None,
1143 user_agent: None,
1144 context_window: 16000,
1145 max_tokens: None,
1146 thinking_type: None,
1147 thinking_keep: None,
1148 reasoning_history: None,
1149 thinking_enabled: None,
1150 thinking_budget: None,
1151 skip_tls_verify: false,
1152 ephemeral: false,
1153 },
1154 );
1155 cfg.save(tmp.path()).unwrap();
1156
1157 let loaded = Config::load(tmp.path()).unwrap();
1158 assert_eq!(loaded.language, Some(crate::locale::Locale::ZhCn));
1159 }
1160
1161 #[test]
1162 fn config_default_has_no_language() {
1163 let toml_str = r#"
1164 default_provider = "test"
1165 [providers]
1166 "#;
1167 let cfg: Config = toml::from_str(toml_str).unwrap();
1168 assert_eq!(cfg.language, None);
1169 }
1170
1171 #[test]
1172 fn config_missing_language_field_loads_as_none() {
1173 let tmp = tempfile::NamedTempFile::new().unwrap();
1174 std::fs::write(tmp.path(), "default_provider = \"foo\"\n[providers]\n").unwrap();
1175 let loaded = Config::load(tmp.path()).unwrap();
1176 assert_eq!(loaded.language, None);
1177 }
1178
1179 #[test]
1180 fn vision_preprocessor_provider_round_trips_through_toml() {
1181 let toml_str = r#"
1182 default_provider = "claude"
1183 vision_preprocessor_provider = "AtomGit-Qwen-Qwen3-VL-32B-Instruct"
1184 [providers.claude]
1185 type = "claude"
1186 model = "claude-sonnet-4-5"
1187 api_key = "sk-test"
1188 "#;
1189 let cfg: Config = toml::from_str(toml_str).expect("parse");
1190 assert_eq!(
1191 cfg.vision_preprocessor_provider.as_deref(),
1192 Some("AtomGit-Qwen-Qwen3-VL-32B-Instruct"),
1193 );
1194 }
1195
1196 fn cfg_with(active_model: &str, preprocessor_key: Option<&str>) -> Config {
1199 let mut providers = std::collections::HashMap::new();
1200 providers.insert(
1201 "active".to_string(),
1202 crate::config::provider::ProviderConfig {
1203 provider_type: "openai".into(),
1204 api_key: Some("sk-test".into()),
1205 model: active_model.into(),
1206 base_url: Some("http://127.0.0.1/".into()),
1207 system_prompt: None,
1208 user_agent: None,
1209 context_window: 8000,
1210 max_tokens: None,
1211 thinking_type: None,
1212 thinking_keep: None,
1213 reasoning_history: None,
1214 thinking_enabled: None,
1215 thinking_budget: None,
1216 skip_tls_verify: false,
1217 ephemeral: false,
1218 },
1219 );
1220 Config {
1221 default_provider: "active".into(),
1222 default_workdir: None,
1223 providers,
1224 datalog: Default::default(),
1225 auto_update: true,
1226 notifications: Default::default(),
1227 telemetry: Default::default(),
1228 lsp: Default::default(),
1229 auto_commit: false,
1230 subagent: Default::default(),
1231 vision_preprocessor_provider: preprocessor_key.map(|s| s.to_string()),
1232 language: None,
1233 ui: Default::default(),
1234 plugin: Default::default(),
1235 }
1236 }
1237
1238 #[test]
1239 fn can_handle_attached_images_true_when_active_provider_accepts_images() {
1240 let cfg = cfg_with("claude-sonnet-4-5", None);
1242 assert!(cfg.can_handle_attached_images());
1243 }
1244
1245 #[test]
1246 fn can_handle_attached_images_false_for_text_only_main_and_no_preprocessor() {
1247 let cfg = cfg_with("deepseek-v4-flash", None);
1249 assert!(!cfg.can_handle_attached_images());
1250 }
1251
1252 #[test]
1253 fn can_handle_attached_images_false_when_preprocessor_key_does_not_resolve() {
1254 let cfg = cfg_with("deepseek-v4-flash", Some("NoSuchProvider"));
1258 assert!(!cfg.can_handle_attached_images());
1259 }
1260
1261 #[test]
1262 fn can_handle_attached_images_false_when_preprocessor_key_is_empty_string() {
1263 let cfg = cfg_with("deepseek-v4-flash", Some(""));
1264 assert!(!cfg.can_handle_attached_images());
1265 }
1266
1267 #[test]
1268 fn can_handle_attached_images_true_when_preprocessor_resolves() {
1269 let mut cfg = cfg_with("deepseek-v4-flash", Some("vl-helper"));
1271 cfg.providers.insert(
1272 "vl-helper".into(),
1273 crate::config::provider::ProviderConfig {
1274 provider_type: "openai".into(),
1275 api_key: Some("sk-vl".into()),
1276 model: "Qwen/Qwen3-VL-32B-Instruct".into(),
1277 base_url: Some("http://127.0.0.1/".into()),
1278 system_prompt: None,
1279 user_agent: None,
1280 context_window: 8000,
1281 max_tokens: None,
1282 thinking_type: None,
1283 thinking_keep: None,
1284 reasoning_history: None,
1285 thinking_enabled: None,
1286 thinking_budget: None,
1287 skip_tls_verify: false,
1288 ephemeral: false,
1289 },
1290 );
1291 assert!(cfg.can_handle_attached_images());
1292 }
1293}
1294
1295#[cfg(test)]
1296mod reflection_config_tests {
1297 use super::*;
1298
1299 #[test]
1300 fn legacy_reflection_cadence_field_is_silently_ignored() {
1301 let toml_text = r#"
1308default_provider = "claude"
1309reflection_cadence = 7
1310[providers]
1311"#;
1312 let _cfg: Config = toml::from_str(toml_text).expect("legacy field ignored");
1313 }
1314
1315 #[test]
1316 fn notifications_default_when_missing_from_toml() {
1317 let toml_text = r#"
1318default_provider = "claude"
1319[providers]
1320"#;
1321 let cfg: Config = toml::from_str(toml_text).expect("parses config");
1322 assert!(cfg.notifications.enabled);
1323 assert_eq!(cfg.notifications.min_duration_secs, 8);
1324 assert!(cfg.notifications.terminal);
1325 assert!(cfg.notifications.system);
1326 assert!(cfg.notifications.bell);
1327 assert!(cfg.notifications.background_only);
1328 }
1329}
1330
1331#[cfg(test)]
1332mod telemetry_section_tests {
1333 use super::*;
1334
1335 #[test]
1336 fn missing_telemetry_section_uses_defaults() {
1337 let s = r#"
1338default_provider = "claude"
1339[providers]
1340"#;
1341 let c: Config = toml::from_str(s).unwrap();
1342 assert!(c.telemetry.enabled.is_none());
1343 }
1344
1345 #[test]
1346 fn telemetry_section_roundtrip() {
1347 let s = r#"
1348default_provider = "claude"
1349[providers]
1350[telemetry]
1351enabled = false
1352endpoint = "https://test.example/v1"
1353"#;
1354 let c: Config = toml::from_str(s).unwrap();
1355 assert_eq!(c.telemetry.enabled, Some(false));
1356 assert_eq!(
1357 c.telemetry.endpoint.as_deref(),
1358 Some("https://test.example/v1")
1359 );
1360 }
1361
1362 #[test]
1363 fn saved_config_preserves_explicit_telemetry_section() {
1364 let tmp = std::env::temp_dir().join(format!(
1365 "atomcode_cfg_telemetry_rt_{}.toml",
1366 std::process::id()
1367 ));
1368 let cfg = Config {
1369 default_provider: "p".to_string(),
1370 telemetry: TelemetryConfig {
1371 enabled: Some(false),
1372 endpoint: Some("https://telemetry.example/v1".to_string()),
1373 },
1374 ..Config::default()
1375 };
1376
1377 cfg.save(&tmp).unwrap();
1378 let text = std::fs::read_to_string(&tmp).unwrap();
1379 assert!(text.contains("[telemetry]"));
1380 assert!(text.contains("enabled = false"));
1381 assert!(text.contains("endpoint = \"https://telemetry.example/v1\""));
1382
1383 let reloaded = Config::load(&tmp).unwrap();
1384 assert_eq!(reloaded.telemetry.enabled, Some(false));
1385 assert_eq!(
1386 reloaded.telemetry.endpoint.as_deref(),
1387 Some("https://telemetry.example/v1")
1388 );
1389 let _ = std::fs::remove_file(&tmp);
1390 }
1391}