Skip to main content

mermaid_cli/app/
config.rs

1use crate::constants::{DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE, LEGACY_DEFAULT_MAX_TOKENS};
2use crate::models::ReasoningLevel;
3use crate::runtime::{PolicyOverride, SafetyMode};
4use anyhow::{Context, Result};
5use directories::ProjectDirs;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// Main configuration structure
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Config {
13    /// Last used model (persisted between sessions)
14    #[serde(default)]
15    pub last_used_model: Option<String>,
16
17    /// Default model configuration
18    #[serde(default)]
19    pub default_model: ModelSettings,
20
21    /// Ollama configuration
22    #[serde(default)]
23    pub ollama: OllamaConfig,
24
25    /// Web tool (`web_search` / `web_fetch`) backend selection.
26    #[serde(default)]
27    pub web: WebConfig,
28
29    /// TUI appearance preferences (`[ui]` table).
30    #[serde(default)]
31    pub ui: UiConfig,
32
33    /// Non-interactive mode configuration
34    #[serde(default)]
35    pub non_interactive: NonInteractiveConfig,
36
37    /// MCP server configurations
38    #[serde(default)]
39    pub mcp_servers: HashMap<String, McpServerConfig>,
40
41    /// When unset or true, MCP tools are DEFERRED: instead of advertising
42    /// every server's tools on every request, the model gets one
43    /// `tool_search` tool that returns matching schemas and promotes them
44    /// to direct advertisement. Bounds the always-on tool surface.
45    /// `Option` so the derived `Config::default()` and the serde default
46    /// agree (both `None` = on) and saved configs don't freeze the value.
47    /// Per-server override: `defer = false` on the server entry. Read via
48    /// [`Config::mcp_deferral_enabled`].
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub mcp_defer_tools: Option<bool>,
51
52    /// User overrides + custom OpenAI-compatible providers. Keys are
53    /// provider names; matching a built-in registry entry overrides its
54    /// defaults, anything else defines a fully custom provider.
55    /// Example:
56    /// ```toml
57    /// [providers.groq]
58    /// api_key_env = "MY_GROQ_KEY"  # override default GROQ_API_KEY
59    ///
60    /// [providers.my-vllm]
61    /// base_url = "http://192.168.1.42:8000/v1"
62    /// api_key_env = "VLLM_KEY"
63    /// compat = "openai-effort"
64    /// ```
65    #[serde(default)]
66    pub providers: HashMap<String, UserProviderConfig>,
67
68    /// Per-model reasoning preferences keyed by full model ID
69    /// (`provider/name`). Set when the user runs `/reasoning <level>` or
70    /// Alt+T cycles while using a specific model — the new value sticks
71    /// for that model until changed. Falls back to
72    /// `default_model.reasoning` when no entry exists.
73    /// Example:
74    /// ```toml
75    /// [reasoning_per_model]
76    /// "<provider>/<model>" = "high"
77    /// "ollama/qwen3-coder:30b" = "low"
78    /// ```
79    #[serde(default)]
80    pub reasoning_per_model: HashMap<String, ReasoningLevel>,
81
82    /// Per-model Ollama `num_ctx` override set via `/context <n>`/`max`. Beats
83    /// auto-fit; cleared by `/context auto`. Keyed by model id.
84    ///
85    /// Example:
86    /// ```toml
87    /// [ollama_num_ctx_per_model]
88    /// "ollama/ornith:9b" = 131072
89    /// ```
90    #[serde(default)]
91    pub ollama_num_ctx_per_model: HashMap<String, u32>,
92
93    /// Named model-id aliases that agents/plugins can request without
94    /// hardcoding a concrete provider model. Values are full model IDs.
95    /// (Distinct from `[profiles.<name>]`, which are whole-config overlays
96    /// selected with `--profile`.) Example:
97    /// ```toml
98    /// [model_aliases]
99    /// fast = "ollama/qwen3-coder:14b"
100    /// large-context = "openai/<model>"
101    /// tool-strong = "anthropic/<model>"
102    /// vision = "gemini/gemini-2.5-pro"
103    /// cheap = "groq/llama-3.3-70b-versatile"
104    /// ```
105    #[serde(default)]
106    pub model_aliases: HashMap<String, String>,
107
108    /// Runtime safety policy. Defaults to `Ask` so mutations / shell /
109    /// network actions require approval out of the box; users opt into
110    /// `Auto` (LLM-vetted) or `FullAccess` deliberately.
111    #[serde(default)]
112    pub safety: SafetyConfig,
113
114    /// Durable semantic memory settings.
115    #[serde(default)]
116    pub memory: MemoryConfig,
117
118    /// `mermaidd` background-daemon settings (task scheduler).
119    #[serde(default)]
120    pub daemon: DaemonConfig,
121
122    /// Context-compaction settings.
123    #[serde(default)]
124    pub compaction: CompactionConfig,
125
126    /// Computer-use (desktop control) preferences.
127    #[serde(default)]
128    pub computer_use: ComputerUseConfig,
129
130    /// Foreground `execute_command` behavior.
131    #[serde(default)]
132    pub exec: ExecConfig,
133
134    /// Plan-mode behavior (`/plan`, `/safety plan`, Shift+Tab).
135    #[serde(default)]
136    pub plan: PlanConfig,
137
138    /// Subagent (`agent` tool) settings: drive timeout and user-defined
139    /// agent types.
140    #[serde(default)]
141    pub agents: AgentsConfig,
142
143    /// Runtime-only prompt customizations supplied by CLI flags. These are
144    /// deliberately skipped when saving config so one-off agent personas do
145    /// not pollute the user's persistent Mermaid settings.
146    #[serde(skip)]
147    pub prompt: PromptConfig,
148
149    /// The `--profile <name>` overlay active this session, for `doctor` and
150    /// startup notices. Runtime-only (`skip`): never persisted, and
151    /// `[profiles.*]` itself is excised before deserialization ever sees it.
152    #[serde(skip)]
153    pub active_profile: Option<String>,
154}
155
156impl Config {
157    /// Effective value of [`Config::mcp_defer_tools`]: unset means ON.
158    pub fn mcp_deferral_enabled(&self) -> bool {
159        self.mcp_defer_tools.unwrap_or(true)
160    }
161}
162
163/// Foreground `execute_command` behavior (`[exec]` table).
164#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct ExecConfig {
166    /// Run foreground commands on a pseudo-terminal (openpty on Unix,
167    /// ConPTY on Windows). On a PTY, `tty`/`isatty` report a terminal,
168    /// spinner-heavy tools emit sane progress, and on Unix `/dev/tty`
169    /// resolves to the CAPTURED pty instead of scribbling over the TUI.
170    /// `Option` so the
171    /// derived default and the serde default agree (both `None` = on) and
172    /// saved configs don't freeze the value. `pty = false` restores pipes.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub pty: Option<bool>,
175}
176
177impl ExecConfig {
178    /// Effective value of [`ExecConfig::pty`]: unset means ON.
179    pub fn pty_enabled(&self) -> bool {
180        self.pty.unwrap_or(true)
181    }
182}
183
184/// TUI appearance preferences.
185#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct UiConfig {
187    /// Color theme the TUI renders with. Switched live via `/theme`.
188    #[serde(default)]
189    pub theme: ThemeChoice,
190}
191
192/// Which built-in color theme the TUI renders with. A typed enum (not a
193/// free string) so a typo in config.toml is a clear deserialize error and
194/// the reducer's match stays exhaustive when a theme is added.
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum ThemeChoice {
198    #[default]
199    Dark,
200    Light,
201}
202
203impl ThemeChoice {
204    /// The lowercase config-file spelling (`/theme` echo + persistence).
205    pub fn as_str(self) -> &'static str {
206        match self {
207            ThemeChoice::Dark => "dark",
208            ThemeChoice::Light => "light",
209        }
210    }
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct PromptConfig {
215    pub system_prompt: Option<String>,
216    pub append_system_prompt: Vec<String>,
217}
218
219impl PromptConfig {
220    pub fn render_system_prompt(&self, default_prompt: &str) -> String {
221        self.append_extras(self.base_prompt(default_prompt))
222    }
223
224    /// The base prompt before any `append_system_prompt` extras: the user's
225    /// override when set, else `default_prompt`.
226    ///
227    /// Split out so callers that REWRITE the base (plan mode splices whole
228    /// sections out of it) can do so before the extras are appended. Rewriting
229    /// the rendered string instead let a section splice run past the end of
230    /// the base and delete the user's appended instructions.
231    pub fn base_prompt<'a>(&'a self, default_prompt: &'a str) -> &'a str {
232        self.system_prompt.as_deref().unwrap_or(default_prompt)
233    }
234
235    /// Append the configured extras to an already-chosen base.
236    pub fn append_extras(&self, base: &str) -> String {
237        let mut rendered = base.trim_end().to_string();
238
239        for extra in &self.append_system_prompt {
240            let extra = extra.trim();
241            if extra.is_empty() {
242                continue;
243            }
244            if !rendered.is_empty() {
245                rendered.push_str("\n\n");
246            }
247            rendered.push_str(extra);
248        }
249
250        rendered
251    }
252
253    pub fn is_customized(&self) -> bool {
254        self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
255    }
256}
257
258/// Whether model-driven actions may reach the network. `Deny` removes web
259/// capabilities and engages the shell-command network kill-switch where the
260/// OS sandbox supports it. Default `Allow` preserves explicit network use.
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum NetworkPolicy {
264    #[default]
265    Allow,
266    Deny,
267}
268
269/// Where model-driven shell commands may write. `Project` engages Linux
270/// Landlock write-confinement (`--confine-fs`): writes are allowed only beneath
271/// the project directory, the system temp directory, and `/dev`; reads and
272/// execution stay unrestricted. Best-effort (no-op on kernels without Landlock
273/// and on other platforms). Default `Unrestricted` preserves today's behavior.
274#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum FilesystemPolicy {
277    #[default]
278    Unrestricted,
279    Project,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(default)]
284pub struct SafetyConfig {
285    pub mode: SafetyMode,
286    pub checkpoint_on_mutation: bool,
287    /// Network access policy for every model-driven network action. `Deny`
288    /// also installs the shell-command OS kill-switch where supported.
289    #[serde(default)]
290    pub network: NetworkPolicy,
291    /// Filesystem write policy for shell commands. `Project` confines writes
292    /// to the project/temp/`/dev` directories on Linux. See
293    /// [`FilesystemPolicy`].
294    #[serde(default)]
295    pub filesystem: FilesystemPolicy,
296    #[serde(default)]
297    pub overrides: Vec<PolicyOverride>,
298    /// Enforcement floor for write-shaped MCP tools (no server-advertised
299    /// `readOnlyHint`): `allow` | `auto` | `ask` | `deny`. Safety mode alone
300    /// never authorizes an external side effect — with the default `auto`,
301    /// even full_access routes MCP writes through the intent classifier
302    /// (aligned runs silently, off-task escalates). `allow` restores the old
303    /// unconditional-allow behavior.
304    #[serde(default)]
305    pub external_writes: crate::runtime::FloorLevel,
306    /// Enforcement floor for machine-scoped package operations (`npm -g`,
307    /// `cargo install`, `pip install`, `brew`/`apt`/`winget` installs) —
308    /// same levels and default as `external_writes`. They mutate the
309    /// MACHINE, not the project (outside checkpoint reach), so even
310    /// full_access vets them. Project-local installs (`npm install`,
311    /// `cargo add`) are untouched.
312    #[serde(default)]
313    pub system_installs: crate::runtime::FloorLevel,
314    /// Model id the `Auto`-mode safety classifier uses to vet borderline
315    /// actions. `None` ⇒ vet with the session's active model. Set this to
316    /// point the vet at a cheaper/faster model than the one driving the work.
317    #[serde(default)]
318    pub auto_classifier_model: Option<String>,
319    /// Headless escape hatch: when true, non-replayable tools (web/mcp/
320    /// subagent/computer_use) are allowed to PROCEED on an `Ask` decision in a
321    /// headless run (no approval UI) instead of being blocked. Default `false`
322    /// — `mermaid run` in `ask` mode otherwise refuses these. Set via
323    /// `--allow-untrusted-tools` or config for CI that needs them.
324    #[serde(default)]
325    pub allow_untrusted_headless_tools: bool,
326    /// Explicit user/session opt-in allowing public web reads to proceed in
327    /// `read_only` mode. Without it, each request requires one-shot approval;
328    /// project configuration is not permitted to enable this capability.
329    #[serde(default)]
330    pub allow_readonly_web: bool,
331}
332
333impl Default for SafetyConfig {
334    fn default() -> Self {
335        Self {
336            // Safe-by-default: the first run prompts for approval on
337            // mutations / shell / network rather than silently auto-allowing
338            // everything. FullAccess remains available via config.
339            mode: SafetyMode::Ask,
340            checkpoint_on_mutation: true,
341            network: NetworkPolicy::default(),
342            filesystem: FilesystemPolicy::default(),
343            overrides: Vec::new(),
344            external_writes: crate::runtime::FloorLevel::default(),
345            system_installs: crate::runtime::FloorLevel::default(),
346            auto_classifier_model: None,
347            allow_untrusted_headless_tools: false,
348            allow_readonly_web: false,
349        }
350    }
351}
352
353/// `mermaidd` background-daemon settings.
354#[derive(Debug, Clone, Serialize, Deserialize)]
355#[serde(default)]
356pub struct DaemonConfig {
357    /// How many daemon-queued tasks may execute concurrently. Each task is a
358    /// full agent run holding a model context, so the default is strictly
359    /// serial — honest for a single local GPU. Raise it when the daemon's
360    /// tasks target cloud providers (or a box with VRAM to spare).
361    pub max_concurrent_tasks: usize,
362    /// Wall-clock budget per daemon task, in minutes. `None` keeps the
363    /// headless runner's built-in 20-minute deadline; set it to give queued
364    /// batch work a shorter (or longer) leash. A task over budget is failed
365    /// with a timeout report.
366    pub task_timeout_minutes: Option<u64>,
367    /// Days to retain finished runtime rows (terminal tasks, stale sessions,
368    /// finished tool runs, old compactions, …) before the startup GC prunes
369    /// them. Active data is never pruned regardless of this value.
370    pub retention_days: i64,
371    /// Days to retain `outcomes` reward rows — the self-improving-loop training
372    /// corpus. Deliberately longer than `retention_days` so a large training
373    /// history survives the shorter task/session window; each outcome's
374    /// denormalized context keeps it usable after its task row is pruned.
375    pub outcomes_retention_days: i64,
376    /// Days to retain unlocked per-session scratch directories before the
377    /// daemon's startup sweep reaps them. Sessions whose owning process is
378    /// still alive are never reaped regardless of age. Interactive sessions
379    /// sweep with the built-in default; this knob only tunes mermaidd.
380    pub scratchpad_retention_days: i64,
381}
382
383impl Default for DaemonConfig {
384    fn default() -> Self {
385        Self {
386            max_concurrent_tasks: 1,
387            task_timeout_minutes: None,
388            retention_days: 30,
389            outcomes_retention_days: 180,
390            scratchpad_retention_days: crate::session::scratchpad::RETENTION_DAYS as i64,
391        }
392    }
393}
394
395/// What approval does once granted, when the user has pinned it in config.
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum PlanPostApprove {
399    /// Approval immediately auto-submits "Implement the plan."
400    Start,
401    /// Approval finalizes the plan and returns to the idle prompt.
402    Wait,
403}
404
405/// Permission level for one plan-mode category. Mirrors the safety-mode
406/// ladder so the picker reads familiarly: `allow` runs, `auto` is vetted by
407/// the Auto classifier, `ask` raises the approval modal, `deny` blocks with
408/// the plan-flavored teaching denial.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum PlanPermLevel {
412    Allow,
413    Auto,
414    Ask,
415    Deny,
416}
417
418impl PlanPermLevel {
419    pub fn as_str(self) -> &'static str {
420        match self {
421            PlanPermLevel::Allow => "allow",
422            PlanPermLevel::Auto => "auto",
423            PlanPermLevel::Ask => "ask",
424            PlanPermLevel::Deny => "deny",
425        }
426    }
427}
428
429/// Per-category permission profile applied while a plan is being drafted.
430/// The read-only floor stays the base; these levels decide how far each
431/// carve-out opens. The plan file itself is not a category — being able to
432/// author the plan IS plan mode.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(default)]
435pub struct PlanPermissions {
436    /// Known-safe build/test commands (`is_plan_safe_build_command`).
437    pub builds: PlanPermLevel,
438    /// `web_search` / `web_fetch` (GET-shaped reads).
439    pub web: PlanPermLevel,
440    /// Durable memory writes.
441    pub memory: PlanPermLevel,
442    /// The checklist writers (`task_create` / `task_update`). Only `allow`
443    /// unblocks them — `auto`/`ask` collapse to `deny` (they are ungated
444    /// tools with no approval path, and the checklist is seeded from the
445    /// approved plan anyway).
446    pub tasks: PlanPermLevel,
447}
448
449impl Default for PlanPermissions {
450    fn default() -> Self {
451        Self {
452            builds: PlanPermLevel::Allow,
453            // Planning inherits the ReadOnly web posture: every externally
454            // observable URL/query needs one-shot approval unless the user
455            // explicitly opens this category in `/plan config`.
456            web: PlanPermLevel::Ask,
457            memory: PlanPermLevel::Allow,
458            tasks: PlanPermLevel::Deny,
459        }
460    }
461}
462
463impl PlanPermissions {
464    /// The top-level picker presets; `None` when the current values match
465    /// none of them (the picker shows "custom").
466    pub fn preset_name(&self) -> Option<&'static str> {
467        if *self == Self::default() {
468            Some("default")
469        } else if *self == Self::strict() {
470            Some("strict")
471        } else if *self == Self::open() {
472            Some("open")
473        } else {
474            None
475        }
476    }
477
478    /// Everything denied: pure read-only exploration plus the plan file.
479    pub fn strict() -> Self {
480        Self {
481            builds: PlanPermLevel::Deny,
482            web: PlanPermLevel::Deny,
483            memory: PlanPermLevel::Deny,
484            tasks: PlanPermLevel::Deny,
485        }
486    }
487
488    /// Everything allowed (the working tree stays read-only regardless).
489    pub fn open() -> Self {
490        Self {
491            builds: PlanPermLevel::Allow,
492            web: PlanPermLevel::Allow,
493            memory: PlanPermLevel::Allow,
494            tasks: PlanPermLevel::Allow,
495        }
496    }
497}
498
499/// Plan-mode settings (`[plan]`).
500#[derive(Debug, Clone, Default, Serialize, Deserialize)]
501#[serde(default)]
502pub struct PlanConfig {
503    /// When true, `exit_plan_mode` skips the approval dialog entirely: the
504    /// plan is approved the moment the model presents it. Default false —
505    /// the dialog is the point of plan mode.
506    pub auto_approve: bool,
507    /// Pin what approval does. Unset (default) the dialog offers both
508    /// "Approve and start" and "Approve and wait" every time; set, it
509    /// collapses to a single Approve option with this behavior. Option +
510    /// skip_serializing keeps "unset" meaningful in saved configs (the
511    /// freeze-defaults rule).
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub post_approve: Option<PlanPostApprove>,
514    /// Per-category permission profile while planning. Edited live in the
515    /// `/plan config` picker; the reducer threads the LIVE values onto each
516    /// tool dispatch (the startup `Config` snapshot in `ExecContext` would
517    /// go stale).
518    pub permissions: PlanPermissions,
519    /// Plan-phase model override: entering plan mode swaps the session to
520    /// this model and leaving restores the previous one — plan on a frontier
521    /// model, execute locally (or invert for privacy). Unset = plan with
522    /// whatever is running.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub model: Option<String>,
525    /// Plan-phase reasoning override, same swap/restore contract as `model`.
526    #[serde(skip_serializing_if = "Option::is_none")]
527    pub reasoning: Option<crate::models::ReasoningLevel>,
528}
529
530/// Durable semantic memory settings (v0.10.0).
531#[derive(Debug, Clone, Serialize, Deserialize)]
532#[serde(default)]
533pub struct MemoryConfig {
534    /// Master switch for agent memory (the tool, the always-loaded index, and
535    /// the slash commands). On by default.
536    pub enabled: bool,
537    /// Byte cap on the always-loaded memory index before it's truncated.
538    pub index_cap_bytes: usize,
539}
540
541impl Default for MemoryConfig {
542    fn default() -> Self {
543        Self {
544            enabled: true,
545            index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
546        }
547    }
548}
549
550/// Context-compaction settings.
551///
552/// Every field maps onto a [`crate::domain::CompactionPolicy`] knob that was
553/// previously a hard-coded constant. Values are sanitized on the way out (see
554/// [`CompactionConfig::policy`]) rather than validated on the way in: a bad
555/// number should degrade to the nearest sane one, not refuse to start the app.
556#[derive(Debug, Clone, Serialize, Deserialize)]
557#[serde(default)]
558pub struct CompactionConfig {
559    /// Cap on consecutive auto-compact-and-continue recoveries after a
560    /// context-window truncation, before the run stops and shows the manual
561    /// levers (`/context max`, `/context offload on`). The counter resets
562    /// whenever the run makes progress, so this bounds only no-progress
563    /// thrashing on a too-small window. `0` means uncapped.
564    ///
565    /// Example:
566    /// ```toml
567    /// [compaction]
568    /// max_truncation_recoveries = 0  # never give up on its own
569    /// ```
570    pub max_truncation_recoveries: u8,
571
572    /// Compact automatically when the context crosses the threshold below.
573    /// `false` leaves compaction entirely to `/compact` — the provider's own
574    /// context limit then becomes the only backstop.
575    pub auto_enabled: bool,
576
577    /// Window fill (percent) at which auto-compaction triggers. Clamped to
578    /// `1..=100`; a value of 100 effectively means "only when the response
579    /// reserve no longer fits".
580    pub auto_threshold_percent: u8,
581
582    /// How many trailing user turns survive compaction verbatim. Clamped to at
583    /// least 1 — a compaction that preserved no turn would hand the model a
584    /// summary with no live thread to continue.
585    pub tail_turns: usize,
586
587    /// Token ceiling on that preserved tail. When the last `tail_turns` exceed
588    /// it, older turns are dropped from the tail until it fits.
589    pub tail_token_budget: usize,
590
591    /// Per-message character cap applied to tool output inside the summarizer's
592    /// history excerpt (prose gets 4x this). Keeps one enormous tool result
593    /// from crowding out the rest of the conversation.
594    pub tool_output_max_chars: usize,
595
596    /// Ceiling on the checkpoint the summarizer may produce. Scaled DOWN
597    /// automatically for small context windows (see
598    /// `CompactionPolicy::summary_output_tokens`), so this is a cap and not a
599    /// demand.
600    pub summary_max_tokens: usize,
601
602    /// Ceiling on the summarizer's input (prompt scaffold plus history
603    /// excerpt). Also scaled down to fit a small window.
604    pub summarizer_input_token_budget: usize,
605
606    /// Floor and ceiling on the window room held back for the model's reply
607    /// when deciding whether the context counts as "full". Swapped values are
608    /// corrected rather than rejected.
609    pub min_response_reserve_tokens: usize,
610    pub max_response_reserve_tokens: usize,
611}
612
613impl Default for CompactionConfig {
614    fn default() -> Self {
615        let policy = crate::domain::CompactionPolicy::default();
616        Self {
617            max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
618            auto_enabled: policy.auto_enabled,
619            auto_threshold_percent: policy.auto_threshold_percent,
620            tail_turns: policy.tail_turns,
621            tail_token_budget: policy.tail_token_budget,
622            tool_output_max_chars: policy.tool_output_max_chars,
623            summary_max_tokens: policy.summary_max_tokens,
624            summarizer_input_token_budget: policy.summarizer_input_token_budget,
625            min_response_reserve_tokens: policy.min_response_reserve_tokens,
626            max_response_reserve_tokens: policy.max_response_reserve_tokens,
627        }
628    }
629}
630
631impl CompactionConfig {
632    /// The live policy, with every value clamped into a range compaction can
633    /// actually operate in.
634    ///
635    /// Sanitizing here rather than at load time means a hand-edited config can
636    /// never put the app in a state where compaction silently cannot run — the
637    /// failure mode that motivated it is a `min_response_reserve` above
638    /// `max_response_reserve`, which would make `response_reserve` return the
639    /// smaller *maximum* and quietly under-reserve on every turn.
640    pub fn policy(&self) -> crate::domain::CompactionPolicy {
641        let defaults = crate::domain::CompactionPolicy::default();
642        let min_reserve = self.min_response_reserve_tokens;
643        let max_reserve = self.max_response_reserve_tokens;
644        crate::domain::CompactionPolicy {
645            auto_enabled: self.auto_enabled,
646            auto_threshold_percent: self.auto_threshold_percent.clamp(1, 100),
647            tail_turns: self.tail_turns.max(1),
648            // A zero budget would drop the whole tail; fall back to the default
649            // rather than produce a checkpoint with nothing after it.
650            tail_token_budget: nonzero_or(self.tail_token_budget, defaults.tail_token_budget),
651            tool_output_max_chars: nonzero_or(
652                self.tool_output_max_chars,
653                defaults.tool_output_max_chars,
654            ),
655            summary_max_tokens: nonzero_or(self.summary_max_tokens, defaults.summary_max_tokens),
656            summarizer_input_token_budget: nonzero_or(
657                self.summarizer_input_token_budget,
658                defaults.summarizer_input_token_budget,
659            ),
660            // Order the pair rather than trusting it: swapped bounds are the
661            // easy hand-edit mistake, and silently inverting the reserve is
662            // worse than ignoring the user's intent about which is which.
663            min_response_reserve_tokens: min_reserve.min(max_reserve),
664            max_response_reserve_tokens: min_reserve.max(max_reserve),
665        }
666    }
667}
668
669/// `value` unless it is zero, in which case `fallback`.
670fn nonzero_or(value: usize, fallback: usize) -> usize {
671    if value == 0 { fallback } else { value }
672}
673
674/// Computer-use (desktop control) preferences.
675#[derive(Debug, Clone, Serialize, Deserialize)]
676#[serde(default)]
677pub struct ComputerUseConfig {
678    /// After a successful click / type_text / press_key, auto-capture the
679    /// focused window and attach it inline so the model can verify the result.
680    /// On by default (non-breaking); set false to cut the per-action capture
681    /// cost + image tokens when visual feedback isn't needed. The model can
682    /// still call `screenshot` explicitly.
683    pub auto_screenshot: bool,
684}
685
686impl Default for ComputerUseConfig {
687    fn default() -> Self {
688        Self {
689            auto_screenshot: true,
690        }
691    }
692}
693
694/// Subagent (`agent` tool) settings.
695#[derive(Debug, Clone, Serialize, Deserialize)]
696#[serde(default)]
697pub struct AgentsConfig {
698    /// Hard ceiling on one subagent drive's wall-clock runtime, in seconds.
699    /// `0` falls back to the built-in default (1200 = 20 minutes).
700    pub timeout_secs: u64,
701    /// User-defined agent types for the `agent` tool's `type` arg, keyed by
702    /// type name. A custom name shadows a built-in (`general`, `explore`),
703    /// so `[agents.types.explore]` retunes the built-in Explore.
704    /// ```toml
705    /// [agents.types.scout]
706    /// tools = ["read_file", "execute_command"]  # omit for the full child set
707    /// safety = "read_only"    # ceiling — the child never runs looser
708    /// preamble = "You are a scout: find and report, fast."
709    /// model = "ollama/qwen3:8b"  # default model; per-call `model` arg wins
710    /// ```
711    pub types: HashMap<String, AgentTypeConfig>,
712}
713
714impl Default for AgentsConfig {
715    fn default() -> Self {
716        Self {
717            timeout_secs: 1200,
718            types: HashMap::new(),
719        }
720    }
721}
722
723/// One user-defined agent type (see [`AgentsConfig::types`]). Every field is
724/// optional; an empty table behaves like the built-in `general` type.
725#[derive(Debug, Clone, Default, Serialize, Deserialize)]
726#[serde(default)]
727pub struct AgentTypeConfig {
728    /// Tool names the child registry is filtered to. Valid names:
729    /// `read_file`, `write_file`, `apply_patch`, `delete_file`,
730    /// `create_directory`, `execute_command`, `web_search`, `web_fetch`,
731    /// `mcp`. Omit for the full child set.
732    pub tools: Option<Vec<String>>,
733    /// Safety ceiling (canonical mode name: `read_only`/`ask`/`auto`/
734    /// `full_access`). The child runs at the LESS permissive of the parent's
735    /// live mode and this ceiling.
736    pub safety: Option<String>,
737    /// Extra system-prompt block appended after the child's subagent
738    /// contract.
739    pub preamble: Option<String>,
740    /// Default model id for this type (e.g. `"ollama/qwen3:8b"`); a per-call
741    /// `model` arg wins over it.
742    pub model: Option<String>,
743    /// Where this type's children write: `shared` (default) puts them in the
744    /// session's directory, `worktree` gives each its own git checkout whose
745    /// changes are applied to the project only when it finishes. A per-call
746    /// `isolation` arg wins over it.
747    ///
748    /// Isolate a type you fan out with; leave a type shared when its writes
749    /// need to be visible to the parent immediately.
750    pub isolation: Option<String>,
751}
752
753/// User-supplied remote provider configuration. All fields are optional for a
754/// built-in provider; fully custom OpenAI-compatible providers require a base
755/// URL and API-key environment variable.
756#[derive(Clone, Default, Serialize, Deserialize)]
757pub struct UserProviderConfig {
758    /// Override the provider API base URL (None = built-in default; required
759    /// for fully custom providers).
760    #[serde(default)]
761    pub base_url: Option<String>,
762    /// Env var name to read the API key from (None = use the built-in
763    /// registry default like `GROQ_API_KEY`; required for fully custom
764    /// providers).
765    #[serde(default)]
766    pub api_key_env: Option<String>,
767    /// Extra HTTP headers sent on every request to this provider.
768    #[serde(default)]
769    pub extra_headers: HashMap<String, String>,
770    /// Extra HTTP headers whose VALUES come from environment variables
771    /// (map is header name -> env var name), resolved at request-build time so
772    /// a secret header (e.g. a gateway token) never has to live in config.toml.
773    /// A missing env var is skipped.
774    #[serde(default)]
775    pub env_headers: HashMap<String, String>,
776    /// For fully custom providers (no built-in registry entry), declares
777    /// which OpenAI-compatible shape the endpoint speaks. Ignored when
778    /// the provider name matches a built-in registry entry. Values:
779    /// `"openai"` (no reasoning), `"openai-effort"` (`reasoning_effort`
780    /// field), `"openrouter"` (nested `reasoning: {effort}` object).
781    #[serde(default)]
782    pub compat: Option<String>,
783    /// Optional preferred model for this provider (a bare model id like
784    /// `claude-x`; a `vendor/model` id is fine too). Used as the startup
785    /// model when nothing else pins one — no `--model`, no
786    /// `last_used_model`, no `[default_model]`, and no local Ollama — which
787    /// is what lets a machine with only a provider key run bare `mermaid`.
788    #[serde(default)]
789    pub default_model: Option<String>,
790}
791
792/// MCP server configuration
793#[derive(Clone, Default, Serialize, Deserialize)]
794pub struct McpServerConfig {
795    /// Command to execute (e.g., "npx", "node", "python"). Empty = unset;
796    /// exactly one of `command` / `url` must be set (see [`Self::transport_kind`]).
797    #[serde(default, skip_serializing_if = "String::is_empty")]
798    pub command: String,
799    /// Command-line arguments
800    #[serde(default)]
801    pub args: Vec<String>,
802    /// Environment variables for the server process
803    #[serde(default)]
804    pub env: HashMap<String, String>,
805    /// Streamable HTTP endpoint URL for a remote MCP server. Presence selects
806    /// the HTTP transport; mutually exclusive with `command`. Must never
807    /// serialize as a bare `None` — toml errors on unsupported None values.
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub url: Option<String>,
810    /// Literal HTTP headers sent on every request to `url` (e.g. an
811    /// `Authorization` token). Values are secrets: redacted in `Debug`.
812    #[serde(default)]
813    pub headers: HashMap<String, String>,
814    /// HTTP headers whose VALUES come from environment variables (map is
815    /// header name -> env var name), resolved at request-build time so a
816    /// secret header never has to live in config.toml. A missing env var is
817    /// skipped. Same semantics as `UserProviderConfig::env_headers`.
818    #[serde(default)]
819    pub env_headers: HashMap<String, String>,
820    /// Allow `url` to resolve to private/link-local addresses. Off by default:
821    /// plugin bundles ship MCP configs, and a malicious bundle must not be
822    /// able to point a server entry at 169.254.169.254 or the LAN.
823    #[serde(default)]
824    pub allow_private_network: bool,
825    /// If non-empty, only these tool names are exposed to the model.
826    #[serde(default)]
827    pub enabled_tools: Vec<String>,
828    /// Tool names hidden from the model. Takes precedence over `enabled_tools`.
829    #[serde(default)]
830    pub disabled_tools: Vec<String>,
831    /// Per-server deferral override: `Some(false)` always advertises this
832    /// server's tools directly (skips `tool_search`); `Some(true)` defers
833    /// even when the global `mcp_defer_tools` is off; `None` follows the
834    /// global setting.
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub defer: Option<bool>,
837}
838
839/// Which transport an [`McpServerConfig`] selects: a spawned child process
840/// (stdio) or a remote Streamable HTTP endpoint.
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum TransportKind {
843    Stdio,
844    Http,
845}
846
847impl McpServerConfig {
848    /// Resolve which transport this config selects, enforcing the invariants:
849    /// exactly one of `command` / `url` set, and an HTTP url must be `https`
850    /// anywhere or `http` to a loopback host only (plaintext to a routable
851    /// host would leak `Authorization` headers in cleartext).
852    pub fn transport_kind(&self) -> Result<TransportKind> {
853        match (&self.url, self.command.is_empty()) {
854            (Some(_), false) => Err(anyhow::anyhow!(
855                "MCP server config sets both `command` and `url`; they are mutually exclusive"
856            )),
857            (None, true) => Err(anyhow::anyhow!(
858                "MCP server config sets neither `command` nor `url`"
859            )),
860            (None, false) => Ok(TransportKind::Stdio),
861            (Some(url), true) => {
862                let parsed = reqwest::Url::parse(url)
863                    .map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
864                let host = parsed.host_str().unwrap_or("");
865                match parsed.scheme() {
866                    "https" => Ok(TransportKind::Http),
867                    "http" if crate::utils::classify_host(host).is_loopback() => {
868                        Ok(TransportKind::Http)
869                    },
870                    "http" => Err(anyhow::anyhow!(
871                        "MCP server url '{url}' uses plaintext http to a non-loopback host; \
872                         use https (auth headers would travel in cleartext)"
873                    )),
874                    other => Err(anyhow::anyhow!(
875                        "MCP server url '{url}' has unsupported scheme '{other}' \
876                         (expected https, or http to loopback)"
877                    )),
878                }
879            },
880        }
881    }
882
883    /// Whether `tool_name` should be exposed to the model: hidden when listed in
884    /// `disabled_tools` (which wins), else allowed when `enabled_tools` is empty
885    /// (allow-all) or names it.
886    pub fn tool_allowed(&self, tool_name: &str) -> bool {
887        if self.disabled_tools.iter().any(|t| t == tool_name) {
888            return false;
889        }
890        self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
891    }
892}
893
894/// Mask a header/env map for `Debug`: keys are kept (so you can still see which
895/// vars are set) but values are never rendered — they hold secrets like API keys
896/// and `Authorization` tokens (#F12). A `BTreeMap` keeps the output deterministic.
897fn debug_masked_map(
898    map: &HashMap<String, String>,
899) -> std::collections::BTreeMap<&str, &'static str> {
900    map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
901}
902
903// Manual `Debug` for the secret-bearing config structs so a `{:?}` (into
904// tracing, a panic, or an error) cannot dump provider keys / Authorization
905// headers / MCP env secrets. `Config` keeps its derived `Debug`, which now
906// recurses through these redacting impls (#F12).
907impl std::fmt::Debug for McpServerConfig {
908    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
909        f.debug_struct("McpServerConfig")
910            .field("command", &self.command)
911            // args may carry an inline secret (e.g. `--api-key=sk-...`).
912            .field(
913                "args",
914                &self
915                    .args
916                    .iter()
917                    .map(|a| crate::utils::redact_secrets(a))
918                    .collect::<Vec<_>>(),
919            )
920            .field("env", &debug_masked_map(&self.env))
921            .field("url", &self.url)
922            // Literal header values are secrets (Authorization tokens).
923            .field("headers", &debug_masked_map(&self.headers))
924            // Values are env var NAMES (not secrets), so render them.
925            .field("env_headers", &self.env_headers)
926            .field("allow_private_network", &self.allow_private_network)
927            // Tool allow/deny lists are plain tool names, not secrets.
928            .field("enabled_tools", &self.enabled_tools)
929            .field("disabled_tools", &self.disabled_tools)
930            .finish()
931    }
932}
933
934impl std::fmt::Debug for UserProviderConfig {
935    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
936        f.debug_struct("UserProviderConfig")
937            .field("base_url", &self.base_url)
938            .field("api_key_env", &self.api_key_env)
939            .field("extra_headers", &debug_masked_map(&self.extra_headers))
940            // Values are env var NAMES (not secrets), so render them.
941            .field("env_headers", &self.env_headers)
942            .field("compat", &self.compat)
943            .field("default_model", &self.default_model)
944            .finish()
945    }
946}
947
948/// Default model settings
949#[derive(Debug, Clone, Serialize, Deserialize)]
950#[serde(default)]
951pub struct ModelSettings {
952    /// Model provider (ollama, openai, anthropic)
953    pub provider: String,
954    /// Model name
955    pub name: String,
956    /// Temperature for generation
957    pub temperature: f32,
958    /// Maximum tokens to generate
959    pub max_tokens: usize,
960    /// Default reasoning depth used for new sessions when no `--reasoning`
961    /// flag is given. Each adapter snaps this onto the closest level the
962    /// model actually supports via `nearest_effort()`.
963    pub reasoning: ReasoningLevel,
964}
965
966impl Default for ModelSettings {
967    fn default() -> Self {
968        Self {
969            provider: String::new(),
970            name: String::new(),
971            temperature: DEFAULT_TEMPERATURE,
972            // 0 = AUTO: the model-scaled output budget (adapters omit the cap so
973            // the provider decides, or size it to the context window). A positive
974            // value set by the user is an explicit hard cap.
975            max_tokens: 0,
976            reasoning: ReasoningLevel::default(),
977        }
978    }
979}
980
981/// Ollama configuration
982#[derive(Debug, Clone, Serialize, Deserialize)]
983#[serde(default)]
984pub struct OllamaConfig {
985    /// Ollama server host
986    pub host: String,
987    /// Ollama server port
988    pub port: u16,
989    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
990    /// Lower values free up VRAM for larger models at the cost of speed
991    pub num_gpu: Option<i32>,
992    /// Number of CPU threads for processing offloaded layers
993    /// Higher values improve CPU inference speed for large models
994    pub num_thread: Option<i32>,
995    /// Context window size (number of tokens)
996    /// Larger values allow longer conversations but use more memory
997    pub num_ctx: Option<i32>,
998    /// Enable NUMA optimization for multi-CPU systems
999    pub numa: Option<bool>,
1000    /// Allow Ollama to offload the model/KV cache to system RAM when it doesn't
1001    /// fit VRAM. **Disabled by default**: RAM offload is 5–20× slower, so by
1002    /// default Mermaid auto-fits `num_ctx` to VRAM (keeping the model on the
1003    /// GPU). Enable to trade speed for a larger context window. Toggle in-app
1004    /// with `/context offload on|off`.
1005    pub allow_ram_offload: bool,
1006    /// Optional hard cap on the auto-fitted context window (in tokens). `None`
1007    /// lets auto-fit use the full memory budget up to the model's max; set this
1008    /// to bound it (e.g. to leave VRAM headroom for other apps).
1009    pub max_auto_num_ctx: Option<usize>,
1010    /// Start `ollama serve` automatically when the configured server is local
1011    /// (loopback) and not running — the user should never have to leave
1012    /// mermaid to start Ollama. Disable if you manage the server yourself
1013    /// (e.g. systemd with custom flags). Never applies to remote hosts.
1014    pub auto_start: bool,
1015}
1016
1017impl Default for OllamaConfig {
1018    fn default() -> Self {
1019        Self {
1020            host: String::from("localhost"),
1021            port: DEFAULT_OLLAMA_PORT,
1022            num_gpu: None,            // Let Ollama auto-detect
1023            num_thread: None,         // Let Ollama auto-detect
1024            num_ctx: None,            // Use model default (overrides auto-fit)
1025            numa: None,               // Auto-detect
1026            allow_ram_offload: false, // VRAM-only by default (RAM is slow)
1027            max_auto_num_ctx: None,   // No cap; auto-fit to the memory budget
1028            auto_start: true,         // A dead local server is mermaid's problem
1029        }
1030    }
1031}
1032
1033/// Backend for the `web_fetch` tool.
1034#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1035#[serde(rename_all = "lowercase")]
1036pub enum FetchBackend {
1037    /// Fetch the URL directly from this machine and convert it to markdown.
1038    /// No API key, no third party — works for any user with network access.
1039    #[default]
1040    Native,
1041    /// Route through Ollama Cloud's `/api/web_fetch` (needs `OLLAMA_API_KEY`).
1042    Ollama,
1043}
1044
1045/// Backend for the `web_search` tool.
1046#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1047#[serde(rename_all = "lowercase")]
1048pub enum SearchBackend {
1049    /// Sovereign zero-config default: an auto-managed local SearXNG process on
1050    /// platforms with a published bundle. It never selects a cloud backend
1051    /// merely because a credential exists.
1052    #[default]
1053    Auto,
1054    /// Ollama Cloud's `/api/web_search` (needs `OLLAMA_API_KEY`).
1055    Ollama,
1056    /// A self-hosted SearXNG instance queried at `searxng_url` — keyless.
1057    Searxng,
1058}
1059
1060/// Web tool backend configuration.
1061///
1062/// ```toml
1063/// [web]
1064/// fetch_backend = "native"   # or "ollama"
1065/// search_backend = "auto"    # or "ollama" / "searxng"
1066/// searxng_url = "http://localhost:8080"
1067/// ```
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1069#[serde(default)]
1070pub struct WebConfig {
1071    /// Backend for `web_fetch`. `native` (default) fetches the URL from this
1072    /// machine and needs no key; `ollama` uses Ollama Cloud.
1073    pub fetch_backend: FetchBackend,
1074    /// Backend for `web_search`. `auto` (default) auto-manages a local SearXNG
1075    /// process where a bundle is supported. `ollama` explicitly selects Ollama
1076    /// Cloud; `searxng` selects a self-hosted instance at `searxng_url`.
1077    pub search_backend: SearchBackend,
1078    /// SearXNG base URL, used when `search_backend = "searxng"` (your own
1079    /// instance). The instance must have the JSON output format enabled
1080    /// (`search.formats` includes `json`). The `auto` managed instance ignores
1081    /// this and picks its own port.
1082    pub searxng_url: String,
1083}
1084
1085impl Default for WebConfig {
1086    fn default() -> Self {
1087        Self {
1088            fetch_backend: FetchBackend::Native,
1089            search_backend: SearchBackend::Auto,
1090            searxng_url: String::from("http://localhost:8080"),
1091        }
1092    }
1093}
1094
1095/// Non-interactive mode configuration
1096#[derive(Debug, Clone, Serialize, Deserialize)]
1097#[serde(default)]
1098pub struct NonInteractiveConfig {
1099    /// Output format (text, json, markdown)
1100    pub output_format: String,
1101    /// Maximum tokens to generate
1102    pub max_tokens: usize,
1103    /// Don't execute agent actions (dry run)
1104    pub no_execute: bool,
1105}
1106
1107impl Default for NonInteractiveConfig {
1108    fn default() -> Self {
1109        Self {
1110            output_format: String::from("text"),
1111            // 0 = AUTO (see `ModelSettings::max_tokens`).
1112            max_tokens: 0,
1113            no_execute: false,
1114        }
1115    }
1116}
1117
1118/// One source of configuration in the layered merge. Declaration order IS
1119/// precedence: every later layer's table is deep-merged over the earlier ones,
1120/// so `Defaults < User < Profile < Project < Session`.
1121#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1122pub enum ConfigLayer {
1123    /// Built-in defaults (`Config::default()`); the implicit base — an empty
1124    /// table deserializes to it, so no explicit table is ever built for it.
1125    Defaults = 0,
1126    /// The user's `~/.config/mermaid/config.toml` — the only layer persists
1127    /// write to.
1128    User = 1,
1129    /// A named overlay from the user file's `[profiles.<name>]`, selected
1130    /// with `--profile <name>`. Sits BELOW Project so a repo's tighten-only
1131    /// safety clamp still wins over a profile's choices.
1132    Profile = 2,
1133    /// A repo's `<git-root>/.mermaid/config.toml` (sanitized + tighten-only;
1134    /// populated by the project-config loader).
1135    Project = 3,
1136    /// This invocation's CLI flags: `-c KEY=VALUE` plus the dedicated flags
1137    /// (`--no-network`, `--confine-fs`, `--sandbox`, `run --max-tokens`,
1138    /// `run --allow-untrusted-tools`).
1139    Session = 4,
1140}
1141
1142impl ConfigLayer {
1143    /// Human name used in unknown-key warnings ("in user config (…)").
1144    fn name(self) -> &'static str {
1145        match self {
1146            ConfigLayer::Defaults => "defaults",
1147            ConfigLayer::User => "user config",
1148            ConfigLayer::Profile => "config profile",
1149            ConfigLayer::Project => "project config",
1150            ConfigLayer::Session => "session flags",
1151        }
1152    }
1153}
1154
1155/// One layer's raw table plus where it came from (for warning attribution).
1156#[derive(Debug, Clone)]
1157pub(crate) struct LayerSource {
1158    /// Which precedence slot this table occupies.
1159    pub layer: ConfigLayer,
1160    /// Human-readable origin (file path or "command line") for warnings.
1161    pub origin: String,
1162    /// The layer's raw parsed TOML, merged verbatim (already sanitized for
1163    /// the project layer).
1164    pub table: toml::Table,
1165}
1166
1167/// The per-invocation config overrides carried by CLI flags — the `Session`
1168/// layer's inputs. Built from the parsed CLI by `Cli::session_flags()`.
1169#[derive(Debug, Clone, Default)]
1170pub struct SessionFlags {
1171    /// Repeatable `-c KEY=VALUE` overrides, applied first (dedicated flags
1172    /// deep-set on top, so a flag beats a contradictory `-c`).
1173    pub overrides: Vec<String>,
1174    /// `--no-network` or `--sandbox` → `safety.network = "deny"`.
1175    pub deny_network: bool,
1176    /// `--confine-fs` or `--sandbox` → `safety.filesystem = "project"`.
1177    pub confine_fs: bool,
1178    /// `run --max-tokens <n>` → `default_model.max_tokens`.
1179    pub max_tokens: Option<usize>,
1180    /// `run --allow-untrusted-tools` → `safety.allow_untrusted_headless_tools`.
1181    pub allow_untrusted_tools: bool,
1182    /// `--profile <name>`: select a `[profiles.<name>]` overlay from the user
1183    /// config file. NOT rendered into `to_table` — profiles are their own
1184    /// layer, resolved by `load_layered_config`.
1185    pub profile: Option<String>,
1186}
1187
1188impl SessionFlags {
1189    /// Render the flags as the `Session` layer's raw table. `-c` overrides go
1190    /// in first; the dedicated flags deep-set on top of them, preserving the
1191    /// historical ordering where `--no-network` beats `-c safety.network=allow`.
1192    pub(crate) fn to_table(&self) -> Result<toml::Table> {
1193        let mut table = toml::Table::new();
1194        apply_cli_overrides(&mut table, &self.overrides)?;
1195        if self.deny_network {
1196            deep_set_segments(
1197                &mut table,
1198                &["safety", "network"],
1199                toml::Value::String("deny".into()),
1200            )?;
1201        }
1202        if self.confine_fs {
1203            deep_set_segments(
1204                &mut table,
1205                &["safety", "filesystem"],
1206                toml::Value::String("project".into()),
1207            )?;
1208        }
1209        if let Some(n) = self.max_tokens {
1210            deep_set_segments(
1211                &mut table,
1212                &["default_model", "max_tokens"],
1213                toml::Value::Integer(n as i64),
1214            )?;
1215        }
1216        if self.allow_untrusted_tools {
1217            deep_set_segments(
1218                &mut table,
1219                &["safety", "allow_untrusted_headless_tools"],
1220                toml::Value::Boolean(true),
1221            )?;
1222        }
1223        Ok(table)
1224    }
1225}
1226
1227/// Remove the `profiles` table from a raw user-config table and return it
1228/// (empty when absent). `[profiles.<name>]` overlays must NEVER reach
1229/// `Config` deserialization — they are a container of layer tables, not
1230/// config keys — so every user-file read excises them before
1231/// `finalize_config` (which would otherwise warn about unknown keys) and
1232/// before any safety baseline is computed.
1233fn take_profiles(table: &mut toml::Table) -> toml::Table {
1234    match table.remove("profiles") {
1235        Some(toml::Value::Table(profiles)) => profiles,
1236        // A non-table `profiles` key is malformed; drop it (the profile
1237        // lookup errors clearly when one was requested).
1238        _ => toml::Table::new(),
1239    }
1240}
1241
1242/// Resolve `--profile <name>` against the user file's excised `[profiles.*]`
1243/// table: the named overlay as a `Profile` layer, or a hard error naming the
1244/// available profiles (sorted).
1245fn resolve_profile_layer(
1246    profiles: &toml::Table,
1247    name: &str,
1248    config_path: &std::path::Path,
1249) -> Result<LayerSource> {
1250    match profiles.get(name) {
1251        Some(toml::Value::Table(overlay)) => Ok(LayerSource {
1252            layer: ConfigLayer::Profile,
1253            origin: format!("profile:{} ({})", name, config_path.display()),
1254            table: overlay.clone(),
1255        }),
1256        Some(_) => anyhow::bail!(
1257            "config profile '{}' is not a table; define it as [profiles.{}] in {}",
1258            name,
1259            name,
1260            config_path.display()
1261        ),
1262        None => {
1263            let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
1264            available.sort_unstable();
1265            if available.is_empty() {
1266                anyhow::bail!(
1267                    "no config profiles defined; add [profiles.{}] to {}",
1268                    name,
1269                    config_path.display()
1270                );
1271            }
1272            anyhow::bail!(
1273                "unknown config profile '{}'; available: {}",
1274                name,
1275                available.join(", ")
1276            )
1277        },
1278    }
1279}
1280
1281/// Load the user-scope configuration (defaults + the user file, no project or
1282/// session layers). This is the view persistence baselines, the daemon, and
1283/// runtime re-reads use — anything that must not observe another repo's
1284/// project config or a one-off CLI flag.
1285pub fn load_config() -> Result<Config> {
1286    let config_path = get_config_path()?;
1287    let mut table = read_config_table(&config_path)?;
1288    migrate_legacy_max_tokens(&mut table);
1289    migrate_legacy_model_profiles(&mut table);
1290    let _ = take_profiles(&mut table);
1291    Ok(finalize_config(table)?.0)
1292}
1293
1294/// A completed layered load: the merged config plus the messages the startup
1295/// path surfaces.
1296pub struct LayeredLoad {
1297    /// The merged, typed configuration.
1298    pub config: Config,
1299    /// Layer-attributed unknown-key and project-sanitizer warnings.
1300    pub warnings: Vec<String>,
1301    /// Informational lines (e.g. "using project config …").
1302    pub notices: Vec<String>,
1303}
1304
1305/// Load the full layered configuration:
1306/// defaults < user file < project file < session flags.
1307/// `cwd` locates the project layer (`<git-root>/.mermaid/config.toml`,
1308/// sanitized + safety-clamped); pass `None` to skip it (daemon, tests).
1309pub fn load_layered_config(
1310    cwd: Option<&std::path::Path>,
1311    flags: &SessionFlags,
1312) -> Result<LayeredLoad> {
1313    let config_path = get_config_path()?;
1314    let mut user_table = read_config_table(&config_path)?;
1315    migrate_legacy_max_tokens(&mut user_table);
1316    migrate_legacy_model_profiles(&mut user_table);
1317    // Excise [profiles.*] BEFORE anything deserializes the user table (the
1318    // safety baseline below and finalize_config's unknown-key scan).
1319    let profiles = take_profiles(&mut user_table);
1320    let mut layers = vec![LayerSource {
1321        layer: ConfigLayer::User,
1322        origin: config_path.display().to_string(),
1323        table: user_table.clone(),
1324    }];
1325    let mut sanitizer_warnings = Vec::new();
1326    let mut notices = Vec::new();
1327    if let Some(name) = flags.profile.as_deref() {
1328        let layer = resolve_profile_layer(&profiles, name, &config_path)?;
1329        notices.push(format!(
1330            "using config profile '{}' (from {})",
1331            name,
1332            config_path.display()
1333        ));
1334        layers.push(layer);
1335    }
1336    if let Some(cwd) = cwd {
1337        // The tighten-only safety clamp compares against the user-scope
1338        // (defaults + user file) values.
1339        let base_safety = finalize_config(user_table)?.0.safety;
1340        let (layer, warnings, notice) =
1341            super::project_config::load_project_layer(cwd, &base_safety);
1342        sanitizer_warnings.extend(warnings);
1343        notices.extend(notice);
1344        if let Some(layer) = layer {
1345            layers.push(layer);
1346        }
1347    }
1348    layers.push(LayerSource {
1349        layer: ConfigLayer::Session,
1350        origin: "command line".to_string(),
1351        table: flags.to_table()?,
1352    });
1353    let (mut config, unknown_key_warnings) = merge_layers(layers)?;
1354    config.active_profile = flags.profile.clone();
1355    // Sanitizer warnings first: they explain keys that will also be absent
1356    // from the merged result.
1357    sanitizer_warnings.extend(unknown_key_warnings);
1358    Ok(LayeredLoad {
1359        config,
1360        warnings: sanitizer_warnings,
1361        notices,
1362    })
1363}
1364
1365/// The project-scoped view (defaults + user + project, NO session flags) for
1366/// runtime re-reads keyed to a workdir — e.g. the memory settings consulted
1367/// per operation. Never fails and never prints; warnings/notices were already
1368/// surfaced by the startup load.
1369pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
1370    fn load(cwd: &std::path::Path) -> Result<Config> {
1371        let config_path = get_config_path()?;
1372        let mut user_table = read_config_table(&config_path)?;
1373        migrate_legacy_max_tokens(&mut user_table);
1374        migrate_legacy_model_profiles(&mut user_table);
1375        let _ = take_profiles(&mut user_table);
1376        let base_safety = finalize_config(user_table.clone())?.0.safety;
1377        let mut layers = vec![LayerSource {
1378            layer: ConfigLayer::User,
1379            origin: config_path.display().to_string(),
1380            table: user_table,
1381        }];
1382        let (layer, _warnings, _notice) =
1383            super::project_config::load_project_layer(cwd, &base_safety);
1384        if let Some(layer) = layer {
1385            layers.push(layer);
1386        }
1387        Ok(merge_layers(layers)?.0)
1388    }
1389    load(cwd).unwrap_or_default()
1390}
1391
1392/// Like [`load_config`] (user scope, no session flags) but never fails: on a
1393/// malformed config, warn on stderr (secret-redacted, #F13) and fall back to
1394/// defaults (#111). For standalone subcommands that only read user settings.
1395pub fn load_config_or_warn() -> Config {
1396    load_config().unwrap_or_else(|e| {
1397        eprintln!(
1398            "mermaid: {}",
1399            crate::utils::redact_secrets(&format!("{e:#}"))
1400        );
1401        Config::default()
1402    })
1403}
1404
1405/// Read and parse one layer's TOML file; a missing file is an empty table.
1406pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
1407    if !path.exists() {
1408        return Ok(toml::Table::new());
1409    }
1410    let raw = std::fs::read_to_string(path)
1411        .with_context(|| format!("Failed to read {}", path.display()))?;
1412    toml::from_str::<toml::Table>(&raw).with_context(|| {
1413        format!(
1414            "Failed to parse {}. Run 'mermaid init' to regenerate.",
1415            path.display()
1416        )
1417    })
1418}
1419
1420/// Deep-merge the layers in order (later wins) and deserialize the result
1421/// once. Unknown-key warnings are collected per layer so each names the file
1422/// (or flag set) that actually contains the typo.
1423pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
1424    let mut warnings = Vec::new();
1425    let mut merged = toml::Table::new();
1426    for layer in layers {
1427        collect_layer_warnings(&layer, &mut warnings);
1428        deep_merge(&mut merged, layer.table);
1429    }
1430    let (config, _) = finalize_config(merged)?;
1431    Ok((config, warnings))
1432}
1433
1434/// Run one layer's table through `serde_ignored` purely for warning
1435/// attribution. A layer that fails to deserialize on its own contributes no
1436/// warnings — the authoritative merged deserialize in `merge_layers` surfaces
1437/// any real error (and a later layer may legitimately fix an earlier one's
1438/// value).
1439fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
1440    let mut ignored = Vec::new();
1441    let result: Result<Config, _> =
1442        serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
1443            ignored.push(path.to_string())
1444        });
1445    if result.is_ok() {
1446        for path in ignored {
1447            warnings.push(format!(
1448                "unknown config key '{path}' in {} ({}) — check for a typo",
1449                layer.layer.name(),
1450                layer.origin
1451            ));
1452        }
1453    }
1454}
1455
1456/// Recursively merge `overlay` into `base`: tables merge key-by-key, while
1457/// scalars and arrays replace wholesale (arrays are atomic values here — an
1458/// element-wise merge could never express removing an entry). A kind conflict
1459/// (table over scalar or vice versa) resolves to the overlay's value.
1460fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
1461    for (key, value) in overlay {
1462        match (base.get_mut(&key), value) {
1463            (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
1464                deep_merge(base_table, overlay_table);
1465            },
1466            (_, value) => {
1467                base.insert(key, value);
1468            },
1469        }
1470    }
1471}
1472
1473/// One-time migration for the AUTO output-budget change. Existing config files
1474/// froze the old `default_model.max_tokens = 4096` default to disk (`save_config`
1475/// serializes every field), which would otherwise pin the stale cap forever.
1476/// Coerce that legacy value to `0` (AUTO) so upgraded users get the model-scaled
1477/// budget. Applied to the on-disk table *before* CLI overrides, so an explicit
1478/// `-c default_model.max_tokens=4096` still wins. The only unpreserved case is a
1479/// user who hand-wrote exactly `4096` in config.toml — an unusual deliberate
1480/// value, and AUTO is the better default regardless.
1481fn migrate_legacy_max_tokens(table: &mut toml::Table) {
1482    if let Some(dm) = table
1483        .get_mut("default_model")
1484        .and_then(|v| v.as_table_mut())
1485        && dm.get("max_tokens").and_then(|v| v.as_integer())
1486            == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
1487    {
1488        dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
1489    }
1490}
1491
1492/// Migrate the pre-profiles `[model_profiles]` table to its new name,
1493/// `[model_aliases]` (the `profile` name now belongs to `--profile` config
1494/// overlays). Runs wherever `migrate_legacy_max_tokens` runs: config loads
1495/// stop warning immediately, and the next persist converges the file on
1496/// disk. A file that somehow has BOTH tables keeps `model_aliases`.
1497fn migrate_legacy_model_profiles(table: &mut toml::Table) {
1498    if table.contains_key("model_aliases") {
1499        table.remove("model_profiles");
1500        return;
1501    }
1502    if let Some(profiles) = table.remove("model_profiles") {
1503        table.insert("model_aliases".to_string(), profiles);
1504    }
1505}
1506
1507/// Deserialize a (possibly merged) config `Table` into `Config`, collecting the
1508/// dotted paths of any keys `Config` doesn't recognize so the caller can warn.
1509/// An empty table yields `Config::default()` (every field is `#[serde(default)]`).
1510fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
1511    let mut ignored = Vec::new();
1512    let mut config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
1513        ignored.push(path.to_string());
1514    })
1515    .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
1516    // `plan` is a live session mode, not a persistent default: entering it
1517    // allocates a plan file, which config loading has no session to do it for.
1518    // `safety.mode = "plan"` would otherwise start a session that reports
1519    // "planning" with no plan to write. Fall back to the default and let
1520    // `/plan`, `/safety plan`, or Shift+Tab do the real thing. It is also what
1521    // `mode_after_plan` reads, so this must never be `plan` itself.
1522    if config.safety.mode.is_planning() {
1523        config.safety.mode = SafetyConfig::default().mode;
1524        ignored.push(
1525            "safety.mode (plan is entered with /plan or Shift+Tab, not configured)".to_string(),
1526        );
1527    }
1528    Ok((config, ignored))
1529}
1530
1531/// Apply repeatable `-c KEY=VALUE` overrides onto a config table. `KEY` is a
1532/// dotted path (`default_model.model`); `VALUE` is parsed as a TOML scalar so
1533/// `true`/`3`/`"x"` keep their types, with a bare word treated as a string.
1534fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
1535    for raw in overrides {
1536        let (key, val) = raw
1537            .split_once('=')
1538            .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
1539        let key = key.trim();
1540        if key.is_empty() {
1541            anyhow::bail!("invalid -c override '{raw}' (empty key)");
1542        }
1543        deep_set(table, key, parse_override_value(val.trim()))?;
1544    }
1545    Ok(())
1546}
1547
1548/// Parse an override value as a standalone TOML value, falling back to a plain
1549/// string when it isn't valid TOML on its own (e.g. `ollama/qwen`).
1550fn parse_override_value(s: &str) -> toml::Value {
1551    toml::from_str::<toml::Table>(&format!("x = {s}"))
1552        .ok()
1553        .and_then(|t| t.get("x").cloned())
1554        .unwrap_or_else(|| toml::Value::String(s.to_string()))
1555}
1556
1557/// Set a dotted `key` path in `table` to `value`, creating intermediate
1558/// tables. Dotted-path parsing means a `-c` override cannot address a map key
1559/// that itself contains a dot (e.g. a `reasoning_per_model` model id) — a
1560/// documented syntax limitation; internal persists use
1561/// [`deep_set_segments`] directly and are immune.
1562fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
1563    let parts: Vec<&str> = key.split('.').collect();
1564    deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
1565}
1566
1567/// Set a pre-split `path` in `table` to `value`, creating intermediate tables.
1568/// Segments are literal keys — a segment containing a dot addresses exactly
1569/// that key (which dotted parsing cannot express).
1570fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
1571    let Some((leaf, parents)) = path.split_last() else {
1572        anyhow::bail!("empty config key path");
1573    };
1574    let mut cur = table;
1575    for part in parents {
1576        let next = cur
1577            .entry((*part).to_string())
1578            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1579        cur = next
1580            .as_table_mut()
1581            .with_context(|| format!("'{part}' is not a table"))?;
1582    }
1583    cur.insert((*leaf).to_string(), value);
1584    Ok(())
1585}
1586
1587/// Remove a pre-split `path` from `table`. Returns whether a value was
1588/// actually removed. Never creates intermediate tables; a missing parent
1589/// simply means there was nothing to remove.
1590pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
1591    let Some((leaf, parents)) = path.split_last() else {
1592        return false;
1593    };
1594    let mut cur = table;
1595    for part in parents {
1596        match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
1597            Some(next) => cur = next,
1598            None => return false,
1599        }
1600    }
1601    cur.remove(*leaf).is_some()
1602}
1603
1604/// Like [`load_layered_config`] but never fails — the startup entry point.
1605/// On success, prints notices and layer-attributed warnings to stderr. On a
1606/// malformed layer, warns (secret-redacted, #F13) and degrades: the session
1607/// flags are re-applied over bare defaults so `--no-network`/`-c` survive a
1608/// corrupt user file rather than being silently dropped with it.
1609pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
1610    match load_layered_config(cwd, flags) {
1611        Ok(load) => {
1612            for notice in &load.notices {
1613                eprintln!("mermaid: {notice}");
1614            }
1615            for warning in &load.warnings {
1616                eprintln!("mermaid: warning: {warning}");
1617            }
1618            load.config
1619        },
1620        Err(e) => {
1621            // A TOML parse error renders the offending source line, which can be
1622            // a secret-bearing one (`extra_headers`/`env`/`api_key_env`); scrub
1623            // credential-shaped content before it reaches stderr (#F13).
1624            eprintln!(
1625                "mermaid: {}",
1626                crate::utils::redact_secrets(&format!("{e:#}"))
1627            );
1628            flags
1629                .to_table()
1630                .ok()
1631                .and_then(|table| finalize_config(table).ok())
1632                .map(|(config, _)| config)
1633                .unwrap_or_default()
1634        },
1635    }
1636}
1637
1638/// Get the path to the single config file
1639pub fn get_config_path() -> Result<PathBuf> {
1640    Ok(get_config_dir()?.join("config.toml"))
1641}
1642
1643/// Get the configuration directory
1644pub fn get_config_dir() -> Result<PathBuf> {
1645    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1646        let config_dir = proj_dirs.config_dir();
1647        std::fs::create_dir_all(config_dir)?;
1648        Ok(config_dir.to_path_buf())
1649    } else {
1650        // Fallback to home directory
1651        let home = std::env::var("HOME")
1652            .or_else(|_| std::env::var("USERPROFILE"))
1653            .context("Could not determine home directory")?;
1654        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
1655        std::fs::create_dir_all(&config_dir)?;
1656        Ok(config_dir)
1657    }
1658}
1659
1660/// Save a full configuration to file. Private on purpose: serializing the
1661/// whole typed `Config` freezes every default (and would freeze merged
1662/// project/session values) into the file, so the only legitimate callers are
1663/// `init_config` (writing pristine defaults to an absent file) and tests.
1664/// Runtime persistence goes through [`update_user_config_key`] /
1665/// [`remove_user_config_key`], which rewrite only their own keys.
1666fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
1667    let path = if let Some(p) = path {
1668        p
1669    } else {
1670        get_config_dir()?.join("config.toml")
1671    };
1672    write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
1673}
1674
1675/// Write raw config bytes atomically and owner-only.
1676///
1677/// The config can carry literal secrets — `mcp_servers[].env`,
1678/// `mcp_servers[].args`, `mcp_servers[].headers`, and
1679/// `providers[].extra_headers` all accept inline credential values — so it
1680/// must not be left world-readable, and a crash
1681/// mid-write must not truncate it. Write atomically (temp → fsync → rename),
1682/// creating the temp 0600 on Unix so the renamed file is never even briefly
1683/// world-readable (this also tightens a pre-existing config, since the new
1684/// file replaces the old one). Windows relies on the per-user profile ACL.
1685fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1686    #[cfg(unix)]
1687    crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
1688        .with_context(|| format!("Failed to write config to {}", path.display()))?;
1689    #[cfg(not(unix))]
1690    crate::runtime::write_atomic(path, bytes)
1691        .with_context(|| format!("Failed to write config to {}", path.display()))?;
1692    Ok(())
1693}
1694
1695/// Create a default configuration file if it doesn't exist
1696pub fn init_config() -> Result<()> {
1697    let config_file = get_config_path()?;
1698
1699    if config_file.exists() {
1700        println!("Configuration already exists at: {}", config_file.display());
1701    } else {
1702        let default_config = Config::default();
1703        save_config(&default_config, Some(config_file.clone()))?;
1704        println!("Created configuration at: {}", config_file.display());
1705    }
1706
1707    Ok(())
1708}
1709
1710/// Serializes the read-modify-write persistence path. The `persist_*` helpers
1711/// run as concurrent detached tasks (dispatched by the effect runner) that all
1712/// load → mutate → save the same file; without a lock two quick toggles
1713/// (`/model` then Alt+T) can interleave their loads and lose one write. Held
1714/// only across the synchronous fs work — never across an `.await`.
1715static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1716
1717/// Read the raw USER config table, apply `mutate`, and write it back — under
1718/// `PERSIST_LOCK` so concurrent persists can't clobber each other. Operating
1719/// on the raw table (never the merged typed `Config`) means a persist rewrites
1720/// only its own keys: unknown keys survive, defaults are not frozen in, and
1721/// project-layer or session-flag values can never leak into the user file.
1722/// A malformed file propagates the parse error rather than being overwritten
1723/// with defaults (#111).
1724fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
1725    update_user_config_table_at(&get_config_path()?, mutate)
1726}
1727
1728/// [`update_user_config_table`] against an explicit path (test seam).
1729fn update_user_config_table_at(
1730    path: &std::path::Path,
1731    mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
1732) -> Result<()> {
1733    let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1734    let mut table = read_config_table(path)?;
1735    // Converge the on-disk legacy output cap while we're rewriting anyway.
1736    migrate_legacy_max_tokens(&mut table);
1737    migrate_legacy_model_profiles(&mut table);
1738    mutate(&mut table)?;
1739    write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
1740}
1741
1742/// Set one key (pre-split path segments, so map keys containing dots — e.g.
1743/// `reasoning_per_model."ollama/qwen3:8b"` — address correctly) in the USER
1744/// config file, leaving every other key untouched.
1745pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
1746    update_user_config_table(|table| deep_set_segments(table, path, value))
1747}
1748
1749/// Persist the whole `[plan]` table (the `/plan config` picker). Values the
1750/// user set through the picker are explicit choices, so writing them —
1751/// including ones that currently match defaults — is correct; unset Options
1752/// stay absent via `skip_serializing_if`.
1753pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
1754    update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
1755}
1756
1757/// Remove one key (pre-split path segments) from the USER config file.
1758/// Returns whether the key existed.
1759pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
1760    let mut removed = false;
1761    update_user_config_table(|table| {
1762        removed = deep_remove_segments(table, path);
1763        Ok(())
1764    })?;
1765    Ok(removed)
1766}
1767
1768/// Persist the last used model to the user config file.
1769pub fn persist_last_model(model: &str) -> Result<()> {
1770    update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
1771}
1772
1773/// Persist the TUI theme choice (`/theme dark|light`).
1774pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
1775    update_user_config_key(
1776        &["ui", "theme"],
1777        toml::Value::String(theme.as_str().to_string()),
1778    )
1779}
1780
1781/// Persist the user's default reasoning level. Used by the `/reasoning` slash
1782/// command and the Alt+T cycle handler so the choice survives across sessions.
1783pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
1784    update_user_config_key(
1785        &["default_model", "reasoning"],
1786        toml::Value::try_from(level)?,
1787    )
1788}
1789
1790/// Persist a reasoning level for a specific model ID
1791/// (e.g. `<provider>/<model>`). The TUI calls this from Alt+T,
1792/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
1793/// the choice sticks per-model rather than bleeding into other models on
1794/// next session start.
1795pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
1796    update_user_config_key(
1797        &["reasoning_per_model", model_id],
1798        toml::Value::try_from(level)?,
1799    )
1800}
1801
1802/// Persist (or clear) a per-model Ollama `num_ctx` override. `Some(n)` sets it,
1803/// `None` removes the entry (returning that model to auto-fit).
1804pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
1805    match num_ctx {
1806        Some(n) => update_user_config_key(
1807            &["ollama_num_ctx_per_model", model_id],
1808            toml::Value::Integer(i64::from(n)),
1809        ),
1810        None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
1811    }
1812}
1813
1814/// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
1815pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
1816    update_user_config_key(
1817        &["ollama", "allow_ram_offload"],
1818        toml::Value::Boolean(enabled),
1819    )
1820}
1821
1822/// Resolve which model to use: CLI arg > last_used > `[default_model]` > a
1823/// local Ollama model > a configured provider's `default_model`.
1824pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
1825    if let Some(model) = cli_model {
1826        if let Some(resolved) = resolve_model_alias(model, config)? {
1827            return Ok(resolved);
1828        }
1829        return Ok(model.to_string());
1830    }
1831    if let Some(last_model) = &config.last_used_model {
1832        if let Some(resolved) = resolve_model_alias(last_model, config)? {
1833            return Ok(resolved);
1834        }
1835        return Ok(last_model.clone());
1836    }
1837    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1838        return Ok(format!(
1839            "{}/{}",
1840            config.default_model.provider, config.default_model.name
1841        ));
1842    }
1843    // Nothing pinned. Ollama is Mermaid's default backend, not a prerequisite:
1844    // prefer a local model when one is installed, then a remote provider the
1845    // user has given an explicit `default_model`, and only then give up — with
1846    // a message that offers both routes instead of demanding an Ollama install
1847    // from someone who set `ANTHROPIC_API_KEY` and never wanted local models.
1848    let local = crate::ollama::local_models(config).await;
1849    if let Some(first) = local.as_ref().and_then(|models| models.first()) {
1850        return Ok(format!("ollama/{}", first));
1851    }
1852    if let Some(model_id) = configured_provider_default_model(config) {
1853        return Ok(model_id);
1854    }
1855    Err(no_model_configured_error(config, local.is_some()))
1856}
1857
1858/// A `[providers.<name>].default_model` belonging to a provider whose API key
1859/// resolves right now. It is a model id the user typed themselves, so using it
1860/// as the startup default requires no guess about which models a vendor
1861/// currently ships — Mermaid never invents model names.
1862fn configured_provider_default_model(config: &Config) -> Option<String> {
1863    for provider in crate::providers::configured_remote_providers(config) {
1864        let model = config
1865            .providers
1866            .get(&provider.name)
1867            .and_then(|entry| entry.default_model.as_deref())
1868            .map(str::trim)
1869            .filter(|model| !model.is_empty());
1870        let Some(model) = model else { continue };
1871        // The field holds a bare model name, but an id that already carries
1872        // its provider prefix (or an OpenRouter-style `vendor/model`) must not
1873        // be double-prefixed into `openrouter/openrouter/...`.
1874        if model.starts_with(&format!("{}/", provider.name)) {
1875            return Some(model.to_string());
1876        }
1877        return Some(format!("{}/{}", provider.name, model));
1878    }
1879    None
1880}
1881
1882/// The startup error for "no model is configured yet".
1883///
1884/// Ollama is one of two ways to get a model, so this never tells a user who
1885/// already has a provider key that they must install it. `ollama_installed`
1886/// distinguishes "install Ollama" from "you have Ollama, pull a model".
1887fn no_model_configured_error(config: &Config, ollama_installed: bool) -> anyhow::Error {
1888    let providers = crate::providers::configured_remote_providers(config);
1889    let mut lines = vec!["No model configured yet.".to_string(), String::new()];
1890
1891    if let Some(first) = providers.first() {
1892        let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
1893        lines.push(format!("Remote providers ready: {}", names.join(", ")));
1894        lines.push("Name a model to use one, e.g.:".to_string());
1895        lines.push(format!("    mermaid --model {}/<model>", first.name));
1896        lines.push(
1897            "Mermaid remembers the last model you used, so --model is a one-time step; \
1898             `mermaid list` shows what is available."
1899                .to_string(),
1900        );
1901        lines.push(String::new());
1902        lines.push("Or pin one in config.toml:".to_string());
1903        lines.push(format!("    [providers.{}]", first.name));
1904        lines.push("    default_model = \"<model>\"".to_string());
1905    } else {
1906        lines.push(
1907            "For a remote model, set a provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY,"
1908                .to_string(),
1909        );
1910        lines.push("GOOGLE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, …) and name a".to_string());
1911        lines.push("model: mermaid --model anthropic/<model>".to_string());
1912    }
1913
1914    lines.push(String::new());
1915    if ollama_installed {
1916        lines.push("For a local model, pull one first: ollama pull qwen3:8b".to_string());
1917    } else {
1918        lines.push(
1919            "For local models, install Ollama (https://ollama.com/download), then: \
1920             ollama pull qwen3:8b"
1921                .to_string(),
1922        );
1923    }
1924    lines.push("`mermaid doctor` reports what is and isn't ready.".to_string());
1925
1926    anyhow::anyhow!(lines.join("\n"))
1927}
1928
1929fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
1930    let alias = requested.strip_prefix("alias:").unwrap_or(requested);
1931    if let Some(model) = config.model_aliases.get(alias) {
1932        anyhow::ensure!(
1933            !model.trim().is_empty(),
1934            "model alias `{}` is configured with an empty model id",
1935            alias
1936        );
1937        return Ok(Some(model.clone()));
1938    }
1939    if requested.starts_with("alias:") {
1940        anyhow::bail!(
1941            "model alias `{}` is not configured; add it under [model_aliases]",
1942            alias
1943        );
1944    }
1945    Ok(None)
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950    use super::*;
1951
1952    #[test]
1953    fn legacy_default_max_tokens_migrates_to_auto() {
1954        // The frozen pre-AUTO default (4096) on disk is coerced to 0 = AUTO…
1955        let mut table: toml::Table =
1956            toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
1957        migrate_legacy_max_tokens(&mut table);
1958        migrate_legacy_model_profiles(&mut table);
1959        let (config, _) = finalize_config(table).unwrap();
1960        assert_eq!(config.default_model.max_tokens, 0);
1961
1962        // …while any other explicit cap is preserved.
1963        let mut table: toml::Table =
1964            toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
1965        migrate_legacy_max_tokens(&mut table);
1966        migrate_legacy_model_profiles(&mut table);
1967        let (config, _) = finalize_config(table).unwrap();
1968        assert_eq!(config.default_model.max_tokens, 8192);
1969
1970        // A config without the key is untouched (stays the 0 default).
1971        let mut table = toml::Table::new();
1972        migrate_legacy_max_tokens(&mut table);
1973        migrate_legacy_model_profiles(&mut table);
1974        let (config, _) = finalize_config(table).unwrap();
1975        assert_eq!(config.default_model.max_tokens, 0);
1976    }
1977
1978    #[test]
1979    fn legacy_model_profiles_table_migrates_to_model_aliases() {
1980        // Loads stop warning immediately...
1981        let mut table: toml::Table =
1982            toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
1983        migrate_legacy_model_profiles(&mut table);
1984        let (config, ignored) = finalize_config(table).unwrap();
1985        assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
1986        assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
1987        // ...and a file with BOTH keeps the new table.
1988        let mut table: toml::Table =
1989            toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
1990                .unwrap();
1991        migrate_legacy_model_profiles(&mut table);
1992        let (config, ignored) = finalize_config(table).unwrap();
1993        assert_eq!(config.model_aliases["fast"], "new");
1994        assert!(ignored.is_empty());
1995        // ...and the persist path rewrites the key on disk.
1996        let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
1997        std::fs::create_dir_all(&dir).unwrap();
1998        let path = dir.join("config.toml");
1999        std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
2000        update_user_config_table_at(&path, |_| Ok(())).unwrap();
2001        let blob = std::fs::read_to_string(&path).unwrap();
2002        assert!(blob.contains("[model_aliases]"), "{blob}");
2003        assert!(!blob.contains("model_profiles"), "{blob}");
2004        let _ = std::fs::remove_dir_all(&dir);
2005    }
2006
2007    #[test]
2008    fn ui_theme_deserializes_defaults_and_rejects_typos() {
2009        let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
2010        assert_eq!(config.ui.theme, ThemeChoice::Light);
2011        // Absent → dark, both from an empty file and from Config::default().
2012        let config: Config = toml::from_str("").unwrap();
2013        assert_eq!(config.ui.theme, ThemeChoice::Dark);
2014        assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
2015        // Typos are a clear deserialize error, not a silent fallback.
2016        assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
2017    }
2018
2019    #[test]
2020    fn finalize_config_flags_unknown_keys() {
2021        let table: toml::Table =
2022            toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
2023                .unwrap();
2024        let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
2025        assert_eq!(config.default_model.max_tokens, 512);
2026        assert!(
2027            ignored.iter().any(|p| p == "unknown_top"),
2028            "got {ignored:?}"
2029        );
2030        assert!(
2031            ignored.iter().any(|p| p.contains("bogus")),
2032            "got {ignored:?}"
2033        );
2034    }
2035
2036    #[test]
2037    fn cli_overrides_beat_file_and_create_nested_tables() {
2038        // Override beats the file value...
2039        let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
2040        apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
2041        let (config, ignored) = finalize_config(table).unwrap();
2042        assert_eq!(config.default_model.max_tokens, 8192);
2043        assert!(ignored.is_empty());
2044        // ...and creates a section absent from the file.
2045        let mut empty = toml::Table::new();
2046        apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
2047        assert_eq!(
2048            finalize_config(empty).unwrap().0.default_model.max_tokens,
2049            256
2050        );
2051    }
2052
2053    #[test]
2054    fn parse_override_value_keeps_toml_types_with_string_fallback() {
2055        assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
2056        assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
2057        assert_eq!(
2058            parse_override_value("ollama/qwen"),
2059            toml::Value::String("ollama/qwen".to_string())
2060        );
2061    }
2062
2063    #[test]
2064    fn cli_override_invalid_format_errors() {
2065        let mut table = toml::Table::new();
2066        assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
2067        assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
2068    }
2069
2070    #[test]
2071    fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
2072        let mut base: toml::Table = toml::from_str(
2073            "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
2074        )
2075        .unwrap();
2076        let overlay: toml::Table =
2077            toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
2078                .unwrap();
2079        deep_merge(&mut base, overlay);
2080        // Sibling keys inside a merged table survive...
2081        assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
2082        // ...the overlaid scalar wins...
2083        assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
2084        // ...arrays replace wholesale (no concat)...
2085        assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
2086        // ...and untouched top-level keys survive.
2087        assert_eq!(base["top"].as_integer(), Some(1));
2088    }
2089
2090    #[test]
2091    fn deep_merge_overlay_wins_on_kind_conflict() {
2092        // Scalar over table and table over scalar both resolve to the overlay.
2093        let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
2094        let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
2095        deep_merge(&mut base, overlay);
2096        assert_eq!(base["a"].as_integer(), Some(5));
2097        assert_eq!(base["b"]["y"].as_integer(), Some(3));
2098    }
2099
2100    #[test]
2101    fn merge_layers_precedence_and_layer_attributed_warnings() {
2102        let user: toml::Table = toml::from_str(
2103            "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
2104        )
2105        .unwrap();
2106        let session: toml::Table =
2107            toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
2108        let (config, warnings) = merge_layers(vec![
2109            LayerSource {
2110                layer: ConfigLayer::User,
2111                origin: "/tmp/user.toml".to_string(),
2112                table: user,
2113            },
2114            LayerSource {
2115                layer: ConfigLayer::Session,
2116                origin: "command line".to_string(),
2117                table: session,
2118            },
2119        ])
2120        .expect("merges");
2121        // Later layer wins; earlier layer's untouched keys survive.
2122        assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
2123        assert_eq!(config.default_model.max_tokens, 100);
2124        // Each unknown key names its own layer + origin.
2125        assert!(
2126            warnings
2127                .iter()
2128                .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
2129            "got {warnings:?}"
2130        );
2131        assert!(
2132            warnings
2133                .iter()
2134                .any(|w| w.contains("session_typo") && w.contains("session flags")),
2135            "got {warnings:?}"
2136        );
2137    }
2138
2139    #[test]
2140    fn take_profiles_excises_and_tolerates_absence() {
2141        let mut table: toml::Table =
2142            toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
2143        let profiles = take_profiles(&mut table);
2144        assert!(table.is_empty(), "profiles must be excised: {table:?}");
2145        assert!(profiles.contains_key("fast"));
2146        // Absent -> empty, table untouched.
2147        let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
2148        assert!(take_profiles(&mut table).is_empty());
2149        assert_eq!(table.len(), 1);
2150        // Malformed (non-table) -> dropped, empty result.
2151        let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
2152        assert!(take_profiles(&mut table).is_empty());
2153        assert!(table.is_empty());
2154    }
2155
2156    #[test]
2157    fn resolve_profile_layer_errors_name_available_profiles() {
2158        let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
2159        let path = std::path::Path::new("/tmp/config.toml");
2160        let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
2161        assert!(err.to_string().contains("available: fast, work"), "{err}");
2162        // No profiles at all -> a distinct, actionable error.
2163        let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
2164        assert!(
2165            err.to_string().contains("no config profiles defined"),
2166            "{err}"
2167        );
2168        // Non-table profile value -> hard error.
2169        let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
2170        let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
2171        assert!(err.to_string().contains("not a table"), "{err}");
2172        // Hit -> Profile layer with attributing origin.
2173        let profiles: toml::Table =
2174            toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
2175        let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
2176        assert_eq!(layer.layer, ConfigLayer::Profile);
2177        assert!(layer.origin.contains("profile:work"));
2178    }
2179
2180    #[test]
2181    fn profile_layer_beats_user_loses_to_project_and_session() {
2182        let user: toml::Table = toml::from_str(
2183            "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
2184        )
2185        .unwrap();
2186        let profile: toml::Table = toml::from_str(
2187            "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
2188        )
2189        .unwrap();
2190        let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
2191        let session: toml::Table =
2192            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2193        let (config, warnings) = merge_layers(vec![
2194            LayerSource {
2195                layer: ConfigLayer::User,
2196                origin: "/tmp/user.toml".to_string(),
2197                table: user,
2198            },
2199            LayerSource {
2200                layer: ConfigLayer::Profile,
2201                origin: "profile:work (/tmp/user.toml)".to_string(),
2202                table: profile,
2203            },
2204            LayerSource {
2205                layer: ConfigLayer::Project,
2206                origin: "/repo/.mermaid/config.toml".to_string(),
2207                table: project,
2208            },
2209            LayerSource {
2210                layer: ConfigLayer::Session,
2211                origin: "command line".to_string(),
2212                table: session,
2213            },
2214        ])
2215        .expect("merges");
2216        // Project beats profile; session beats everything; profile beats user
2217        // where later layers are silent.
2218        assert_eq!(config.default_model.temperature, 0.5);
2219        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2220        assert_eq!(config.default_model.max_tokens, 100);
2221        // Unknown keys inside the profile attribute to it.
2222        assert!(
2223            warnings.iter().any(|w| w.contains("profile_typo")
2224                && w.contains("config profile (profile:work (/tmp/user.toml))")),
2225            "got {warnings:?}"
2226        );
2227    }
2228
2229    #[test]
2230    fn persists_never_touch_profile_tables() {
2231        let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
2232        std::fs::create_dir_all(&dir).expect("create temp dir");
2233        let path = dir.join("config.toml");
2234        std::fs::write(
2235            &path,
2236            "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
2237        )
2238        .expect("seed");
2239
2240        update_user_config_table_at(&path, |table| {
2241            deep_set_segments(
2242                table,
2243                &["safety", "mode"],
2244                toml::Value::String("auto".to_string()),
2245            )
2246        })
2247        .expect("persist");
2248
2249        let table: toml::Table =
2250            toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
2251        assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
2252        // The overlay table survives persists byte-for-byte semantically.
2253        assert_eq!(
2254            table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
2255            Some(0.1)
2256        );
2257        let _ = std::fs::remove_dir_all(&dir);
2258    }
2259
2260    #[test]
2261    fn session_flags_table_maps_each_flag() {
2262        let flags = SessionFlags {
2263            overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
2264            deny_network: true,
2265            confine_fs: true,
2266            max_tokens: Some(512),
2267            allow_untrusted_tools: true,
2268            profile: None,
2269        };
2270        let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2271        assert_eq!(config.safety.network, NetworkPolicy::Deny);
2272        assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
2273        assert_eq!(config.default_model.max_tokens, 512);
2274        assert!(config.safety.allow_untrusted_headless_tools);
2275        assert_eq!(config.web.searxng_url, "http://x:1");
2276    }
2277
2278    #[test]
2279    fn session_dedicated_flags_beat_dash_c() {
2280        // `--no-network` wins over a contradictory `-c safety.network=allow`
2281        // (the dedicated flags deep-set after the -c overrides).
2282        let flags = SessionFlags {
2283            overrides: vec!["safety.network=allow".to_string()],
2284            deny_network: true,
2285            ..Default::default()
2286        };
2287        let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2288        assert_eq!(config.safety.network, NetworkPolicy::Deny);
2289    }
2290
2291    #[test]
2292    fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
2293        // A layer that doesn't deserialize on its own contributes no warnings…
2294        let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
2295        let mut warnings = Vec::new();
2296        collect_layer_warnings(
2297            &LayerSource {
2298                layer: ConfigLayer::User,
2299                origin: "x".to_string(),
2300                table: bad.clone(),
2301            },
2302            &mut warnings,
2303        );
2304        assert!(warnings.is_empty());
2305        // …and the merged deserialize is what errors…
2306        assert!(
2307            merge_layers(vec![LayerSource {
2308                layer: ConfigLayer::User,
2309                origin: "x".to_string(),
2310                table: bad.clone(),
2311            }])
2312            .is_err()
2313        );
2314        // …unless a later layer fixes the value (session repairing a bad file).
2315        let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
2316        let (config, _) = merge_layers(vec![
2317            LayerSource {
2318                layer: ConfigLayer::User,
2319                origin: "x".to_string(),
2320                table: bad,
2321            },
2322            LayerSource {
2323                layer: ConfigLayer::Session,
2324                origin: "command line".to_string(),
2325                table: fix,
2326            },
2327        ])
2328        .expect("later layer repairs the earlier one");
2329        assert_eq!(config.safety.mode, SafetyMode::Ask);
2330    }
2331
2332    #[test]
2333    fn project_layer_beats_user_and_loses_to_session() {
2334        let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
2335        let project: toml::Table = toml::from_str(
2336            "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
2337        )
2338        .unwrap();
2339        let session: toml::Table =
2340            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2341        let (config, _) = merge_layers(vec![
2342            LayerSource {
2343                layer: ConfigLayer::User,
2344                origin: "user".to_string(),
2345                table: user,
2346            },
2347            LayerSource {
2348                layer: ConfigLayer::Project,
2349                origin: "project".to_string(),
2350                table: project,
2351            },
2352            LayerSource {
2353                layer: ConfigLayer::Session,
2354                origin: "command line".to_string(),
2355                table: session,
2356            },
2357        ])
2358        .expect("merges");
2359        // Session beats project beats user for the contested key…
2360        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2361        // …while the project's uncontested key lands.
2362        assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
2363    }
2364
2365    #[test]
2366    fn session_flags_survive_corrupt_user_layer_fallback() {
2367        // The or_warn fallback re-applies the session flags over bare defaults;
2368        // pin the exact expression it uses.
2369        let flags = SessionFlags {
2370            deny_network: true,
2371            ..Default::default()
2372        };
2373        let config = flags
2374            .to_table()
2375            .ok()
2376            .and_then(|table| finalize_config(table).ok())
2377            .map(|(config, _)| config)
2378            .unwrap_or_default();
2379        assert_eq!(config.safety.network, NetworkPolicy::Deny);
2380    }
2381
2382    #[test]
2383    fn deep_set_segments_addresses_keys_containing_dots() {
2384        // A model id with dots must be ONE key, which dotted parsing cannot
2385        // express — the latent bug the segment API fixes.
2386        let mut table = toml::Table::new();
2387        deep_set_segments(
2388            &mut table,
2389            &["reasoning_per_model", "gemini/gemini-2.5-pro"],
2390            toml::Value::String("high".to_string()),
2391        )
2392        .unwrap();
2393        let (config, ignored) = finalize_config(table).unwrap();
2394        assert!(ignored.is_empty(), "got {ignored:?}");
2395        assert_eq!(
2396            config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
2397            Some(&ReasoningLevel::High)
2398        );
2399    }
2400
2401    #[test]
2402    fn deep_remove_segments_removes_leaf_only() {
2403        let mut table: toml::Table =
2404            toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
2405                .unwrap();
2406        assert!(deep_remove_segments(
2407            &mut table,
2408            &["ollama_num_ctx_per_model", "ollama/a"]
2409        ));
2410        // Sibling survives; parent table survives; missing keys report false.
2411        assert_eq!(
2412            table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
2413            Some(2)
2414        );
2415        assert!(!deep_remove_segments(
2416            &mut table,
2417            &["ollama_num_ctx_per_model", "ollama/a"]
2418        ));
2419        assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
2420    }
2421
2422    #[test]
2423    fn update_user_config_table_preserves_unknown_keys() {
2424        let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
2425        std::fs::create_dir_all(&dir).expect("create temp dir");
2426        let path = dir.join("config.toml");
2427        // A file with an unknown key (maybe from a newer mermaid) and one known
2428        // setting the persist must not disturb.
2429        std::fs::write(
2430            &path,
2431            "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
2432        )
2433        .expect("seed");
2434
2435        update_user_config_table_at(&path, |table| {
2436            deep_set_segments(
2437                table,
2438                &["last_used_model"],
2439                toml::Value::String("ollama/new".to_string()),
2440            )
2441        })
2442        .expect("persist");
2443
2444        let blob = std::fs::read_to_string(&path).expect("read back");
2445        let table: toml::Table = toml::from_str(&blob).expect("parse back");
2446        // The targeted key changed…
2447        assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
2448        // …the unknown key survived (typed round-trips would have dropped it)…
2449        assert_eq!(table["future_key"].as_str(), Some("kept"));
2450        // …and no defaults were frozen in (only the keys that were there).
2451        assert!(!blob.contains("safety"), "defaults must not be frozen in");
2452        assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
2453
2454        let _ = std::fs::remove_dir_all(&dir);
2455    }
2456
2457    #[test]
2458    fn mcp_tool_allowed_honors_enabled_and_disabled() {
2459        // Default (both empty) allows everything.
2460        let cfg = McpServerConfig::default();
2461        assert!(cfg.tool_allowed("anything"));
2462        // enabled_tools acts as an allowlist.
2463        let cfg = McpServerConfig {
2464            enabled_tools: vec!["read".into(), "search".into()],
2465            ..Default::default()
2466        };
2467        assert!(cfg.tool_allowed("read"));
2468        assert!(!cfg.tool_allowed("write"));
2469        // disabled_tools wins over enabled_tools.
2470        let cfg = McpServerConfig {
2471            enabled_tools: vec!["read".into(), "write".into()],
2472            disabled_tools: vec!["write".into()],
2473            ..Default::default()
2474        };
2475        assert!(cfg.tool_allowed("read"));
2476        assert!(!cfg.tool_allowed("write"));
2477    }
2478
2479    #[test]
2480    fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
2481        // command-only → stdio.
2482        let cfg = McpServerConfig {
2483            command: "npx".to_string(),
2484            ..Default::default()
2485        };
2486        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
2487        // url-only → http.
2488        let cfg = McpServerConfig {
2489            url: Some("https://example.com/mcp".to_string()),
2490            ..Default::default()
2491        };
2492        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
2493        // Both set → error.
2494        let cfg = McpServerConfig {
2495            command: "npx".to_string(),
2496            url: Some("https://example.com/mcp".to_string()),
2497            ..Default::default()
2498        };
2499        assert!(
2500            cfg.transport_kind()
2501                .unwrap_err()
2502                .to_string()
2503                .contains("mutually exclusive")
2504        );
2505        // Neither set → error.
2506        let cfg = McpServerConfig::default();
2507        assert!(
2508            cfg.transport_kind()
2509                .unwrap_err()
2510                .to_string()
2511                .contains("neither")
2512        );
2513    }
2514
2515    #[test]
2516    fn mcp_transport_kind_gates_url_scheme() {
2517        let with_url = |url: &str| McpServerConfig {
2518            url: Some(url.to_string()),
2519            ..Default::default()
2520        };
2521        // https anywhere is fine; http only to loopback (plaintext to a
2522        // routable host would leak auth headers).
2523        assert!(
2524            with_url("https://mcp.example.com/x")
2525                .transport_kind()
2526                .is_ok()
2527        );
2528        assert!(
2529            with_url("http://localhost:8080/mcp")
2530                .transport_kind()
2531                .is_ok()
2532        );
2533        assert!(
2534            with_url("http://127.0.0.1:8080/mcp")
2535                .transport_kind()
2536                .is_ok()
2537        );
2538        assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
2539        assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
2540        assert!(with_url("not a url").transport_kind().is_err());
2541    }
2542
2543    #[test]
2544    fn mcp_server_config_debug_masks_header_values() {
2545        let mut headers = HashMap::new();
2546        headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
2547        let mut env_headers = HashMap::new();
2548        env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
2549        let cfg = McpServerConfig {
2550            url: Some("https://example.com/mcp".to_string()),
2551            headers,
2552            env_headers,
2553            ..Default::default()
2554        };
2555        let rendered = format!("{cfg:?}");
2556        assert!(!rendered.contains("sk-secret"), "{rendered}");
2557        assert!(rendered.contains("Authorization"), "{rendered}");
2558        // env_headers values are env var NAMES, safe to render.
2559        assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
2560    }
2561
2562    #[test]
2563    fn mcp_url_config_round_trips_through_toml_without_command() {
2564        // `mermaid add --url` persists via toml::Value::try_from; a bare None
2565        // url or a forced empty `command` key would break that round-trip.
2566        let cfg = McpServerConfig {
2567            url: Some("https://example.com/mcp".to_string()),
2568            ..Default::default()
2569        };
2570        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2571        assert!(
2572            !blob.contains("command"),
2573            "empty command must be omitted: {blob}"
2574        );
2575        let back: McpServerConfig = toml::from_str(&blob).unwrap();
2576        assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
2577        assert!(back.command.is_empty());
2578        // And a stdio config must not serialize a `url` key at all.
2579        let cfg = McpServerConfig {
2580            command: "npx".to_string(),
2581            ..Default::default()
2582        };
2583        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2584        assert!(!blob.contains("url"), "{blob}");
2585    }
2586
2587    /// Configs persisted before Step 4 don't have a `reasoning` field on
2588    /// `[default_model]`. Loading them must succeed and yield the
2589    /// `Medium` default — otherwise existing user configs break on
2590    /// upgrade.
2591    #[test]
2592    fn model_settings_deserializes_without_reasoning_field() {
2593        let toml_blob = r#"
2594            provider = "ollama"
2595            name = "qwen3-coder:30b"
2596            temperature = 0.7
2597            max_tokens = 4096
2598        "#;
2599        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
2600        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
2601        assert_eq!(settings.provider, "ollama");
2602    }
2603
2604    #[test]
2605    fn model_settings_round_trips_reasoning_high() {
2606        let original = ModelSettings {
2607            provider: "anthropic".to_string(),
2608            name: "claude-sonnet-4-6".to_string(),
2609            temperature: 0.5,
2610            max_tokens: 8192,
2611            reasoning: ReasoningLevel::High,
2612        };
2613        let toml_blob = toml::to_string(&original).expect("serialize");
2614        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
2615        assert_eq!(back.reasoning, ReasoningLevel::High);
2616        assert_eq!(back.name, "claude-sonnet-4-6");
2617    }
2618
2619    #[test]
2620    fn agents_config_defaults_and_parses_custom_types() {
2621        // Absent section → defaults (20-minute timeout, no custom types).
2622        let config: Config = toml::from_str("").expect("empty config parses");
2623        assert_eq!(config.agents.timeout_secs, 1200);
2624        assert!(config.agents.types.is_empty());
2625
2626        let config: Config = toml::from_str(
2627            r#"
2628[agents]
2629timeout_secs = 300
2630
2631[agents.types.scout]
2632tools = ["read_file", "execute_command"]
2633safety = "read_only"
2634preamble = "You are a scout."
2635model = "ollama/qwen3:8b"
2636"#,
2637        )
2638        .expect("agents section parses");
2639        assert_eq!(config.agents.timeout_secs, 300);
2640        let scout = &config.agents.types["scout"];
2641        assert_eq!(
2642            scout.tools.as_deref(),
2643            Some(&["read_file".to_string(), "execute_command".to_string()][..])
2644        );
2645        assert_eq!(scout.safety.as_deref(), Some("read_only"));
2646        assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
2647    }
2648
2649    #[test]
2650    fn configured_model_alias_resolves_explicit_prefix() {
2651        let mut config = Config::default();
2652        config
2653            .model_aliases
2654            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
2655        assert_eq!(
2656            resolve_model_alias("fast", &config).unwrap(),
2657            Some("ollama/qwen3-coder:14b".to_string())
2658        );
2659        assert_eq!(
2660            resolve_model_alias("alias:fast", &config).unwrap(),
2661            Some("ollama/qwen3-coder:14b".to_string())
2662        );
2663    }
2664
2665    #[test]
2666    fn alias_prefix_requires_configuration() {
2667        let config = Config::default();
2668        assert!(resolve_model_alias("alias:vision", &config).is_err());
2669        assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
2670    }
2671
2672    /// `persist_default_reasoning` writes to the real config path, so
2673    /// this test goes through `save_config(_, Some(path))` directly to
2674    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
2675    /// Uses `std::env::temp_dir` (matching the pattern in
2676    /// `session::conversation` and `utils::logger`) — no external
2677    /// `tempfile` crate dependency.
2678    #[test]
2679    fn save_and_reload_preserves_reasoning_field() {
2680        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
2681        std::fs::create_dir_all(&dir).expect("create temp dir");
2682        let path = dir.join("config.toml");
2683
2684        let mut cfg = Config::default();
2685        cfg.default_model.provider = "ollama".to_string();
2686        cfg.default_model.name = "qwen3-coder:30b".to_string();
2687        cfg.default_model.reasoning = ReasoningLevel::Low;
2688
2689        save_config(&cfg, Some(path.clone())).expect("save");
2690
2691        let blob = std::fs::read_to_string(&path).expect("read");
2692        let loaded: Config = toml::from_str(&blob).expect("parse back");
2693        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
2694
2695        let _ = std::fs::remove_dir_all(&dir);
2696    }
2697
2698    /// Per-model entries serialize as a TOML table with quoted keys (the
2699    /// model IDs contain `/`). This test verifies the round-trip works
2700    /// through both serialization and deserialization, matching what
2701    /// `persist_reasoning_for_model` would produce in real use.
2702    #[test]
2703    fn save_and_reload_preserves_reasoning_per_model_table() {
2704        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
2705        std::fs::create_dir_all(&dir).expect("create temp dir");
2706        let path = dir.join("config.toml");
2707
2708        let mut cfg = Config::default();
2709        cfg.reasoning_per_model.insert(
2710            "anthropic/claude-sonnet-4-6".to_string(),
2711            ReasoningLevel::High,
2712        );
2713        cfg.reasoning_per_model
2714            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
2715
2716        save_config(&cfg, Some(path.clone())).expect("save");
2717
2718        let blob = std::fs::read_to_string(&path).expect("read");
2719        let loaded: Config = toml::from_str(&blob).expect("parse back");
2720        assert_eq!(
2721            loaded
2722                .reasoning_per_model
2723                .get("anthropic/claude-sonnet-4-6"),
2724            Some(&ReasoningLevel::High)
2725        );
2726        assert_eq!(
2727            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
2728            Some(&ReasoningLevel::Low)
2729        );
2730
2731        let _ = std::fs::remove_dir_all(&dir);
2732    }
2733
2734    /// `/context <n>` overrides round-trip through the per-model TOML table, and
2735    /// the offload toggle persists on `[ollama]`.
2736    #[test]
2737    fn save_and_reload_preserves_ollama_context_overrides() {
2738        let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
2739        std::fs::create_dir_all(&dir).expect("create temp dir");
2740        let path = dir.join("config.toml");
2741
2742        let mut cfg = Config::default();
2743        cfg.ollama_num_ctx_per_model
2744            .insert("ollama/ornith:9b".to_string(), 131_072);
2745        cfg.ollama.allow_ram_offload = true;
2746        cfg.ollama.max_auto_num_ctx = Some(65_536);
2747
2748        save_config(&cfg, Some(path.clone())).expect("save");
2749        let blob = std::fs::read_to_string(&path).expect("read");
2750        let loaded: Config = toml::from_str(&blob).expect("parse back");
2751
2752        assert_eq!(
2753            loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
2754            Some(&131_072)
2755        );
2756        assert!(loaded.ollama.allow_ram_offload);
2757        assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
2758
2759        let _ = std::fs::remove_dir_all(&dir);
2760    }
2761
2762    /// Older configs have neither the per-model num_ctx table nor the new
2763    /// `[ollama]` keys; loading must default cleanly (empty map, offload off).
2764    #[test]
2765    fn config_deserializes_without_ollama_context_keys() {
2766        let toml_blob = r#"
2767[ollama]
2768host = "localhost"
2769port = 11434
2770"#;
2771        let cfg: Config = toml::from_str(toml_blob).expect("parse");
2772        assert!(cfg.ollama_num_ctx_per_model.is_empty());
2773        assert!(!cfg.ollama.allow_ram_offload);
2774        assert_eq!(cfg.ollama.max_auto_num_ctx, None);
2775        // Configs from before the auto-start knob default it ON — reviving a
2776        // dead local server is the out-of-the-box behavior.
2777        assert!(cfg.ollama.auto_start);
2778    }
2779
2780    /// Configs from before Step 5b don't have a `reasoning_per_model`
2781    /// section. Loading them must succeed with an empty map — otherwise
2782    /// upgrade breaks every existing user.
2783    #[test]
2784    fn config_deserializes_without_reasoning_per_model() {
2785        let toml_blob = r#"
2786            last_used_model = "ollama/qwen3-coder:30b"
2787
2788            [default_model]
2789            provider = "ollama"
2790            name = "qwen3-coder:30b"
2791            temperature = 0.7
2792            max_tokens = 4096
2793        "#;
2794        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
2795        assert!(cfg.reasoning_per_model.is_empty());
2796        assert!(!cfg.prompt.is_customized());
2797    }
2798
2799    /// Config holds inline-secret-capable fields (`mcp_servers[].env`, `args`,
2800    /// `headers`, `providers[].extra_headers`), so it must be written
2801    /// owner-only rather than inheriting a world-readable umask.
2802    #[cfg(unix)]
2803    #[test]
2804    fn save_config_writes_owner_only_perms() {
2805        use std::os::unix::fs::PermissionsExt;
2806        let dir = std::env::temp_dir().join("mermaid_test_config_perms");
2807        std::fs::create_dir_all(&dir).expect("create temp dir");
2808        let path = dir.join("config.toml");
2809        // Pre-create a world-readable file to prove we also tighten existing.
2810        std::fs::write(&path, "stale").expect("seed");
2811        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
2812
2813        save_config(&Config::default(), Some(path.clone())).expect("save");
2814        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2815        assert_eq!(mode, 0o600, "config must be written owner-only");
2816
2817        let _ = std::fs::remove_dir_all(&dir);
2818    }
2819
2820    #[test]
2821    fn config_defaults_computer_use_auto_screenshot_on() {
2822        // An empty/legacy config must keep the auto-screenshot behavior (#98).
2823        let cfg: Config = toml::from_str("").expect("empty config");
2824        assert!(cfg.computer_use.auto_screenshot);
2825    }
2826
2827    #[test]
2828    fn prompt_config_replaces_and_appends_without_persisting() {
2829        let mut cfg = Config::default();
2830        cfg.prompt.system_prompt = Some("base".to_string());
2831        cfg.prompt
2832            .append_system_prompt
2833            .push("extra instructions".to_string());
2834
2835        assert_eq!(
2836            cfg.prompt.render_system_prompt("default"),
2837            "base\n\nextra instructions"
2838        );
2839
2840        let blob = toml::to_string(&cfg).expect("serialize");
2841        assert!(!blob.contains("extra instructions"));
2842        let loaded: Config = toml::from_str(&blob).expect("deserialize");
2843        assert!(!loaded.prompt.is_customized());
2844    }
2845
2846    /// An absent `[compaction]` section must reproduce the constants exactly —
2847    /// making the policy configurable must not change anyone's behavior.
2848    #[test]
2849    fn absent_compaction_section_matches_the_built_in_policy() {
2850        let c: Config = toml::from_str("").expect("empty config parses");
2851        assert_eq!(
2852            c.compaction.policy(),
2853            crate::domain::CompactionPolicy::default(),
2854        );
2855    }
2856
2857    #[test]
2858    fn compaction_settings_reach_the_policy() {
2859        let c: Config = toml::from_str(
2860            "[compaction]\n\
2861             auto_enabled = false\n\
2862             auto_threshold_percent = 60\n\
2863             tail_turns = 5\n\
2864             tail_token_budget = 12000\n\
2865             summary_max_tokens = 3000\n",
2866        )
2867        .expect("compaction section parses");
2868        let policy = c.compaction.policy();
2869        assert!(!policy.auto_enabled);
2870        assert_eq!(policy.auto_threshold_percent, 60);
2871        assert_eq!(policy.tail_turns, 5);
2872        assert_eq!(policy.tail_token_budget, 12_000);
2873        assert_eq!(policy.summary_max_tokens, 3_000);
2874        // Unset keys keep their defaults rather than zeroing out.
2875        let defaults = crate::domain::CompactionPolicy::default();
2876        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2877    }
2878
2879    /// A hand-edited config degrades to the nearest workable value rather than
2880    /// putting compaction in a state where it silently cannot run.
2881    #[test]
2882    fn nonsense_compaction_settings_are_clamped() {
2883        let c: Config = toml::from_str(
2884            "[compaction]\n\
2885             auto_threshold_percent = 250\n\
2886             tail_turns = 0\n\
2887             tail_token_budget = 0\n\
2888             summary_max_tokens = 0\n\
2889             summarizer_input_token_budget = 0\n\
2890             tool_output_max_chars = 0\n\
2891             min_response_reserve_tokens = 50000\n\
2892             max_response_reserve_tokens = 1000\n",
2893        )
2894        .expect("config parses");
2895        let policy = c.compaction.policy();
2896        let defaults = crate::domain::CompactionPolicy::default();
2897
2898        assert_eq!(policy.auto_threshold_percent, 100, "percent clamps to 100");
2899        assert_eq!(
2900            policy.tail_turns, 1,
2901            "a checkpoint needs a live turn after it"
2902        );
2903        // Zero would mean "no budget at all"; fall back rather than disable.
2904        assert_eq!(policy.tail_token_budget, defaults.tail_token_budget);
2905        assert_eq!(policy.summary_max_tokens, defaults.summary_max_tokens);
2906        assert_eq!(
2907            policy.summarizer_input_token_budget,
2908            defaults.summarizer_input_token_budget
2909        );
2910        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2911
2912        // Swapped reserve bounds are ordered, not obeyed: `response_reserve`
2913        // clamps with `.max(min).min(max)`, so an inverted pair would return
2914        // the smaller value and under-reserve on every single turn.
2915        assert_eq!(policy.min_response_reserve_tokens, 1_000);
2916        assert_eq!(policy.max_response_reserve_tokens, 50_000);
2917        assert!(policy.min_response_reserve_tokens <= policy.max_response_reserve_tokens);
2918    }
2919
2920    /// `auto_threshold_percent = 0` would compact on every single turn, before
2921    /// there is anything to compact.
2922    #[test]
2923    fn zero_compaction_threshold_clamps_up() {
2924        let c: Config =
2925            toml::from_str("[compaction]\nauto_threshold_percent = 0\n").expect("parses");
2926        assert_eq!(c.compaction.policy().auto_threshold_percent, 1);
2927    }
2928
2929    #[test]
2930    fn plan_config_defaults_parse_and_do_not_freeze() {
2931        // Absent section: dialog on, nothing pinned.
2932        let c: Config = toml::from_str("").expect("empty config parses");
2933        assert!(!c.plan.auto_approve);
2934        assert!(c.plan.post_approve.is_none());
2935        // Explicit values parse.
2936        let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
2937            .expect("plan section parses");
2938        assert!(c.plan.auto_approve);
2939        assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
2940        assert_eq!(
2941            toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
2942                .expect("wait parses")
2943                .plan
2944                .post_approve,
2945            Some(PlanPostApprove::Wait)
2946        );
2947        // The unset pin is never frozen into a saved config (Option +
2948        // skip_serializing_if), so a future default change still reaches
2949        // existing files.
2950        let blob = toml::to_string(&Config::default()).expect("serialize");
2951        assert!(!blob.contains("post_approve"));
2952    }
2953
2954    /// Config with one remote provider carrying an explicit `default_model`.
2955    fn config_with_provider_default(provider: &str, model: &str) -> Config {
2956        let mut config = Config::default();
2957        config.providers.insert(
2958            provider.to_string(),
2959            UserProviderConfig {
2960                default_model: Some(model.to_string()),
2961                ..Default::default()
2962            },
2963        );
2964        config
2965    }
2966
2967    /// The whole point of the Ollama-optional path: a machine whose only
2968    /// backend is Anthropic must resolve a model without Ollama in the picture.
2969    #[test]
2970    fn provider_default_model_resolves_without_ollama() {
2971        let config = config_with_provider_default("anthropic", "claude-x");
2972        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
2973            assert_eq!(
2974                configured_provider_default_model(&config).as_deref(),
2975                Some("anthropic/claude-x")
2976            );
2977        });
2978    }
2979
2980    /// An unconfigured provider's `default_model` is not a usable default —
2981    /// building it would fail on the missing key at the first request.
2982    #[test]
2983    fn provider_default_model_ignored_without_a_key() {
2984        let config = config_with_provider_default("anthropic", "claude-x");
2985        temp_env::with_vars([("ANTHROPIC_API_KEY", None::<&str>)], || {
2986            // The keyring is the machine's, so only assert the env-var half:
2987            // with no key in the environment there is nothing to prefer.
2988            if crate::utils::provider_key_source("anthropic", "ANTHROPIC_API_KEY", None) == "none" {
2989                assert_eq!(configured_provider_default_model(&config), None);
2990            }
2991        });
2992    }
2993
2994    /// OpenRouter ids are `vendor/model`, which must be prefixed once, not
2995    /// twice — and an id that already names its provider is left alone.
2996    #[test]
2997    fn provider_default_model_is_prefixed_exactly_once() {
2998        temp_env::with_vars([("OPENROUTER_API_KEY", Some("sk-test"))], || {
2999            let vendor_model = config_with_provider_default("openrouter", "z-ai/glm-5.2");
3000            assert_eq!(
3001                configured_provider_default_model(&vendor_model).as_deref(),
3002                Some("openrouter/z-ai/glm-5.2")
3003            );
3004            let already_prefixed =
3005                config_with_provider_default("openrouter", "openrouter/z-ai/glm-5.2");
3006            assert_eq!(
3007                configured_provider_default_model(&already_prefixed).as_deref(),
3008                Some("openrouter/z-ai/glm-5.2")
3009            );
3010        });
3011    }
3012
3013    /// The regression this replaced: startup used to end at "Ollama is not
3014    /// installed", which reads as "Mermaid needs Ollama". With a provider key
3015    /// present the message must be about naming a model, not about Ollama.
3016    #[test]
3017    fn missing_model_error_does_not_demand_ollama_when_a_provider_is_ready() {
3018        let config = Config::default();
3019        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
3020            let msg = no_model_configured_error(&config, false).to_string();
3021            assert!(msg.contains("anthropic"), "{msg}");
3022            assert!(msg.contains("mermaid --model anthropic/<model>"), "{msg}");
3023            assert!(msg.contains("[providers.anthropic]"), "{msg}");
3024            // Ollama may still be mentioned as the local option, but never as
3025            // a prerequisite for running Mermaid at all.
3026            assert!(!msg.contains("Ollama is not installed"), "{msg}");
3027        });
3028    }
3029
3030    /// Run `f` with every built-in provider's key env var unset, so a key in
3031    /// the developer's own shell can't change what the message says.
3032    fn with_no_provider_keys<T>(f: impl FnOnce() -> T) -> T {
3033        let cleared: Vec<(&str, Option<&str>)> = [
3034            crate::providers::model::anthropic::DEFAULT_API_KEY_ENV,
3035            crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
3036            crate::providers::model::gemini::LEGACY_API_KEY_ENV,
3037            crate::providers::model::meta::DEFAULT_API_KEY_ENV,
3038        ]
3039        .iter()
3040        .map(|env| (*env, None))
3041        .chain(
3042            crate::models::PROVIDER_REGISTRY
3043                .iter()
3044                .map(|profile| (profile.api_key_env, None)),
3045        )
3046        .collect();
3047        temp_env::with_vars(cleared, f)
3048    }
3049
3050    /// With nothing configured at all, both routes are offered — the remote
3051    /// one first, since it needs no install.
3052    #[test]
3053    fn missing_model_error_offers_both_routes_when_nothing_is_configured() {
3054        with_no_provider_keys(|| {
3055            let msg = no_model_configured_error(&Config::default(), false).to_string();
3056            assert!(msg.contains("https://ollama.com/download"), "{msg}");
3057            // A keyring login would legitimately name a provider instead; only
3058            // assert the no-provider wording when there really is none.
3059            if !msg.contains("Remote providers ready") {
3060                assert!(msg.contains("ANTHROPIC_API_KEY"), "{msg}");
3061            }
3062        });
3063    }
3064
3065    /// End-to-end through `resolve_model_id` itself: nothing pinned, no local
3066    /// model reachable, one configured provider — Mermaid starts on that
3067    /// provider instead of erroring out about Ollama.
3068    #[test]
3069    fn resolve_model_id_falls_back_to_a_configured_provider() {
3070        let mut config = config_with_provider_default("anthropic", "claude-x");
3071        // Point at a dead port with autostart off, so "no local model" holds
3072        // whether or not this machine has Ollama installed.
3073        config.ollama.host = "http://127.0.0.1".to_string();
3074        config.ollama.port = 1;
3075        config.ollama.auto_start = false;
3076        temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
3077            let runtime = tokio::runtime::Runtime::new().expect("runtime");
3078            let resolved = runtime
3079                .block_on(resolve_model_id(None, &config))
3080                .expect("a configured provider is enough to resolve a model");
3081            assert_eq!(resolved, "anthropic/claude-x");
3082        });
3083    }
3084
3085    /// An installed-but-empty Ollama needs a pull, not another install.
3086    #[test]
3087    fn missing_model_error_says_pull_when_ollama_is_installed() {
3088        let msg = no_model_configured_error(&Config::default(), true).to_string();
3089        assert!(msg.contains("ollama pull qwen3:8b"), "{msg}");
3090        assert!(!msg.contains("https://ollama.com/download"), "{msg}");
3091    }
3092}