Skip to main content

atomcode_core/config/
mod.rs

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// DEFAULT_SYSTEM_PROMPT removed — single source of truth is now
16// config/prompt_sections.rs::UNIFIED_PROMPT (~500 tok).
17// Do NOT add prompt rules here. Edit prompt_sections.rs instead.
18
19/// Windows-specific rules appended to the system prompt.
20/// Only injected on Windows builds — macOS/Linux never see these.
21#[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
33/// macOS-specific rules (minimal — macOS is the primary dev platform).
34pub const MACOS_RULES: &str = "";
35
36/// Linux-specific rules.
37pub const LINUX_RULES: &str = "";
38
39/// Get platform-specific rules for the current OS.
40pub 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/// Sub-agent execution policy (enable + resilience knobs).
51/// Drives `agent::sub_agent::SubAgentTask::execute` and the
52/// `try_sub_agent_dispatch` config gate.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(default)]
55pub struct SubAgentConfig {
56    /// Master switch. `false` makes `try_sub_agent_dispatch` return None
57    /// immediately and the parent agent falls back to serial execution.
58    pub enabled: bool,
59    /// Initial per-task turn budget. Adaptive logic may extend up to
60    /// `max_turns`. See `ResilienceConfig::initial_turns`.
61    pub initial_turns: usize,
62    /// Hard cap on per-task turns regardless of progress signals.
63    pub max_turns: usize,
64    /// Max parallel sub-agents per pool batch.
65    pub max_concurrent: usize,
66    /// Wall-time timeout for a single sub-agent (seconds).
67    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    /// Default working directory. Saved on /cd, restored on startup.
86    pub default_workdir: Option<String>,
87    pub providers: HashMap<String, ProviderConfig>,
88    /// Per-turn datalog settings. Missing from older configs → defaults to
89    /// enabled=true, dir="$ATOMCODE_HOME/datalog" (project slug appended underneath).
90    ///
91    /// `skip_serializing` intentionally suppresses serde's automatic output;
92    /// `save()` writes this section manually with explanatory comments and
93    /// the resolved default `dir` value so users can see and edit it without
94    /// having to know the field names in advance.
95    #[serde(default, skip_serializing)]
96    pub datalog: DatalogConfig,
97    /// Task-finished notifications. Saved manually with help comments so users
98    /// can discover the terminal-first strategy and platform fallbacks.
99    #[serde(default, skip_serializing)]
100    pub notifications: NotificationConfig,
101    /// When true (default), atomcode polls for new releases every hour
102    /// while running and stages any newer version it finds. The stage is
103    /// applied on the next startup (see `self_update::apply_pending_upgrade`).
104    /// Set to `false` to disable auto-staging entirely; `/upgrade` still
105    /// works manually. Missing from older configs → defaults to `true`.
106    #[serde(default = "default_true")]
107    pub auto_update: bool,
108    /// Telemetry configuration. Missing from older configs → defaults to
109    /// enabled=None (consent-pending), endpoint=None (use the built-in default).
110    /// Uses `#[serde(default)]` because `TelemetryConfig` has its own `Default`
111    /// impl that matches the no-section-present semantics.
112    #[serde(default, skip_serializing)]
113    pub telemetry: TelemetryConfig,
114    /// LSP integration configuration.
115    #[serde(default)]
116    pub lsp: LspConfig,
117    /// Automatically commit edited files after each agent turn completes.
118    /// Only applies when working inside a git repository.
119    #[serde(default)]
120    pub auto_commit: bool,
121    /// Sub-agent execution policy. Missing from older configs → defaults to
122    /// enabled=true, initial_turns=4, max_turns=12, max_concurrent=3, timeout_secs=300.
123    #[serde(default)]
124    pub subagent: SubAgentConfig,
125    /// Provider key (matches a key in `Config.providers`) of a vision-language
126    /// model used to preprocess images before forwarding to a non-vision main
127    /// provider. When `None` or empty, image preprocessing is disabled — pasted
128    /// images either go directly to a vision-capable main provider, or get
129    /// degraded to `"[image attached]"` placeholder by the existing path.
130    ///
131    /// Example value: `"AtomGit-Qwen-Qwen3-VL-32B-Instruct"`.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub vision_preprocessor_provider: Option<String>,
134    /// UI / prompt language override. `None` means auto-detect from the
135    /// environment (LC_ALL / LANG / system default). Persisted as the
136    /// short key defined by `Locale`'s serde rename (e.g. `"zh_CN"`).
137    #[serde(default)]
138    pub language: Option<crate::locale::Locale>,
139    /// UI rendering preferences. Currently exposes the light/dark theme
140    /// switch driving the TUIX colour palette (markdown headings, code
141    /// block syntax highlight, session-name pill). Missing from older
142    /// configs → defaults to `dark` (legacy behaviour).
143    #[serde(default)]
144    pub ui: UiConfig,
145    /// Plugin marketplace bootstrap + auto-update behaviour. Missing
146    /// from older configs → both knobs default to `true`, matching the
147    /// "ship batteries included" UX: first-startup auto-installs the
148    /// default `atomcode-skills` marketplace, and an in-place version
149    /// upgrade silently `git pull`s every installed marketplace so
150    /// skills track the binary.
151    #[serde(default)]
152    pub plugin: PluginConfig,
153}
154
155/// Plugin / marketplace bootstrap configuration. Persisted as the
156/// `[plugin]` table.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct PluginConfig {
159    /// First-startup behaviour: when true (default), atomcode runs a
160    /// one-time `git clone` of the default `atomcode-skills`
161    /// marketplace into `$ATOMCODE_HOME/plugins/marketplaces/`. A marker
162    /// file (`~/.atomcode/.plugin_bootstrap_v1`) is touched after the
163    /// first attempt — set or unset — so the install fires exactly
164    /// once per user. A subsequent `/plugin uninstall` is respected;
165    /// the marker stays in place and the directory is NOT recreated.
166    /// To force a re-bootstrap, delete the marker.
167    #[serde(default = "default_true")]
168    pub auto_install_default_skills: bool,
169    /// Self-update follow-up: when true (default), after
170    /// `apply_pending_upgrade` actually applies a new atomcode binary
171    /// (`ATOMCODE_UPGRADED_FROM` env var set on re-exec), the new
172    /// session runs `git pull --ff-only` on every installed marketplace
173    /// so skills stay in lockstep with the binary. Failures (no
174    /// network, fast-forward conflict from local edits) are warned
175    /// and ignored — never block startup.
176    #[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/// UI section of the config — currently just the theme switch driving
190/// the TUIX colour palette. Persisted as a top-level `[ui]` table.
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
192pub struct UiConfig {
193    /// Colour palette to use for markdown / code-block / chrome
194    /// rendering. `dark` keeps the legacy palette (designed for dark
195    /// terminals); `light` swaps in darker saturated variants that hit
196    /// WCAG AA contrast on `#FFFFFF`. Defaults to `dark` so existing
197    /// configs see no behaviour change.
198    #[serde(default)]
199    pub theme: UiTheme,
200}
201
202/// UI colour palette selector.
203///
204/// - `Auto` (default): query the terminal's background colour via
205///   OSC 11 at startup and pick light or dark accordingly. Terminals
206///   that don't respond (macOS Terminal.app, Windows conhost) fall
207///   back to dark.
208/// - `Dark` / `Light`: skip detection, use the explicit palette.
209#[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    /// True iff attaching an image to the active turn will reach a model
220    /// that can process it — either the active provider accepts images
221    /// directly, or `vision_preprocessor_provider` points at a real entry
222    /// in `providers` that will OCR them before forwarding. Used by the
223    /// TUIX Ctrl+V paste gate to decide whether to accept the image or
224    /// reject with the "switch to a vision-capable model" hint.
225    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/// Controls the per-turn markdown datalog writer.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct DatalogConfig {
266    /// When false, `DatalogWriter` becomes a no-op and no files are created.
267    #[serde(default = "default_true")]
268    pub enabled: bool,
269    /// Root directory under which datalog files are written. The per-project
270    /// slug (`<basename>-<hash8>`) is always appended underneath, so two
271    /// projects never collide. Accepted forms:
272    /// - `None` (or omitted) → `~/.atomcode/datalog/` (default)
273    /// - Absolute path        → used as-is, not affected by /cd
274    /// - `~/...`              → expanded relative to home, not affected by /cd
275    /// - Relative path        → resolved against working_dir, follows /cd
276    #[serde(default)]
277    pub dir: Option<String>,
278}
279
280/// Controls long-running task completion notifications.
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct NotificationConfig {
283    /// Master switch for all completion notifications.
284    #[serde(default = "default_true")]
285    pub enabled: bool,
286    /// Only notify when the turn runs for at least this many seconds.
287    #[serde(default = "default_notification_min_duration_secs")]
288    pub min_duration_secs: u64,
289    /// Try terminal-native notification escape sequences first.
290    #[serde(default = "default_true")]
291    pub terminal: bool,
292    /// Fall back to OS-native notifications when terminal protocols are unavailable.
293    #[serde(default = "default_true")]
294    pub system: bool,
295    /// Emit BEL so terminals can play a sound or request attention.
296    #[serde(default = "default_true")]
297    pub bell: bool,
298    /// Best-effort background-only behavior where the terminal protocol supports it.
299    #[serde(default = "default_true")]
300    pub background_only: bool,
301}
302
303/// Controls LSP (Language Server Protocol) integration.
304///
305/// Off by default. 5-7 atomgr datalog (build 942b615): the only `diagnostics`
306/// call in a 99-turn session took 33.6s (cold rust-analyzer spin-up) and
307/// returned "No diagnostics found", contributing nothing to task completion.
308/// LSP is also platform/toolchain-specific (rust-analyzer, gopls, etc.) and
309/// pulling those binaries unprompted violates the project's
310/// tech-stack-neutrality rule. Users who want it can flip `enabled = true`
311/// in their config.
312#[derive(Debug, Clone, Serialize, Deserialize)]
313pub struct LspConfig {
314    /// Master switch for LSP diagnostics. Off by default — opt-in only.
315    #[serde(default)]
316    pub enabled: bool,
317    /// Automatically detect and start language servers from the built-in
318    /// registry. Off by default — even when `enabled = true`, users must
319    /// explicitly opt in to auto-detect (or list specific `servers`) to
320    /// avoid surprising the user with binary spawns.
321    #[serde(default)]
322    pub auto_detect: bool,
323    /// Custom server configurations keyed by file extension.
324    #[serde(default)]
325    pub servers: std::collections::HashMap<String, crate::lsp::registry::LspServerConfig>,
326    /// Time in milliseconds to wait after file sync before reading diagnostics.
327    /// LSP servers need time to process notifications and publish diagnostics.
328    /// Larger files or slower servers may need higher values.
329    #[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
348/// One-shot migration for users who had atomcode installed before the
349/// "LSP off by default" flip (commit 5b07e2a, 2026-05-07). The setup
350/// wizard at install time used `LspConfig::default()` which **at that
351/// time** was `enabled=true, auto_detect=true, delay=150, servers={}`,
352/// and `Config::save()` serialized those literals into
353/// `~/.atomcode/config.toml`. Subsequent loads see explicit `enabled=true`
354/// and ignore the new in-memory default — old installs keep spawning
355/// rust-analyzer / gopls and surface init failures the user never asked
356/// for.
357///
358/// Heuristic: if the on-disk LspConfig matches the OLD wizard-written
359/// shape **byte-for-byte** (every field equals its old default), reset
360/// to the new default. Any deviation (custom server, non-default delay,
361/// auto_detect=false) means the user customised it intentionally —
362/// leave alone.
363///
364/// False-positive risk: a user who manually wrote `enabled=true +
365/// auto_detect=true + delay=150 + servers={}` exactly gets silently
366/// reset. The shape is identical to the auto-written default, so
367/// distinguishing intent is impossible without a schema-version field.
368/// Probability is low; failure mode is mild (re-enable explicitly).
369fn 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            // Pre-fill the default root so it round-trips into config.toml on
391            // first save — users see exactly where logs go without having to
392            // discover that "unset == ~/.atomcode/datalog". Resolver still
393            // treats this string the same as `None` (project slug appended).
394            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
412/// Serialize the `[datalog]` section with help comments so users editing
413/// config.toml by hand can discover the options without reading the source.
414/// `enabled` and `dir` are always emitted as real values — the default `dir`
415/// (`~/.atomcode/datalog`) is shown explicitly so users see exactly where
416/// logs go without having to discover that "unset == default".
417fn 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
474/// Render a documentation comment about the layered instruction file system.
475/// Always emitted (even on first save) so users discover the feature.
476fn 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    /// Context window of the currently-selected default provider.
539    /// Falls back to 128_000 when the default_provider is missing or
540    /// has no provider entry — matches pre-existing behavior at the
541    /// ~5 sites that previously open-coded this lookup.
542    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        // Filter out ephemeral providers (e.g. OAuth /login) — they live in memory only.
563        let mut persistent = self.clone();
564        persistent.providers.retain(|_, v| !v.ephemeral);
565        // If default_provider is ephemeral, don't change the saved default
566        if !self
567            .providers
568            .get(&self.default_provider)
569            .map(|p| !p.ephemeral)
570            .unwrap_or(true)
571        {
572            // Restore original default from disk if possible
573            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        // Defence against an accidentally-empty `default_provider` (e.g.
589        // an older /logout path wrote "" back to config.toml) OR a
590        // `default_provider` that points to a provider section the user
591        // has since deleted from config.toml.  Rather than failing at
592        // startup, fall back to a lexicographically-first provider so
593        // the TUI still boots and the user can self-correct via /provider.
594        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                // default_provider / override pointed to a key that no
615                // longer exists — fall back to the first available.
616                let fallback_name = fallback()?;
617                // SAFETY: fallback() just returned Ok from self.providers,
618                // so the key must exist.
619                Ok(self.providers.get(fallback_name).unwrap())
620            }
621        }
622    }
623
624    /// Resolve the atomcode config dir. Pure function for testability —
625    /// `config_dir()` is a thin wrapper that injects real env + real home.
626    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    /// LSP must default to disabled. 5-7 atomgr datalog (build 942b615):
652    /// the only `diagnostics` call in 99 turns took 33.6s for a "No
653    /// diagnostics found" reply. Spinning up rust-analyzer / gopls /
654    /// pyright unprompted also conflicts with the framework's
655    /// tech-stack-neutrality stance. Users must opt in explicitly.
656    #[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    /// Migration: on-disk config that looks like it was auto-written by
667    /// the OLD setup wizard (enabled=true + auto_detect=true + delay=150
668    /// + no custom servers) must be silently reset to disabled. Without
669    /// this, users installed before commit 5b07e2a keep spawning
670    /// rust-analyzer / gopls every startup despite the new default.
671    #[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    /// User who deliberately customised LSP (e.g. added a custom server
685    /// or tuned the settle delay) must NOT be reset. Migration only fires
686    /// for byte-perfect old-default shape.
687    #[test]
688    fn migrate_keeps_user_customised_lsp_intact() {
689        // Case 1: custom server registered.
690        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        // Case 2: tuned settle delay.
709        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        // Case 3: auto_detect=false but enabled=true (explicit narrow
719        // setup with `servers` listed) — already deviates, keep.
720        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    /// Already-disabled config: migration must be a no-op (don't flip
731    /// disabled → re-disabled, but more importantly don't trigger any
732    /// surprise side effects).
733    #[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    /// Empty/missing `[lsp]` section in user TOML must produce the
761    /// disabled default — not silently flip back to enabled via a
762    /// stray `default = "default_true"` serde attribute.
763    #[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        // Belt-and-suspenders: even if some caller hands us a Config where
879        // `dir` somehow ended up None (older config file, manual deserialize),
880        // render still emits the default value rather than omitting the line.
881        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        // Guards against the /logout bug where default_provider got
987        // written back as "" — startup must still succeed by falling
988        // back to a lexicographically-first provider instead of
989        // failing with "Provider '' not found".
990        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        // Regression test for https://gitcode.com/atomgit_atomcode/atomcode/issues/353
1039        // User deletes a provider section from config.toml but leaves
1040        // default_provider pointing at it — startup must still succeed by
1041        // falling back to a lexicographically-first provider instead of
1042        // failing with "Provider 'xxx' not found".
1043        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        // Should fall back to "claude" (lexicographically first)
1059        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        // Same as above but via the --provider CLI override.
1065        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        // Should fall back to "claude" (lexicographically first)
1081        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        // default_provider points to a deleted section AND there are no
1087        // other providers — must error (nothing to fall back to).
1088        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        // Existing config.toml files (pre-feature) must parse cleanly with
1103        // `vision_preprocessor_provider` defaulting to None — feature is opt-in
1104        // and absence must not break load.
1105        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    /// Helper: minimal Config with one provider, configurable model name +
1197    /// optional preprocessor key. Used by the can_handle_attached_images tests.
1198    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        // Vision-capable main provider — preprocessor irrelevant.
1241        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        // The original gate's behaviour: refuse paste.
1248        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        // Configured but the key is missing from `providers`. Must NOT
1255        // accept the paste — the user would just hit `[图片识别失败]` on
1256        // every send. Better to surface the error at paste time.
1257        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        // Main is text-only but a preprocessor is configured + present.
1270        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        // Older configs in the wild still carry `reflection_cadence = 7`
1302        // (the field's value at the time the mechanism was removed).
1303        // toml + serde's default permissiveness means the unknown field
1304        // is dropped without erroring; this test pins that behaviour so
1305        // an accidental `#[serde(deny_unknown_fields)]` later doesn't
1306        // start rejecting users' on-disk configs.
1307        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}