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}
744
745/// User-supplied remote provider configuration. All fields are optional for a
746/// built-in provider; fully custom OpenAI-compatible providers require a base
747/// URL and API-key environment variable.
748#[derive(Clone, Default, Serialize, Deserialize)]
749pub struct UserProviderConfig {
750    /// Override the provider API base URL (None = built-in default; required
751    /// for fully custom providers).
752    #[serde(default)]
753    pub base_url: Option<String>,
754    /// Env var name to read the API key from (None = use the built-in
755    /// registry default like `GROQ_API_KEY`; required for fully custom
756    /// providers).
757    #[serde(default)]
758    pub api_key_env: Option<String>,
759    /// Extra HTTP headers sent on every request to this provider.
760    #[serde(default)]
761    pub extra_headers: HashMap<String, String>,
762    /// Extra HTTP headers whose VALUES come from environment variables
763    /// (map is header name -> env var name), resolved at request-build time so
764    /// a secret header (e.g. a gateway token) never has to live in config.toml.
765    /// A missing env var is skipped.
766    #[serde(default)]
767    pub env_headers: HashMap<String, String>,
768    /// For fully custom providers (no built-in registry entry), declares
769    /// which OpenAI-compatible shape the endpoint speaks. Ignored when
770    /// the provider name matches a built-in registry entry. Values:
771    /// `"openai"` (no reasoning), `"openai-effort"` (`reasoning_effort`
772    /// field), `"openrouter"` (nested `reasoning: {effort}` object).
773    #[serde(default)]
774    pub compat: Option<String>,
775    /// Optional preferred model — surfaced by `mermaid status` and used
776    /// as the default when the user picks this provider with no model
777    /// suffix.
778    #[serde(default)]
779    pub default_model: Option<String>,
780}
781
782/// MCP server configuration
783#[derive(Clone, Default, Serialize, Deserialize)]
784pub struct McpServerConfig {
785    /// Command to execute (e.g., "npx", "node", "python"). Empty = unset;
786    /// exactly one of `command` / `url` must be set (see [`Self::transport_kind`]).
787    #[serde(default, skip_serializing_if = "String::is_empty")]
788    pub command: String,
789    /// Command-line arguments
790    #[serde(default)]
791    pub args: Vec<String>,
792    /// Environment variables for the server process
793    #[serde(default)]
794    pub env: HashMap<String, String>,
795    /// Streamable HTTP endpoint URL for a remote MCP server. Presence selects
796    /// the HTTP transport; mutually exclusive with `command`. Must never
797    /// serialize as a bare `None` — toml errors on unsupported None values.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub url: Option<String>,
800    /// Literal HTTP headers sent on every request to `url` (e.g. an
801    /// `Authorization` token). Values are secrets: redacted in `Debug`.
802    #[serde(default)]
803    pub headers: HashMap<String, String>,
804    /// HTTP headers whose VALUES come from environment variables (map is
805    /// header name -> env var name), resolved at request-build time so a
806    /// secret header never has to live in config.toml. A missing env var is
807    /// skipped. Same semantics as `UserProviderConfig::env_headers`.
808    #[serde(default)]
809    pub env_headers: HashMap<String, String>,
810    /// Allow `url` to resolve to private/link-local addresses. Off by default:
811    /// plugin bundles ship MCP configs, and a malicious bundle must not be
812    /// able to point a server entry at 169.254.169.254 or the LAN.
813    #[serde(default)]
814    pub allow_private_network: bool,
815    /// If non-empty, only these tool names are exposed to the model.
816    #[serde(default)]
817    pub enabled_tools: Vec<String>,
818    /// Tool names hidden from the model. Takes precedence over `enabled_tools`.
819    #[serde(default)]
820    pub disabled_tools: Vec<String>,
821    /// Per-server deferral override: `Some(false)` always advertises this
822    /// server's tools directly (skips `tool_search`); `Some(true)` defers
823    /// even when the global `mcp_defer_tools` is off; `None` follows the
824    /// global setting.
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub defer: Option<bool>,
827}
828
829/// Which transport an [`McpServerConfig`] selects: a spawned child process
830/// (stdio) or a remote Streamable HTTP endpoint.
831#[derive(Debug, Clone, Copy, PartialEq, Eq)]
832pub enum TransportKind {
833    Stdio,
834    Http,
835}
836
837impl McpServerConfig {
838    /// Resolve which transport this config selects, enforcing the invariants:
839    /// exactly one of `command` / `url` set, and an HTTP url must be `https`
840    /// anywhere or `http` to a loopback host only (plaintext to a routable
841    /// host would leak `Authorization` headers in cleartext).
842    pub fn transport_kind(&self) -> Result<TransportKind> {
843        match (&self.url, self.command.is_empty()) {
844            (Some(_), false) => Err(anyhow::anyhow!(
845                "MCP server config sets both `command` and `url`; they are mutually exclusive"
846            )),
847            (None, true) => Err(anyhow::anyhow!(
848                "MCP server config sets neither `command` nor `url`"
849            )),
850            (None, false) => Ok(TransportKind::Stdio),
851            (Some(url), true) => {
852                let parsed = reqwest::Url::parse(url)
853                    .map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
854                let host = parsed.host_str().unwrap_or("");
855                match parsed.scheme() {
856                    "https" => Ok(TransportKind::Http),
857                    "http" if crate::utils::classify_host(host).is_loopback() => {
858                        Ok(TransportKind::Http)
859                    },
860                    "http" => Err(anyhow::anyhow!(
861                        "MCP server url '{url}' uses plaintext http to a non-loopback host; \
862                         use https (auth headers would travel in cleartext)"
863                    )),
864                    other => Err(anyhow::anyhow!(
865                        "MCP server url '{url}' has unsupported scheme '{other}' \
866                         (expected https, or http to loopback)"
867                    )),
868                }
869            },
870        }
871    }
872
873    /// Whether `tool_name` should be exposed to the model: hidden when listed in
874    /// `disabled_tools` (which wins), else allowed when `enabled_tools` is empty
875    /// (allow-all) or names it.
876    pub fn tool_allowed(&self, tool_name: &str) -> bool {
877        if self.disabled_tools.iter().any(|t| t == tool_name) {
878            return false;
879        }
880        self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
881    }
882}
883
884/// Mask a header/env map for `Debug`: keys are kept (so you can still see which
885/// vars are set) but values are never rendered — they hold secrets like API keys
886/// and `Authorization` tokens (#F12). A `BTreeMap` keeps the output deterministic.
887fn debug_masked_map(
888    map: &HashMap<String, String>,
889) -> std::collections::BTreeMap<&str, &'static str> {
890    map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
891}
892
893// Manual `Debug` for the secret-bearing config structs so a `{:?}` (into
894// tracing, a panic, or an error) cannot dump provider keys / Authorization
895// headers / MCP env secrets. `Config` keeps its derived `Debug`, which now
896// recurses through these redacting impls (#F12).
897impl std::fmt::Debug for McpServerConfig {
898    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
899        f.debug_struct("McpServerConfig")
900            .field("command", &self.command)
901            // args may carry an inline secret (e.g. `--api-key=sk-...`).
902            .field(
903                "args",
904                &self
905                    .args
906                    .iter()
907                    .map(|a| crate::utils::redact_secrets(a))
908                    .collect::<Vec<_>>(),
909            )
910            .field("env", &debug_masked_map(&self.env))
911            .field("url", &self.url)
912            // Literal header values are secrets (Authorization tokens).
913            .field("headers", &debug_masked_map(&self.headers))
914            // Values are env var NAMES (not secrets), so render them.
915            .field("env_headers", &self.env_headers)
916            .field("allow_private_network", &self.allow_private_network)
917            // Tool allow/deny lists are plain tool names, not secrets.
918            .field("enabled_tools", &self.enabled_tools)
919            .field("disabled_tools", &self.disabled_tools)
920            .finish()
921    }
922}
923
924impl std::fmt::Debug for UserProviderConfig {
925    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
926        f.debug_struct("UserProviderConfig")
927            .field("base_url", &self.base_url)
928            .field("api_key_env", &self.api_key_env)
929            .field("extra_headers", &debug_masked_map(&self.extra_headers))
930            // Values are env var NAMES (not secrets), so render them.
931            .field("env_headers", &self.env_headers)
932            .field("compat", &self.compat)
933            .field("default_model", &self.default_model)
934            .finish()
935    }
936}
937
938/// Default model settings
939#[derive(Debug, Clone, Serialize, Deserialize)]
940#[serde(default)]
941pub struct ModelSettings {
942    /// Model provider (ollama, openai, anthropic)
943    pub provider: String,
944    /// Model name
945    pub name: String,
946    /// Temperature for generation
947    pub temperature: f32,
948    /// Maximum tokens to generate
949    pub max_tokens: usize,
950    /// Default reasoning depth used for new sessions when no `--reasoning`
951    /// flag is given. Each adapter snaps this onto the closest level the
952    /// model actually supports via `nearest_effort()`.
953    pub reasoning: ReasoningLevel,
954}
955
956impl Default for ModelSettings {
957    fn default() -> Self {
958        Self {
959            provider: String::new(),
960            name: String::new(),
961            temperature: DEFAULT_TEMPERATURE,
962            // 0 = AUTO: the model-scaled output budget (adapters omit the cap so
963            // the provider decides, or size it to the context window). A positive
964            // value set by the user is an explicit hard cap.
965            max_tokens: 0,
966            reasoning: ReasoningLevel::default(),
967        }
968    }
969}
970
971/// Ollama configuration
972#[derive(Debug, Clone, Serialize, Deserialize)]
973#[serde(default)]
974pub struct OllamaConfig {
975    /// Ollama server host
976    pub host: String,
977    /// Ollama server port
978    pub port: u16,
979    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
980    /// Lower values free up VRAM for larger models at the cost of speed
981    pub num_gpu: Option<i32>,
982    /// Number of CPU threads for processing offloaded layers
983    /// Higher values improve CPU inference speed for large models
984    pub num_thread: Option<i32>,
985    /// Context window size (number of tokens)
986    /// Larger values allow longer conversations but use more memory
987    pub num_ctx: Option<i32>,
988    /// Enable NUMA optimization for multi-CPU systems
989    pub numa: Option<bool>,
990    /// Allow Ollama to offload the model/KV cache to system RAM when it doesn't
991    /// fit VRAM. **Disabled by default**: RAM offload is 5–20× slower, so by
992    /// default Mermaid auto-fits `num_ctx` to VRAM (keeping the model on the
993    /// GPU). Enable to trade speed for a larger context window. Toggle in-app
994    /// with `/context offload on|off`.
995    pub allow_ram_offload: bool,
996    /// Optional hard cap on the auto-fitted context window (in tokens). `None`
997    /// lets auto-fit use the full memory budget up to the model's max; set this
998    /// to bound it (e.g. to leave VRAM headroom for other apps).
999    pub max_auto_num_ctx: Option<usize>,
1000    /// Start `ollama serve` automatically when the configured server is local
1001    /// (loopback) and not running — the user should never have to leave
1002    /// mermaid to start Ollama. Disable if you manage the server yourself
1003    /// (e.g. systemd with custom flags). Never applies to remote hosts.
1004    pub auto_start: bool,
1005}
1006
1007impl Default for OllamaConfig {
1008    fn default() -> Self {
1009        Self {
1010            host: String::from("localhost"),
1011            port: DEFAULT_OLLAMA_PORT,
1012            num_gpu: None,            // Let Ollama auto-detect
1013            num_thread: None,         // Let Ollama auto-detect
1014            num_ctx: None,            // Use model default (overrides auto-fit)
1015            numa: None,               // Auto-detect
1016            allow_ram_offload: false, // VRAM-only by default (RAM is slow)
1017            max_auto_num_ctx: None,   // No cap; auto-fit to the memory budget
1018            auto_start: true,         // A dead local server is mermaid's problem
1019        }
1020    }
1021}
1022
1023/// Backend for the `web_fetch` tool.
1024#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1025#[serde(rename_all = "lowercase")]
1026pub enum FetchBackend {
1027    /// Fetch the URL directly from this machine and convert it to markdown.
1028    /// No API key, no third party — works for any user with network access.
1029    #[default]
1030    Native,
1031    /// Route through Ollama Cloud's `/api/web_fetch` (needs `OLLAMA_API_KEY`).
1032    Ollama,
1033}
1034
1035/// Backend for the `web_search` tool.
1036#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1037#[serde(rename_all = "lowercase")]
1038pub enum SearchBackend {
1039    /// Sovereign zero-config default: an auto-managed local SearXNG process on
1040    /// platforms with a published bundle. It never selects a cloud backend
1041    /// merely because a credential exists.
1042    #[default]
1043    Auto,
1044    /// Ollama Cloud's `/api/web_search` (needs `OLLAMA_API_KEY`).
1045    Ollama,
1046    /// A self-hosted SearXNG instance queried at `searxng_url` — keyless.
1047    Searxng,
1048}
1049
1050/// Web tool backend configuration.
1051///
1052/// ```toml
1053/// [web]
1054/// fetch_backend = "native"   # or "ollama"
1055/// search_backend = "auto"    # or "ollama" / "searxng"
1056/// searxng_url = "http://localhost:8080"
1057/// ```
1058#[derive(Debug, Clone, Serialize, Deserialize)]
1059#[serde(default)]
1060pub struct WebConfig {
1061    /// Backend for `web_fetch`. `native` (default) fetches the URL from this
1062    /// machine and needs no key; `ollama` uses Ollama Cloud.
1063    pub fetch_backend: FetchBackend,
1064    /// Backend for `web_search`. `auto` (default) auto-manages a local SearXNG
1065    /// process where a bundle is supported. `ollama` explicitly selects Ollama
1066    /// Cloud; `searxng` selects a self-hosted instance at `searxng_url`.
1067    pub search_backend: SearchBackend,
1068    /// SearXNG base URL, used when `search_backend = "searxng"` (your own
1069    /// instance). The instance must have the JSON output format enabled
1070    /// (`search.formats` includes `json`). The `auto` managed instance ignores
1071    /// this and picks its own port.
1072    pub searxng_url: String,
1073}
1074
1075impl Default for WebConfig {
1076    fn default() -> Self {
1077        Self {
1078            fetch_backend: FetchBackend::Native,
1079            search_backend: SearchBackend::Auto,
1080            searxng_url: String::from("http://localhost:8080"),
1081        }
1082    }
1083}
1084
1085/// Non-interactive mode configuration
1086#[derive(Debug, Clone, Serialize, Deserialize)]
1087#[serde(default)]
1088pub struct NonInteractiveConfig {
1089    /// Output format (text, json, markdown)
1090    pub output_format: String,
1091    /// Maximum tokens to generate
1092    pub max_tokens: usize,
1093    /// Don't execute agent actions (dry run)
1094    pub no_execute: bool,
1095}
1096
1097impl Default for NonInteractiveConfig {
1098    fn default() -> Self {
1099        Self {
1100            output_format: String::from("text"),
1101            // 0 = AUTO (see `ModelSettings::max_tokens`).
1102            max_tokens: 0,
1103            no_execute: false,
1104        }
1105    }
1106}
1107
1108/// One source of configuration in the layered merge. Declaration order IS
1109/// precedence: every later layer's table is deep-merged over the earlier ones,
1110/// so `Defaults < User < Profile < Project < Session`.
1111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1112pub enum ConfigLayer {
1113    /// Built-in defaults (`Config::default()`); the implicit base — an empty
1114    /// table deserializes to it, so no explicit table is ever built for it.
1115    Defaults = 0,
1116    /// The user's `~/.config/mermaid/config.toml` — the only layer persists
1117    /// write to.
1118    User = 1,
1119    /// A named overlay from the user file's `[profiles.<name>]`, selected
1120    /// with `--profile <name>`. Sits BELOW Project so a repo's tighten-only
1121    /// safety clamp still wins over a profile's choices.
1122    Profile = 2,
1123    /// A repo's `<git-root>/.mermaid/config.toml` (sanitized + tighten-only;
1124    /// populated by the project-config loader).
1125    Project = 3,
1126    /// This invocation's CLI flags: `-c KEY=VALUE` plus the dedicated flags
1127    /// (`--no-network`, `--confine-fs`, `--sandbox`, `run --max-tokens`,
1128    /// `run --allow-untrusted-tools`).
1129    Session = 4,
1130}
1131
1132impl ConfigLayer {
1133    /// Human name used in unknown-key warnings ("in user config (…)").
1134    fn name(self) -> &'static str {
1135        match self {
1136            ConfigLayer::Defaults => "defaults",
1137            ConfigLayer::User => "user config",
1138            ConfigLayer::Profile => "config profile",
1139            ConfigLayer::Project => "project config",
1140            ConfigLayer::Session => "session flags",
1141        }
1142    }
1143}
1144
1145/// One layer's raw table plus where it came from (for warning attribution).
1146#[derive(Debug, Clone)]
1147pub(crate) struct LayerSource {
1148    /// Which precedence slot this table occupies.
1149    pub layer: ConfigLayer,
1150    /// Human-readable origin (file path or "command line") for warnings.
1151    pub origin: String,
1152    /// The layer's raw parsed TOML, merged verbatim (already sanitized for
1153    /// the project layer).
1154    pub table: toml::Table,
1155}
1156
1157/// The per-invocation config overrides carried by CLI flags — the `Session`
1158/// layer's inputs. Built from the parsed CLI by `Cli::session_flags()`.
1159#[derive(Debug, Clone, Default)]
1160pub struct SessionFlags {
1161    /// Repeatable `-c KEY=VALUE` overrides, applied first (dedicated flags
1162    /// deep-set on top, so a flag beats a contradictory `-c`).
1163    pub overrides: Vec<String>,
1164    /// `--no-network` or `--sandbox` → `safety.network = "deny"`.
1165    pub deny_network: bool,
1166    /// `--confine-fs` or `--sandbox` → `safety.filesystem = "project"`.
1167    pub confine_fs: bool,
1168    /// `run --max-tokens <n>` → `default_model.max_tokens`.
1169    pub max_tokens: Option<usize>,
1170    /// `run --allow-untrusted-tools` → `safety.allow_untrusted_headless_tools`.
1171    pub allow_untrusted_tools: bool,
1172    /// `--profile <name>`: select a `[profiles.<name>]` overlay from the user
1173    /// config file. NOT rendered into `to_table` — profiles are their own
1174    /// layer, resolved by `load_layered_config`.
1175    pub profile: Option<String>,
1176}
1177
1178impl SessionFlags {
1179    /// Render the flags as the `Session` layer's raw table. `-c` overrides go
1180    /// in first; the dedicated flags deep-set on top of them, preserving the
1181    /// historical ordering where `--no-network` beats `-c safety.network=allow`.
1182    pub(crate) fn to_table(&self) -> Result<toml::Table> {
1183        let mut table = toml::Table::new();
1184        apply_cli_overrides(&mut table, &self.overrides)?;
1185        if self.deny_network {
1186            deep_set_segments(
1187                &mut table,
1188                &["safety", "network"],
1189                toml::Value::String("deny".into()),
1190            )?;
1191        }
1192        if self.confine_fs {
1193            deep_set_segments(
1194                &mut table,
1195                &["safety", "filesystem"],
1196                toml::Value::String("project".into()),
1197            )?;
1198        }
1199        if let Some(n) = self.max_tokens {
1200            deep_set_segments(
1201                &mut table,
1202                &["default_model", "max_tokens"],
1203                toml::Value::Integer(n as i64),
1204            )?;
1205        }
1206        if self.allow_untrusted_tools {
1207            deep_set_segments(
1208                &mut table,
1209                &["safety", "allow_untrusted_headless_tools"],
1210                toml::Value::Boolean(true),
1211            )?;
1212        }
1213        Ok(table)
1214    }
1215}
1216
1217/// Remove the `profiles` table from a raw user-config table and return it
1218/// (empty when absent). `[profiles.<name>]` overlays must NEVER reach
1219/// `Config` deserialization — they are a container of layer tables, not
1220/// config keys — so every user-file read excises them before
1221/// `finalize_config` (which would otherwise warn about unknown keys) and
1222/// before any safety baseline is computed.
1223fn take_profiles(table: &mut toml::Table) -> toml::Table {
1224    match table.remove("profiles") {
1225        Some(toml::Value::Table(profiles)) => profiles,
1226        // A non-table `profiles` key is malformed; drop it (the profile
1227        // lookup errors clearly when one was requested).
1228        _ => toml::Table::new(),
1229    }
1230}
1231
1232/// Resolve `--profile <name>` against the user file's excised `[profiles.*]`
1233/// table: the named overlay as a `Profile` layer, or a hard error naming the
1234/// available profiles (sorted).
1235fn resolve_profile_layer(
1236    profiles: &toml::Table,
1237    name: &str,
1238    config_path: &std::path::Path,
1239) -> Result<LayerSource> {
1240    match profiles.get(name) {
1241        Some(toml::Value::Table(overlay)) => Ok(LayerSource {
1242            layer: ConfigLayer::Profile,
1243            origin: format!("profile:{} ({})", name, config_path.display()),
1244            table: overlay.clone(),
1245        }),
1246        Some(_) => anyhow::bail!(
1247            "config profile '{}' is not a table; define it as [profiles.{}] in {}",
1248            name,
1249            name,
1250            config_path.display()
1251        ),
1252        None => {
1253            let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
1254            available.sort_unstable();
1255            if available.is_empty() {
1256                anyhow::bail!(
1257                    "no config profiles defined; add [profiles.{}] to {}",
1258                    name,
1259                    config_path.display()
1260                );
1261            }
1262            anyhow::bail!(
1263                "unknown config profile '{}'; available: {}",
1264                name,
1265                available.join(", ")
1266            )
1267        },
1268    }
1269}
1270
1271/// Load the user-scope configuration (defaults + the user file, no project or
1272/// session layers). This is the view persistence baselines, the daemon, and
1273/// runtime re-reads use — anything that must not observe another repo's
1274/// project config or a one-off CLI flag.
1275pub fn load_config() -> Result<Config> {
1276    let config_path = get_config_path()?;
1277    let mut table = read_config_table(&config_path)?;
1278    migrate_legacy_max_tokens(&mut table);
1279    migrate_legacy_model_profiles(&mut table);
1280    let _ = take_profiles(&mut table);
1281    Ok(finalize_config(table)?.0)
1282}
1283
1284/// A completed layered load: the merged config plus the messages the startup
1285/// path surfaces.
1286pub struct LayeredLoad {
1287    /// The merged, typed configuration.
1288    pub config: Config,
1289    /// Layer-attributed unknown-key and project-sanitizer warnings.
1290    pub warnings: Vec<String>,
1291    /// Informational lines (e.g. "using project config …").
1292    pub notices: Vec<String>,
1293}
1294
1295/// Load the full layered configuration:
1296/// defaults < user file < project file < session flags.
1297/// `cwd` locates the project layer (`<git-root>/.mermaid/config.toml`,
1298/// sanitized + safety-clamped); pass `None` to skip it (daemon, tests).
1299pub fn load_layered_config(
1300    cwd: Option<&std::path::Path>,
1301    flags: &SessionFlags,
1302) -> Result<LayeredLoad> {
1303    let config_path = get_config_path()?;
1304    let mut user_table = read_config_table(&config_path)?;
1305    migrate_legacy_max_tokens(&mut user_table);
1306    migrate_legacy_model_profiles(&mut user_table);
1307    // Excise [profiles.*] BEFORE anything deserializes the user table (the
1308    // safety baseline below and finalize_config's unknown-key scan).
1309    let profiles = take_profiles(&mut user_table);
1310    let mut layers = vec![LayerSource {
1311        layer: ConfigLayer::User,
1312        origin: config_path.display().to_string(),
1313        table: user_table.clone(),
1314    }];
1315    let mut sanitizer_warnings = Vec::new();
1316    let mut notices = Vec::new();
1317    if let Some(name) = flags.profile.as_deref() {
1318        let layer = resolve_profile_layer(&profiles, name, &config_path)?;
1319        notices.push(format!(
1320            "using config profile '{}' (from {})",
1321            name,
1322            config_path.display()
1323        ));
1324        layers.push(layer);
1325    }
1326    if let Some(cwd) = cwd {
1327        // The tighten-only safety clamp compares against the user-scope
1328        // (defaults + user file) values.
1329        let base_safety = finalize_config(user_table)?.0.safety;
1330        let (layer, warnings, notice) =
1331            super::project_config::load_project_layer(cwd, &base_safety);
1332        sanitizer_warnings.extend(warnings);
1333        notices.extend(notice);
1334        if let Some(layer) = layer {
1335            layers.push(layer);
1336        }
1337    }
1338    layers.push(LayerSource {
1339        layer: ConfigLayer::Session,
1340        origin: "command line".to_string(),
1341        table: flags.to_table()?,
1342    });
1343    let (mut config, unknown_key_warnings) = merge_layers(layers)?;
1344    config.active_profile = flags.profile.clone();
1345    // Sanitizer warnings first: they explain keys that will also be absent
1346    // from the merged result.
1347    sanitizer_warnings.extend(unknown_key_warnings);
1348    Ok(LayeredLoad {
1349        config,
1350        warnings: sanitizer_warnings,
1351        notices,
1352    })
1353}
1354
1355/// The project-scoped view (defaults + user + project, NO session flags) for
1356/// runtime re-reads keyed to a workdir — e.g. the memory settings consulted
1357/// per operation. Never fails and never prints; warnings/notices were already
1358/// surfaced by the startup load.
1359pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
1360    fn load(cwd: &std::path::Path) -> Result<Config> {
1361        let config_path = get_config_path()?;
1362        let mut user_table = read_config_table(&config_path)?;
1363        migrate_legacy_max_tokens(&mut user_table);
1364        migrate_legacy_model_profiles(&mut user_table);
1365        let _ = take_profiles(&mut user_table);
1366        let base_safety = finalize_config(user_table.clone())?.0.safety;
1367        let mut layers = vec![LayerSource {
1368            layer: ConfigLayer::User,
1369            origin: config_path.display().to_string(),
1370            table: user_table,
1371        }];
1372        let (layer, _warnings, _notice) =
1373            super::project_config::load_project_layer(cwd, &base_safety);
1374        if let Some(layer) = layer {
1375            layers.push(layer);
1376        }
1377        Ok(merge_layers(layers)?.0)
1378    }
1379    load(cwd).unwrap_or_default()
1380}
1381
1382/// Like [`load_config`] (user scope, no session flags) but never fails: on a
1383/// malformed config, warn on stderr (secret-redacted, #F13) and fall back to
1384/// defaults (#111). For standalone subcommands that only read user settings.
1385pub fn load_config_or_warn() -> Config {
1386    load_config().unwrap_or_else(|e| {
1387        eprintln!(
1388            "mermaid: {}",
1389            crate::utils::redact_secrets(&format!("{e:#}"))
1390        );
1391        Config::default()
1392    })
1393}
1394
1395/// Read and parse one layer's TOML file; a missing file is an empty table.
1396pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
1397    if !path.exists() {
1398        return Ok(toml::Table::new());
1399    }
1400    let raw = std::fs::read_to_string(path)
1401        .with_context(|| format!("Failed to read {}", path.display()))?;
1402    toml::from_str::<toml::Table>(&raw).with_context(|| {
1403        format!(
1404            "Failed to parse {}. Run 'mermaid init' to regenerate.",
1405            path.display()
1406        )
1407    })
1408}
1409
1410/// Deep-merge the layers in order (later wins) and deserialize the result
1411/// once. Unknown-key warnings are collected per layer so each names the file
1412/// (or flag set) that actually contains the typo.
1413pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
1414    let mut warnings = Vec::new();
1415    let mut merged = toml::Table::new();
1416    for layer in layers {
1417        collect_layer_warnings(&layer, &mut warnings);
1418        deep_merge(&mut merged, layer.table);
1419    }
1420    let (config, _) = finalize_config(merged)?;
1421    Ok((config, warnings))
1422}
1423
1424/// Run one layer's table through `serde_ignored` purely for warning
1425/// attribution. A layer that fails to deserialize on its own contributes no
1426/// warnings — the authoritative merged deserialize in `merge_layers` surfaces
1427/// any real error (and a later layer may legitimately fix an earlier one's
1428/// value).
1429fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
1430    let mut ignored = Vec::new();
1431    let result: Result<Config, _> =
1432        serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
1433            ignored.push(path.to_string())
1434        });
1435    if result.is_ok() {
1436        for path in ignored {
1437            warnings.push(format!(
1438                "unknown config key '{path}' in {} ({}) — check for a typo",
1439                layer.layer.name(),
1440                layer.origin
1441            ));
1442        }
1443    }
1444}
1445
1446/// Recursively merge `overlay` into `base`: tables merge key-by-key, while
1447/// scalars and arrays replace wholesale (arrays are atomic values here — an
1448/// element-wise merge could never express removing an entry). A kind conflict
1449/// (table over scalar or vice versa) resolves to the overlay's value.
1450fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
1451    for (key, value) in overlay {
1452        match (base.get_mut(&key), value) {
1453            (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
1454                deep_merge(base_table, overlay_table);
1455            },
1456            (_, value) => {
1457                base.insert(key, value);
1458            },
1459        }
1460    }
1461}
1462
1463/// One-time migration for the AUTO output-budget change. Existing config files
1464/// froze the old `default_model.max_tokens = 4096` default to disk (`save_config`
1465/// serializes every field), which would otherwise pin the stale cap forever.
1466/// Coerce that legacy value to `0` (AUTO) so upgraded users get the model-scaled
1467/// budget. Applied to the on-disk table *before* CLI overrides, so an explicit
1468/// `-c default_model.max_tokens=4096` still wins. The only unpreserved case is a
1469/// user who hand-wrote exactly `4096` in config.toml — an unusual deliberate
1470/// value, and AUTO is the better default regardless.
1471fn migrate_legacy_max_tokens(table: &mut toml::Table) {
1472    if let Some(dm) = table
1473        .get_mut("default_model")
1474        .and_then(|v| v.as_table_mut())
1475        && dm.get("max_tokens").and_then(|v| v.as_integer())
1476            == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
1477    {
1478        dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
1479    }
1480}
1481
1482/// Migrate the pre-profiles `[model_profiles]` table to its new name,
1483/// `[model_aliases]` (the `profile` name now belongs to `--profile` config
1484/// overlays). Runs wherever `migrate_legacy_max_tokens` runs: config loads
1485/// stop warning immediately, and the next persist converges the file on
1486/// disk. A file that somehow has BOTH tables keeps `model_aliases`.
1487fn migrate_legacy_model_profiles(table: &mut toml::Table) {
1488    if table.contains_key("model_aliases") {
1489        table.remove("model_profiles");
1490        return;
1491    }
1492    if let Some(profiles) = table.remove("model_profiles") {
1493        table.insert("model_aliases".to_string(), profiles);
1494    }
1495}
1496
1497/// Deserialize a (possibly merged) config `Table` into `Config`, collecting the
1498/// dotted paths of any keys `Config` doesn't recognize so the caller can warn.
1499/// An empty table yields `Config::default()` (every field is `#[serde(default)]`).
1500fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
1501    let mut ignored = Vec::new();
1502    let mut config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
1503        ignored.push(path.to_string());
1504    })
1505    .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
1506    // `plan` is a live session mode, not a persistent default: entering it
1507    // allocates a plan file, which config loading has no session to do it for.
1508    // `safety.mode = "plan"` would otherwise start a session that reports
1509    // "planning" with no plan to write. Fall back to the default and let
1510    // `/plan`, `/safety plan`, or Shift+Tab do the real thing. It is also what
1511    // `mode_after_plan` reads, so this must never be `plan` itself.
1512    if config.safety.mode.is_planning() {
1513        config.safety.mode = SafetyConfig::default().mode;
1514        ignored.push(
1515            "safety.mode (plan is entered with /plan or Shift+Tab, not configured)".to_string(),
1516        );
1517    }
1518    Ok((config, ignored))
1519}
1520
1521/// Apply repeatable `-c KEY=VALUE` overrides onto a config table. `KEY` is a
1522/// dotted path (`default_model.model`); `VALUE` is parsed as a TOML scalar so
1523/// `true`/`3`/`"x"` keep their types, with a bare word treated as a string.
1524fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
1525    for raw in overrides {
1526        let (key, val) = raw
1527            .split_once('=')
1528            .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
1529        let key = key.trim();
1530        if key.is_empty() {
1531            anyhow::bail!("invalid -c override '{raw}' (empty key)");
1532        }
1533        deep_set(table, key, parse_override_value(val.trim()))?;
1534    }
1535    Ok(())
1536}
1537
1538/// Parse an override value as a standalone TOML value, falling back to a plain
1539/// string when it isn't valid TOML on its own (e.g. `ollama/qwen`).
1540fn parse_override_value(s: &str) -> toml::Value {
1541    toml::from_str::<toml::Table>(&format!("x = {s}"))
1542        .ok()
1543        .and_then(|t| t.get("x").cloned())
1544        .unwrap_or_else(|| toml::Value::String(s.to_string()))
1545}
1546
1547/// Set a dotted `key` path in `table` to `value`, creating intermediate
1548/// tables. Dotted-path parsing means a `-c` override cannot address a map key
1549/// that itself contains a dot (e.g. a `reasoning_per_model` model id) — a
1550/// documented syntax limitation; internal persists use
1551/// [`deep_set_segments`] directly and are immune.
1552fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
1553    let parts: Vec<&str> = key.split('.').collect();
1554    deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
1555}
1556
1557/// Set a pre-split `path` in `table` to `value`, creating intermediate tables.
1558/// Segments are literal keys — a segment containing a dot addresses exactly
1559/// that key (which dotted parsing cannot express).
1560fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
1561    let Some((leaf, parents)) = path.split_last() else {
1562        anyhow::bail!("empty config key path");
1563    };
1564    let mut cur = table;
1565    for part in parents {
1566        let next = cur
1567            .entry((*part).to_string())
1568            .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1569        cur = next
1570            .as_table_mut()
1571            .with_context(|| format!("'{part}' is not a table"))?;
1572    }
1573    cur.insert((*leaf).to_string(), value);
1574    Ok(())
1575}
1576
1577/// Remove a pre-split `path` from `table`. Returns whether a value was
1578/// actually removed. Never creates intermediate tables; a missing parent
1579/// simply means there was nothing to remove.
1580pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
1581    let Some((leaf, parents)) = path.split_last() else {
1582        return false;
1583    };
1584    let mut cur = table;
1585    for part in parents {
1586        match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
1587            Some(next) => cur = next,
1588            None => return false,
1589        }
1590    }
1591    cur.remove(*leaf).is_some()
1592}
1593
1594/// Like [`load_layered_config`] but never fails — the startup entry point.
1595/// On success, prints notices and layer-attributed warnings to stderr. On a
1596/// malformed layer, warns (secret-redacted, #F13) and degrades: the session
1597/// flags are re-applied over bare defaults so `--no-network`/`-c` survive a
1598/// corrupt user file rather than being silently dropped with it.
1599pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
1600    match load_layered_config(cwd, flags) {
1601        Ok(load) => {
1602            for notice in &load.notices {
1603                eprintln!("mermaid: {notice}");
1604            }
1605            for warning in &load.warnings {
1606                eprintln!("mermaid: warning: {warning}");
1607            }
1608            load.config
1609        },
1610        Err(e) => {
1611            // A TOML parse error renders the offending source line, which can be
1612            // a secret-bearing one (`extra_headers`/`env`/`api_key_env`); scrub
1613            // credential-shaped content before it reaches stderr (#F13).
1614            eprintln!(
1615                "mermaid: {}",
1616                crate::utils::redact_secrets(&format!("{e:#}"))
1617            );
1618            flags
1619                .to_table()
1620                .ok()
1621                .and_then(|table| finalize_config(table).ok())
1622                .map(|(config, _)| config)
1623                .unwrap_or_default()
1624        },
1625    }
1626}
1627
1628/// Get the path to the single config file
1629pub fn get_config_path() -> Result<PathBuf> {
1630    Ok(get_config_dir()?.join("config.toml"))
1631}
1632
1633/// Get the configuration directory
1634pub fn get_config_dir() -> Result<PathBuf> {
1635    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1636        let config_dir = proj_dirs.config_dir();
1637        std::fs::create_dir_all(config_dir)?;
1638        Ok(config_dir.to_path_buf())
1639    } else {
1640        // Fallback to home directory
1641        let home = std::env::var("HOME")
1642            .or_else(|_| std::env::var("USERPROFILE"))
1643            .context("Could not determine home directory")?;
1644        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
1645        std::fs::create_dir_all(&config_dir)?;
1646        Ok(config_dir)
1647    }
1648}
1649
1650/// Save a full configuration to file. Private on purpose: serializing the
1651/// whole typed `Config` freezes every default (and would freeze merged
1652/// project/session values) into the file, so the only legitimate callers are
1653/// `init_config` (writing pristine defaults to an absent file) and tests.
1654/// Runtime persistence goes through [`update_user_config_key`] /
1655/// [`remove_user_config_key`], which rewrite only their own keys.
1656fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
1657    let path = if let Some(p) = path {
1658        p
1659    } else {
1660        get_config_dir()?.join("config.toml")
1661    };
1662    write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
1663}
1664
1665/// Write raw config bytes atomically and owner-only.
1666///
1667/// The config can carry literal secrets — `mcp_servers[].env`,
1668/// `mcp_servers[].args`, `mcp_servers[].headers`, and
1669/// `providers[].extra_headers` all accept inline credential values — so it
1670/// must not be left world-readable, and a crash
1671/// mid-write must not truncate it. Write atomically (temp → fsync → rename),
1672/// creating the temp 0600 on Unix so the renamed file is never even briefly
1673/// world-readable (this also tightens a pre-existing config, since the new
1674/// file replaces the old one). Windows relies on the per-user profile ACL.
1675fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1676    #[cfg(unix)]
1677    crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
1678        .with_context(|| format!("Failed to write config to {}", path.display()))?;
1679    #[cfg(not(unix))]
1680    crate::runtime::write_atomic(path, bytes)
1681        .with_context(|| format!("Failed to write config to {}", path.display()))?;
1682    Ok(())
1683}
1684
1685/// Create a default configuration file if it doesn't exist
1686pub fn init_config() -> Result<()> {
1687    let config_file = get_config_path()?;
1688
1689    if config_file.exists() {
1690        println!("Configuration already exists at: {}", config_file.display());
1691    } else {
1692        let default_config = Config::default();
1693        save_config(&default_config, Some(config_file.clone()))?;
1694        println!("Created configuration at: {}", config_file.display());
1695    }
1696
1697    Ok(())
1698}
1699
1700/// Serializes the read-modify-write persistence path. The `persist_*` helpers
1701/// run as concurrent detached tasks (dispatched by the effect runner) that all
1702/// load → mutate → save the same file; without a lock two quick toggles
1703/// (`/model` then Alt+T) can interleave their loads and lose one write. Held
1704/// only across the synchronous fs work — never across an `.await`.
1705static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1706
1707/// Read the raw USER config table, apply `mutate`, and write it back — under
1708/// `PERSIST_LOCK` so concurrent persists can't clobber each other. Operating
1709/// on the raw table (never the merged typed `Config`) means a persist rewrites
1710/// only its own keys: unknown keys survive, defaults are not frozen in, and
1711/// project-layer or session-flag values can never leak into the user file.
1712/// A malformed file propagates the parse error rather than being overwritten
1713/// with defaults (#111).
1714fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
1715    update_user_config_table_at(&get_config_path()?, mutate)
1716}
1717
1718/// [`update_user_config_table`] against an explicit path (test seam).
1719fn update_user_config_table_at(
1720    path: &std::path::Path,
1721    mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
1722) -> Result<()> {
1723    let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1724    let mut table = read_config_table(path)?;
1725    // Converge the on-disk legacy output cap while we're rewriting anyway.
1726    migrate_legacy_max_tokens(&mut table);
1727    migrate_legacy_model_profiles(&mut table);
1728    mutate(&mut table)?;
1729    write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
1730}
1731
1732/// Set one key (pre-split path segments, so map keys containing dots — e.g.
1733/// `reasoning_per_model."ollama/qwen3:8b"` — address correctly) in the USER
1734/// config file, leaving every other key untouched.
1735pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
1736    update_user_config_table(|table| deep_set_segments(table, path, value))
1737}
1738
1739/// Persist the whole `[plan]` table (the `/plan config` picker). Values the
1740/// user set through the picker are explicit choices, so writing them —
1741/// including ones that currently match defaults — is correct; unset Options
1742/// stay absent via `skip_serializing_if`.
1743pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
1744    update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
1745}
1746
1747/// Remove one key (pre-split path segments) from the USER config file.
1748/// Returns whether the key existed.
1749pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
1750    let mut removed = false;
1751    update_user_config_table(|table| {
1752        removed = deep_remove_segments(table, path);
1753        Ok(())
1754    })?;
1755    Ok(removed)
1756}
1757
1758/// Persist the last used model to the user config file.
1759pub fn persist_last_model(model: &str) -> Result<()> {
1760    update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
1761}
1762
1763/// Persist the TUI theme choice (`/theme dark|light`).
1764pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
1765    update_user_config_key(
1766        &["ui", "theme"],
1767        toml::Value::String(theme.as_str().to_string()),
1768    )
1769}
1770
1771/// Persist the user's default reasoning level. Used by the `/reasoning` slash
1772/// command and the Alt+T cycle handler so the choice survives across sessions.
1773pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
1774    update_user_config_key(
1775        &["default_model", "reasoning"],
1776        toml::Value::try_from(level)?,
1777    )
1778}
1779
1780/// Persist a reasoning level for a specific model ID
1781/// (e.g. `<provider>/<model>`). The TUI calls this from Alt+T,
1782/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
1783/// the choice sticks per-model rather than bleeding into other models on
1784/// next session start.
1785pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
1786    update_user_config_key(
1787        &["reasoning_per_model", model_id],
1788        toml::Value::try_from(level)?,
1789    )
1790}
1791
1792/// Persist (or clear) a per-model Ollama `num_ctx` override. `Some(n)` sets it,
1793/// `None` removes the entry (returning that model to auto-fit).
1794pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
1795    match num_ctx {
1796        Some(n) => update_user_config_key(
1797            &["ollama_num_ctx_per_model", model_id],
1798            toml::Value::Integer(i64::from(n)),
1799        ),
1800        None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
1801    }
1802}
1803
1804/// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
1805pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
1806    update_user_config_key(
1807        &["ollama", "allow_ram_offload"],
1808        toml::Value::Boolean(enabled),
1809    )
1810}
1811
1812/// Resolve which model to use: CLI arg > last_used > default_model > any available
1813pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
1814    if let Some(model) = cli_model {
1815        if let Some(resolved) = resolve_model_alias(model, config)? {
1816            return Ok(resolved);
1817        }
1818        return Ok(model.to_string());
1819    }
1820    if let Some(last_model) = &config.last_used_model {
1821        if let Some(resolved) = resolve_model_alias(last_model, config)? {
1822            return Ok(resolved);
1823        }
1824        return Ok(last_model.clone());
1825    }
1826    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1827        return Ok(format!(
1828            "{}/{}",
1829            config.default_model.provider, config.default_model.name
1830        ));
1831    }
1832    let available = crate::ollama::require_any_model(config).await?;
1833    // `require_any_model` already errors on empty, so this `.first()` is
1834    // never `None` in practice. Use `.first()` over `[0]` so the precondition
1835    // is enforced by the type system instead of by a comment.
1836    let first = available
1837        .first()
1838        .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
1839    Ok(format!("ollama/{}", first))
1840}
1841
1842fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
1843    let alias = requested.strip_prefix("alias:").unwrap_or(requested);
1844    if let Some(model) = config.model_aliases.get(alias) {
1845        anyhow::ensure!(
1846            !model.trim().is_empty(),
1847            "model alias `{}` is configured with an empty model id",
1848            alias
1849        );
1850        return Ok(Some(model.clone()));
1851    }
1852    if requested.starts_with("alias:") {
1853        anyhow::bail!(
1854            "model alias `{}` is not configured; add it under [model_aliases]",
1855            alias
1856        );
1857    }
1858    Ok(None)
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863    use super::*;
1864
1865    #[test]
1866    fn legacy_default_max_tokens_migrates_to_auto() {
1867        // The frozen pre-AUTO default (4096) on disk is coerced to 0 = AUTO…
1868        let mut table: toml::Table =
1869            toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
1870        migrate_legacy_max_tokens(&mut table);
1871        migrate_legacy_model_profiles(&mut table);
1872        let (config, _) = finalize_config(table).unwrap();
1873        assert_eq!(config.default_model.max_tokens, 0);
1874
1875        // …while any other explicit cap is preserved.
1876        let mut table: toml::Table =
1877            toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
1878        migrate_legacy_max_tokens(&mut table);
1879        migrate_legacy_model_profiles(&mut table);
1880        let (config, _) = finalize_config(table).unwrap();
1881        assert_eq!(config.default_model.max_tokens, 8192);
1882
1883        // A config without the key is untouched (stays the 0 default).
1884        let mut table = toml::Table::new();
1885        migrate_legacy_max_tokens(&mut table);
1886        migrate_legacy_model_profiles(&mut table);
1887        let (config, _) = finalize_config(table).unwrap();
1888        assert_eq!(config.default_model.max_tokens, 0);
1889    }
1890
1891    #[test]
1892    fn legacy_model_profiles_table_migrates_to_model_aliases() {
1893        // Loads stop warning immediately...
1894        let mut table: toml::Table =
1895            toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
1896        migrate_legacy_model_profiles(&mut table);
1897        let (config, ignored) = finalize_config(table).unwrap();
1898        assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
1899        assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
1900        // ...and a file with BOTH keeps the new table.
1901        let mut table: toml::Table =
1902            toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
1903                .unwrap();
1904        migrate_legacy_model_profiles(&mut table);
1905        let (config, ignored) = finalize_config(table).unwrap();
1906        assert_eq!(config.model_aliases["fast"], "new");
1907        assert!(ignored.is_empty());
1908        // ...and the persist path rewrites the key on disk.
1909        let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
1910        std::fs::create_dir_all(&dir).unwrap();
1911        let path = dir.join("config.toml");
1912        std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
1913        update_user_config_table_at(&path, |_| Ok(())).unwrap();
1914        let blob = std::fs::read_to_string(&path).unwrap();
1915        assert!(blob.contains("[model_aliases]"), "{blob}");
1916        assert!(!blob.contains("model_profiles"), "{blob}");
1917        let _ = std::fs::remove_dir_all(&dir);
1918    }
1919
1920    #[test]
1921    fn ui_theme_deserializes_defaults_and_rejects_typos() {
1922        let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
1923        assert_eq!(config.ui.theme, ThemeChoice::Light);
1924        // Absent → dark, both from an empty file and from Config::default().
1925        let config: Config = toml::from_str("").unwrap();
1926        assert_eq!(config.ui.theme, ThemeChoice::Dark);
1927        assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
1928        // Typos are a clear deserialize error, not a silent fallback.
1929        assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
1930    }
1931
1932    #[test]
1933    fn finalize_config_flags_unknown_keys() {
1934        let table: toml::Table =
1935            toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
1936                .unwrap();
1937        let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
1938        assert_eq!(config.default_model.max_tokens, 512);
1939        assert!(
1940            ignored.iter().any(|p| p == "unknown_top"),
1941            "got {ignored:?}"
1942        );
1943        assert!(
1944            ignored.iter().any(|p| p.contains("bogus")),
1945            "got {ignored:?}"
1946        );
1947    }
1948
1949    #[test]
1950    fn cli_overrides_beat_file_and_create_nested_tables() {
1951        // Override beats the file value...
1952        let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
1953        apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
1954        let (config, ignored) = finalize_config(table).unwrap();
1955        assert_eq!(config.default_model.max_tokens, 8192);
1956        assert!(ignored.is_empty());
1957        // ...and creates a section absent from the file.
1958        let mut empty = toml::Table::new();
1959        apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
1960        assert_eq!(
1961            finalize_config(empty).unwrap().0.default_model.max_tokens,
1962            256
1963        );
1964    }
1965
1966    #[test]
1967    fn parse_override_value_keeps_toml_types_with_string_fallback() {
1968        assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
1969        assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
1970        assert_eq!(
1971            parse_override_value("ollama/qwen"),
1972            toml::Value::String("ollama/qwen".to_string())
1973        );
1974    }
1975
1976    #[test]
1977    fn cli_override_invalid_format_errors() {
1978        let mut table = toml::Table::new();
1979        assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
1980        assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
1981    }
1982
1983    #[test]
1984    fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
1985        let mut base: toml::Table = toml::from_str(
1986            "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
1987        )
1988        .unwrap();
1989        let overlay: toml::Table =
1990            toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
1991                .unwrap();
1992        deep_merge(&mut base, overlay);
1993        // Sibling keys inside a merged table survive...
1994        assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
1995        // ...the overlaid scalar wins...
1996        assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
1997        // ...arrays replace wholesale (no concat)...
1998        assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
1999        // ...and untouched top-level keys survive.
2000        assert_eq!(base["top"].as_integer(), Some(1));
2001    }
2002
2003    #[test]
2004    fn deep_merge_overlay_wins_on_kind_conflict() {
2005        // Scalar over table and table over scalar both resolve to the overlay.
2006        let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
2007        let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
2008        deep_merge(&mut base, overlay);
2009        assert_eq!(base["a"].as_integer(), Some(5));
2010        assert_eq!(base["b"]["y"].as_integer(), Some(3));
2011    }
2012
2013    #[test]
2014    fn merge_layers_precedence_and_layer_attributed_warnings() {
2015        let user: toml::Table = toml::from_str(
2016            "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
2017        )
2018        .unwrap();
2019        let session: toml::Table =
2020            toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
2021        let (config, warnings) = merge_layers(vec![
2022            LayerSource {
2023                layer: ConfigLayer::User,
2024                origin: "/tmp/user.toml".to_string(),
2025                table: user,
2026            },
2027            LayerSource {
2028                layer: ConfigLayer::Session,
2029                origin: "command line".to_string(),
2030                table: session,
2031            },
2032        ])
2033        .expect("merges");
2034        // Later layer wins; earlier layer's untouched keys survive.
2035        assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
2036        assert_eq!(config.default_model.max_tokens, 100);
2037        // Each unknown key names its own layer + origin.
2038        assert!(
2039            warnings
2040                .iter()
2041                .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
2042            "got {warnings:?}"
2043        );
2044        assert!(
2045            warnings
2046                .iter()
2047                .any(|w| w.contains("session_typo") && w.contains("session flags")),
2048            "got {warnings:?}"
2049        );
2050    }
2051
2052    #[test]
2053    fn take_profiles_excises_and_tolerates_absence() {
2054        let mut table: toml::Table =
2055            toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
2056        let profiles = take_profiles(&mut table);
2057        assert!(table.is_empty(), "profiles must be excised: {table:?}");
2058        assert!(profiles.contains_key("fast"));
2059        // Absent -> empty, table untouched.
2060        let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
2061        assert!(take_profiles(&mut table).is_empty());
2062        assert_eq!(table.len(), 1);
2063        // Malformed (non-table) -> dropped, empty result.
2064        let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
2065        assert!(take_profiles(&mut table).is_empty());
2066        assert!(table.is_empty());
2067    }
2068
2069    #[test]
2070    fn resolve_profile_layer_errors_name_available_profiles() {
2071        let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
2072        let path = std::path::Path::new("/tmp/config.toml");
2073        let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
2074        assert!(err.to_string().contains("available: fast, work"), "{err}");
2075        // No profiles at all -> a distinct, actionable error.
2076        let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
2077        assert!(
2078            err.to_string().contains("no config profiles defined"),
2079            "{err}"
2080        );
2081        // Non-table profile value -> hard error.
2082        let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
2083        let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
2084        assert!(err.to_string().contains("not a table"), "{err}");
2085        // Hit -> Profile layer with attributing origin.
2086        let profiles: toml::Table =
2087            toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
2088        let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
2089        assert_eq!(layer.layer, ConfigLayer::Profile);
2090        assert!(layer.origin.contains("profile:work"));
2091    }
2092
2093    #[test]
2094    fn profile_layer_beats_user_loses_to_project_and_session() {
2095        let user: toml::Table = toml::from_str(
2096            "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
2097        )
2098        .unwrap();
2099        let profile: toml::Table = toml::from_str(
2100            "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
2101        )
2102        .unwrap();
2103        let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
2104        let session: toml::Table =
2105            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2106        let (config, warnings) = merge_layers(vec![
2107            LayerSource {
2108                layer: ConfigLayer::User,
2109                origin: "/tmp/user.toml".to_string(),
2110                table: user,
2111            },
2112            LayerSource {
2113                layer: ConfigLayer::Profile,
2114                origin: "profile:work (/tmp/user.toml)".to_string(),
2115                table: profile,
2116            },
2117            LayerSource {
2118                layer: ConfigLayer::Project,
2119                origin: "/repo/.mermaid/config.toml".to_string(),
2120                table: project,
2121            },
2122            LayerSource {
2123                layer: ConfigLayer::Session,
2124                origin: "command line".to_string(),
2125                table: session,
2126            },
2127        ])
2128        .expect("merges");
2129        // Project beats profile; session beats everything; profile beats user
2130        // where later layers are silent.
2131        assert_eq!(config.default_model.temperature, 0.5);
2132        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2133        assert_eq!(config.default_model.max_tokens, 100);
2134        // Unknown keys inside the profile attribute to it.
2135        assert!(
2136            warnings.iter().any(|w| w.contains("profile_typo")
2137                && w.contains("config profile (profile:work (/tmp/user.toml))")),
2138            "got {warnings:?}"
2139        );
2140    }
2141
2142    #[test]
2143    fn persists_never_touch_profile_tables() {
2144        let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
2145        std::fs::create_dir_all(&dir).expect("create temp dir");
2146        let path = dir.join("config.toml");
2147        std::fs::write(
2148            &path,
2149            "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
2150        )
2151        .expect("seed");
2152
2153        update_user_config_table_at(&path, |table| {
2154            deep_set_segments(
2155                table,
2156                &["safety", "mode"],
2157                toml::Value::String("auto".to_string()),
2158            )
2159        })
2160        .expect("persist");
2161
2162        let table: toml::Table =
2163            toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
2164        assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
2165        // The overlay table survives persists byte-for-byte semantically.
2166        assert_eq!(
2167            table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
2168            Some(0.1)
2169        );
2170        let _ = std::fs::remove_dir_all(&dir);
2171    }
2172
2173    #[test]
2174    fn session_flags_table_maps_each_flag() {
2175        let flags = SessionFlags {
2176            overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
2177            deny_network: true,
2178            confine_fs: true,
2179            max_tokens: Some(512),
2180            allow_untrusted_tools: true,
2181            profile: None,
2182        };
2183        let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2184        assert_eq!(config.safety.network, NetworkPolicy::Deny);
2185        assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
2186        assert_eq!(config.default_model.max_tokens, 512);
2187        assert!(config.safety.allow_untrusted_headless_tools);
2188        assert_eq!(config.web.searxng_url, "http://x:1");
2189    }
2190
2191    #[test]
2192    fn session_dedicated_flags_beat_dash_c() {
2193        // `--no-network` wins over a contradictory `-c safety.network=allow`
2194        // (the dedicated flags deep-set after the -c overrides).
2195        let flags = SessionFlags {
2196            overrides: vec!["safety.network=allow".to_string()],
2197            deny_network: true,
2198            ..Default::default()
2199        };
2200        let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2201        assert_eq!(config.safety.network, NetworkPolicy::Deny);
2202    }
2203
2204    #[test]
2205    fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
2206        // A layer that doesn't deserialize on its own contributes no warnings…
2207        let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
2208        let mut warnings = Vec::new();
2209        collect_layer_warnings(
2210            &LayerSource {
2211                layer: ConfigLayer::User,
2212                origin: "x".to_string(),
2213                table: bad.clone(),
2214            },
2215            &mut warnings,
2216        );
2217        assert!(warnings.is_empty());
2218        // …and the merged deserialize is what errors…
2219        assert!(
2220            merge_layers(vec![LayerSource {
2221                layer: ConfigLayer::User,
2222                origin: "x".to_string(),
2223                table: bad.clone(),
2224            }])
2225            .is_err()
2226        );
2227        // …unless a later layer fixes the value (session repairing a bad file).
2228        let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
2229        let (config, _) = merge_layers(vec![
2230            LayerSource {
2231                layer: ConfigLayer::User,
2232                origin: "x".to_string(),
2233                table: bad,
2234            },
2235            LayerSource {
2236                layer: ConfigLayer::Session,
2237                origin: "command line".to_string(),
2238                table: fix,
2239            },
2240        ])
2241        .expect("later layer repairs the earlier one");
2242        assert_eq!(config.safety.mode, SafetyMode::Ask);
2243    }
2244
2245    #[test]
2246    fn project_layer_beats_user_and_loses_to_session() {
2247        let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
2248        let project: toml::Table = toml::from_str(
2249            "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
2250        )
2251        .unwrap();
2252        let session: toml::Table =
2253            toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2254        let (config, _) = merge_layers(vec![
2255            LayerSource {
2256                layer: ConfigLayer::User,
2257                origin: "user".to_string(),
2258                table: user,
2259            },
2260            LayerSource {
2261                layer: ConfigLayer::Project,
2262                origin: "project".to_string(),
2263                table: project,
2264            },
2265            LayerSource {
2266                layer: ConfigLayer::Session,
2267                origin: "command line".to_string(),
2268                table: session,
2269            },
2270        ])
2271        .expect("merges");
2272        // Session beats project beats user for the contested key…
2273        assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2274        // …while the project's uncontested key lands.
2275        assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
2276    }
2277
2278    #[test]
2279    fn session_flags_survive_corrupt_user_layer_fallback() {
2280        // The or_warn fallback re-applies the session flags over bare defaults;
2281        // pin the exact expression it uses.
2282        let flags = SessionFlags {
2283            deny_network: true,
2284            ..Default::default()
2285        };
2286        let config = flags
2287            .to_table()
2288            .ok()
2289            .and_then(|table| finalize_config(table).ok())
2290            .map(|(config, _)| config)
2291            .unwrap_or_default();
2292        assert_eq!(config.safety.network, NetworkPolicy::Deny);
2293    }
2294
2295    #[test]
2296    fn deep_set_segments_addresses_keys_containing_dots() {
2297        // A model id with dots must be ONE key, which dotted parsing cannot
2298        // express — the latent bug the segment API fixes.
2299        let mut table = toml::Table::new();
2300        deep_set_segments(
2301            &mut table,
2302            &["reasoning_per_model", "gemini/gemini-2.5-pro"],
2303            toml::Value::String("high".to_string()),
2304        )
2305        .unwrap();
2306        let (config, ignored) = finalize_config(table).unwrap();
2307        assert!(ignored.is_empty(), "got {ignored:?}");
2308        assert_eq!(
2309            config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
2310            Some(&ReasoningLevel::High)
2311        );
2312    }
2313
2314    #[test]
2315    fn deep_remove_segments_removes_leaf_only() {
2316        let mut table: toml::Table =
2317            toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
2318                .unwrap();
2319        assert!(deep_remove_segments(
2320            &mut table,
2321            &["ollama_num_ctx_per_model", "ollama/a"]
2322        ));
2323        // Sibling survives; parent table survives; missing keys report false.
2324        assert_eq!(
2325            table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
2326            Some(2)
2327        );
2328        assert!(!deep_remove_segments(
2329            &mut table,
2330            &["ollama_num_ctx_per_model", "ollama/a"]
2331        ));
2332        assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
2333    }
2334
2335    #[test]
2336    fn update_user_config_table_preserves_unknown_keys() {
2337        let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
2338        std::fs::create_dir_all(&dir).expect("create temp dir");
2339        let path = dir.join("config.toml");
2340        // A file with an unknown key (maybe from a newer mermaid) and one known
2341        // setting the persist must not disturb.
2342        std::fs::write(
2343            &path,
2344            "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
2345        )
2346        .expect("seed");
2347
2348        update_user_config_table_at(&path, |table| {
2349            deep_set_segments(
2350                table,
2351                &["last_used_model"],
2352                toml::Value::String("ollama/new".to_string()),
2353            )
2354        })
2355        .expect("persist");
2356
2357        let blob = std::fs::read_to_string(&path).expect("read back");
2358        let table: toml::Table = toml::from_str(&blob).expect("parse back");
2359        // The targeted key changed…
2360        assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
2361        // …the unknown key survived (typed round-trips would have dropped it)…
2362        assert_eq!(table["future_key"].as_str(), Some("kept"));
2363        // …and no defaults were frozen in (only the keys that were there).
2364        assert!(!blob.contains("safety"), "defaults must not be frozen in");
2365        assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
2366
2367        let _ = std::fs::remove_dir_all(&dir);
2368    }
2369
2370    #[test]
2371    fn mcp_tool_allowed_honors_enabled_and_disabled() {
2372        // Default (both empty) allows everything.
2373        let cfg = McpServerConfig::default();
2374        assert!(cfg.tool_allowed("anything"));
2375        // enabled_tools acts as an allowlist.
2376        let cfg = McpServerConfig {
2377            enabled_tools: vec!["read".into(), "search".into()],
2378            ..Default::default()
2379        };
2380        assert!(cfg.tool_allowed("read"));
2381        assert!(!cfg.tool_allowed("write"));
2382        // disabled_tools wins over enabled_tools.
2383        let cfg = McpServerConfig {
2384            enabled_tools: vec!["read".into(), "write".into()],
2385            disabled_tools: vec!["write".into()],
2386            ..Default::default()
2387        };
2388        assert!(cfg.tool_allowed("read"));
2389        assert!(!cfg.tool_allowed("write"));
2390    }
2391
2392    #[test]
2393    fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
2394        // command-only → stdio.
2395        let cfg = McpServerConfig {
2396            command: "npx".to_string(),
2397            ..Default::default()
2398        };
2399        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
2400        // url-only → http.
2401        let cfg = McpServerConfig {
2402            url: Some("https://example.com/mcp".to_string()),
2403            ..Default::default()
2404        };
2405        assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
2406        // Both set → error.
2407        let cfg = McpServerConfig {
2408            command: "npx".to_string(),
2409            url: Some("https://example.com/mcp".to_string()),
2410            ..Default::default()
2411        };
2412        assert!(
2413            cfg.transport_kind()
2414                .unwrap_err()
2415                .to_string()
2416                .contains("mutually exclusive")
2417        );
2418        // Neither set → error.
2419        let cfg = McpServerConfig::default();
2420        assert!(
2421            cfg.transport_kind()
2422                .unwrap_err()
2423                .to_string()
2424                .contains("neither")
2425        );
2426    }
2427
2428    #[test]
2429    fn mcp_transport_kind_gates_url_scheme() {
2430        let with_url = |url: &str| McpServerConfig {
2431            url: Some(url.to_string()),
2432            ..Default::default()
2433        };
2434        // https anywhere is fine; http only to loopback (plaintext to a
2435        // routable host would leak auth headers).
2436        assert!(
2437            with_url("https://mcp.example.com/x")
2438                .transport_kind()
2439                .is_ok()
2440        );
2441        assert!(
2442            with_url("http://localhost:8080/mcp")
2443                .transport_kind()
2444                .is_ok()
2445        );
2446        assert!(
2447            with_url("http://127.0.0.1:8080/mcp")
2448                .transport_kind()
2449                .is_ok()
2450        );
2451        assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
2452        assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
2453        assert!(with_url("not a url").transport_kind().is_err());
2454    }
2455
2456    #[test]
2457    fn mcp_server_config_debug_masks_header_values() {
2458        let mut headers = HashMap::new();
2459        headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
2460        let mut env_headers = HashMap::new();
2461        env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
2462        let cfg = McpServerConfig {
2463            url: Some("https://example.com/mcp".to_string()),
2464            headers,
2465            env_headers,
2466            ..Default::default()
2467        };
2468        let rendered = format!("{cfg:?}");
2469        assert!(!rendered.contains("sk-secret"), "{rendered}");
2470        assert!(rendered.contains("Authorization"), "{rendered}");
2471        // env_headers values are env var NAMES, safe to render.
2472        assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
2473    }
2474
2475    #[test]
2476    fn mcp_url_config_round_trips_through_toml_without_command() {
2477        // `mermaid add --url` persists via toml::Value::try_from; a bare None
2478        // url or a forced empty `command` key would break that round-trip.
2479        let cfg = McpServerConfig {
2480            url: Some("https://example.com/mcp".to_string()),
2481            ..Default::default()
2482        };
2483        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2484        assert!(
2485            !blob.contains("command"),
2486            "empty command must be omitted: {blob}"
2487        );
2488        let back: McpServerConfig = toml::from_str(&blob).unwrap();
2489        assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
2490        assert!(back.command.is_empty());
2491        // And a stdio config must not serialize a `url` key at all.
2492        let cfg = McpServerConfig {
2493            command: "npx".to_string(),
2494            ..Default::default()
2495        };
2496        let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2497        assert!(!blob.contains("url"), "{blob}");
2498    }
2499
2500    /// Configs persisted before Step 4 don't have a `reasoning` field on
2501    /// `[default_model]`. Loading them must succeed and yield the
2502    /// `Medium` default — otherwise existing user configs break on
2503    /// upgrade.
2504    #[test]
2505    fn model_settings_deserializes_without_reasoning_field() {
2506        let toml_blob = r#"
2507            provider = "ollama"
2508            name = "qwen3-coder:30b"
2509            temperature = 0.7
2510            max_tokens = 4096
2511        "#;
2512        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
2513        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
2514        assert_eq!(settings.provider, "ollama");
2515    }
2516
2517    #[test]
2518    fn model_settings_round_trips_reasoning_high() {
2519        let original = ModelSettings {
2520            provider: "anthropic".to_string(),
2521            name: "claude-sonnet-4-6".to_string(),
2522            temperature: 0.5,
2523            max_tokens: 8192,
2524            reasoning: ReasoningLevel::High,
2525        };
2526        let toml_blob = toml::to_string(&original).expect("serialize");
2527        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
2528        assert_eq!(back.reasoning, ReasoningLevel::High);
2529        assert_eq!(back.name, "claude-sonnet-4-6");
2530    }
2531
2532    #[test]
2533    fn agents_config_defaults_and_parses_custom_types() {
2534        // Absent section → defaults (20-minute timeout, no custom types).
2535        let config: Config = toml::from_str("").expect("empty config parses");
2536        assert_eq!(config.agents.timeout_secs, 1200);
2537        assert!(config.agents.types.is_empty());
2538
2539        let config: Config = toml::from_str(
2540            r#"
2541[agents]
2542timeout_secs = 300
2543
2544[agents.types.scout]
2545tools = ["read_file", "execute_command"]
2546safety = "read_only"
2547preamble = "You are a scout."
2548model = "ollama/qwen3:8b"
2549"#,
2550        )
2551        .expect("agents section parses");
2552        assert_eq!(config.agents.timeout_secs, 300);
2553        let scout = &config.agents.types["scout"];
2554        assert_eq!(
2555            scout.tools.as_deref(),
2556            Some(&["read_file".to_string(), "execute_command".to_string()][..])
2557        );
2558        assert_eq!(scout.safety.as_deref(), Some("read_only"));
2559        assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
2560    }
2561
2562    #[test]
2563    fn configured_model_alias_resolves_explicit_prefix() {
2564        let mut config = Config::default();
2565        config
2566            .model_aliases
2567            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
2568        assert_eq!(
2569            resolve_model_alias("fast", &config).unwrap(),
2570            Some("ollama/qwen3-coder:14b".to_string())
2571        );
2572        assert_eq!(
2573            resolve_model_alias("alias:fast", &config).unwrap(),
2574            Some("ollama/qwen3-coder:14b".to_string())
2575        );
2576    }
2577
2578    #[test]
2579    fn alias_prefix_requires_configuration() {
2580        let config = Config::default();
2581        assert!(resolve_model_alias("alias:vision", &config).is_err());
2582        assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
2583    }
2584
2585    /// `persist_default_reasoning` writes to the real config path, so
2586    /// this test goes through `save_config(_, Some(path))` directly to
2587    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
2588    /// Uses `std::env::temp_dir` (matching the pattern in
2589    /// `session::conversation` and `utils::logger`) — no external
2590    /// `tempfile` crate dependency.
2591    #[test]
2592    fn save_and_reload_preserves_reasoning_field() {
2593        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
2594        std::fs::create_dir_all(&dir).expect("create temp dir");
2595        let path = dir.join("config.toml");
2596
2597        let mut cfg = Config::default();
2598        cfg.default_model.provider = "ollama".to_string();
2599        cfg.default_model.name = "qwen3-coder:30b".to_string();
2600        cfg.default_model.reasoning = ReasoningLevel::Low;
2601
2602        save_config(&cfg, Some(path.clone())).expect("save");
2603
2604        let blob = std::fs::read_to_string(&path).expect("read");
2605        let loaded: Config = toml::from_str(&blob).expect("parse back");
2606        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
2607
2608        let _ = std::fs::remove_dir_all(&dir);
2609    }
2610
2611    /// Per-model entries serialize as a TOML table with quoted keys (the
2612    /// model IDs contain `/`). This test verifies the round-trip works
2613    /// through both serialization and deserialization, matching what
2614    /// `persist_reasoning_for_model` would produce in real use.
2615    #[test]
2616    fn save_and_reload_preserves_reasoning_per_model_table() {
2617        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
2618        std::fs::create_dir_all(&dir).expect("create temp dir");
2619        let path = dir.join("config.toml");
2620
2621        let mut cfg = Config::default();
2622        cfg.reasoning_per_model.insert(
2623            "anthropic/claude-sonnet-4-6".to_string(),
2624            ReasoningLevel::High,
2625        );
2626        cfg.reasoning_per_model
2627            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
2628
2629        save_config(&cfg, Some(path.clone())).expect("save");
2630
2631        let blob = std::fs::read_to_string(&path).expect("read");
2632        let loaded: Config = toml::from_str(&blob).expect("parse back");
2633        assert_eq!(
2634            loaded
2635                .reasoning_per_model
2636                .get("anthropic/claude-sonnet-4-6"),
2637            Some(&ReasoningLevel::High)
2638        );
2639        assert_eq!(
2640            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
2641            Some(&ReasoningLevel::Low)
2642        );
2643
2644        let _ = std::fs::remove_dir_all(&dir);
2645    }
2646
2647    /// `/context <n>` overrides round-trip through the per-model TOML table, and
2648    /// the offload toggle persists on `[ollama]`.
2649    #[test]
2650    fn save_and_reload_preserves_ollama_context_overrides() {
2651        let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
2652        std::fs::create_dir_all(&dir).expect("create temp dir");
2653        let path = dir.join("config.toml");
2654
2655        let mut cfg = Config::default();
2656        cfg.ollama_num_ctx_per_model
2657            .insert("ollama/ornith:9b".to_string(), 131_072);
2658        cfg.ollama.allow_ram_offload = true;
2659        cfg.ollama.max_auto_num_ctx = Some(65_536);
2660
2661        save_config(&cfg, Some(path.clone())).expect("save");
2662        let blob = std::fs::read_to_string(&path).expect("read");
2663        let loaded: Config = toml::from_str(&blob).expect("parse back");
2664
2665        assert_eq!(
2666            loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
2667            Some(&131_072)
2668        );
2669        assert!(loaded.ollama.allow_ram_offload);
2670        assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
2671
2672        let _ = std::fs::remove_dir_all(&dir);
2673    }
2674
2675    /// Older configs have neither the per-model num_ctx table nor the new
2676    /// `[ollama]` keys; loading must default cleanly (empty map, offload off).
2677    #[test]
2678    fn config_deserializes_without_ollama_context_keys() {
2679        let toml_blob = r#"
2680[ollama]
2681host = "localhost"
2682port = 11434
2683"#;
2684        let cfg: Config = toml::from_str(toml_blob).expect("parse");
2685        assert!(cfg.ollama_num_ctx_per_model.is_empty());
2686        assert!(!cfg.ollama.allow_ram_offload);
2687        assert_eq!(cfg.ollama.max_auto_num_ctx, None);
2688        // Configs from before the auto-start knob default it ON — reviving a
2689        // dead local server is the out-of-the-box behavior.
2690        assert!(cfg.ollama.auto_start);
2691    }
2692
2693    /// Configs from before Step 5b don't have a `reasoning_per_model`
2694    /// section. Loading them must succeed with an empty map — otherwise
2695    /// upgrade breaks every existing user.
2696    #[test]
2697    fn config_deserializes_without_reasoning_per_model() {
2698        let toml_blob = r#"
2699            last_used_model = "ollama/qwen3-coder:30b"
2700
2701            [default_model]
2702            provider = "ollama"
2703            name = "qwen3-coder:30b"
2704            temperature = 0.7
2705            max_tokens = 4096
2706        "#;
2707        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
2708        assert!(cfg.reasoning_per_model.is_empty());
2709        assert!(!cfg.prompt.is_customized());
2710    }
2711
2712    /// Config holds inline-secret-capable fields (`mcp_servers[].env`, `args`,
2713    /// `headers`, `providers[].extra_headers`), so it must be written
2714    /// owner-only rather than inheriting a world-readable umask.
2715    #[cfg(unix)]
2716    #[test]
2717    fn save_config_writes_owner_only_perms() {
2718        use std::os::unix::fs::PermissionsExt;
2719        let dir = std::env::temp_dir().join("mermaid_test_config_perms");
2720        std::fs::create_dir_all(&dir).expect("create temp dir");
2721        let path = dir.join("config.toml");
2722        // Pre-create a world-readable file to prove we also tighten existing.
2723        std::fs::write(&path, "stale").expect("seed");
2724        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
2725
2726        save_config(&Config::default(), Some(path.clone())).expect("save");
2727        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2728        assert_eq!(mode, 0o600, "config must be written owner-only");
2729
2730        let _ = std::fs::remove_dir_all(&dir);
2731    }
2732
2733    #[test]
2734    fn config_defaults_computer_use_auto_screenshot_on() {
2735        // An empty/legacy config must keep the auto-screenshot behavior (#98).
2736        let cfg: Config = toml::from_str("").expect("empty config");
2737        assert!(cfg.computer_use.auto_screenshot);
2738    }
2739
2740    #[test]
2741    fn prompt_config_replaces_and_appends_without_persisting() {
2742        let mut cfg = Config::default();
2743        cfg.prompt.system_prompt = Some("base".to_string());
2744        cfg.prompt
2745            .append_system_prompt
2746            .push("extra instructions".to_string());
2747
2748        assert_eq!(
2749            cfg.prompt.render_system_prompt("default"),
2750            "base\n\nextra instructions"
2751        );
2752
2753        let blob = toml::to_string(&cfg).expect("serialize");
2754        assert!(!blob.contains("extra instructions"));
2755        let loaded: Config = toml::from_str(&blob).expect("deserialize");
2756        assert!(!loaded.prompt.is_customized());
2757    }
2758
2759    /// An absent `[compaction]` section must reproduce the constants exactly —
2760    /// making the policy configurable must not change anyone's behavior.
2761    #[test]
2762    fn absent_compaction_section_matches_the_built_in_policy() {
2763        let c: Config = toml::from_str("").expect("empty config parses");
2764        assert_eq!(
2765            c.compaction.policy(),
2766            crate::domain::CompactionPolicy::default(),
2767        );
2768    }
2769
2770    #[test]
2771    fn compaction_settings_reach_the_policy() {
2772        let c: Config = toml::from_str(
2773            "[compaction]\n\
2774             auto_enabled = false\n\
2775             auto_threshold_percent = 60\n\
2776             tail_turns = 5\n\
2777             tail_token_budget = 12000\n\
2778             summary_max_tokens = 3000\n",
2779        )
2780        .expect("compaction section parses");
2781        let policy = c.compaction.policy();
2782        assert!(!policy.auto_enabled);
2783        assert_eq!(policy.auto_threshold_percent, 60);
2784        assert_eq!(policy.tail_turns, 5);
2785        assert_eq!(policy.tail_token_budget, 12_000);
2786        assert_eq!(policy.summary_max_tokens, 3_000);
2787        // Unset keys keep their defaults rather than zeroing out.
2788        let defaults = crate::domain::CompactionPolicy::default();
2789        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2790    }
2791
2792    /// A hand-edited config degrades to the nearest workable value rather than
2793    /// putting compaction in a state where it silently cannot run.
2794    #[test]
2795    fn nonsense_compaction_settings_are_clamped() {
2796        let c: Config = toml::from_str(
2797            "[compaction]\n\
2798             auto_threshold_percent = 250\n\
2799             tail_turns = 0\n\
2800             tail_token_budget = 0\n\
2801             summary_max_tokens = 0\n\
2802             summarizer_input_token_budget = 0\n\
2803             tool_output_max_chars = 0\n\
2804             min_response_reserve_tokens = 50000\n\
2805             max_response_reserve_tokens = 1000\n",
2806        )
2807        .expect("config parses");
2808        let policy = c.compaction.policy();
2809        let defaults = crate::domain::CompactionPolicy::default();
2810
2811        assert_eq!(policy.auto_threshold_percent, 100, "percent clamps to 100");
2812        assert_eq!(
2813            policy.tail_turns, 1,
2814            "a checkpoint needs a live turn after it"
2815        );
2816        // Zero would mean "no budget at all"; fall back rather than disable.
2817        assert_eq!(policy.tail_token_budget, defaults.tail_token_budget);
2818        assert_eq!(policy.summary_max_tokens, defaults.summary_max_tokens);
2819        assert_eq!(
2820            policy.summarizer_input_token_budget,
2821            defaults.summarizer_input_token_budget
2822        );
2823        assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2824
2825        // Swapped reserve bounds are ordered, not obeyed: `response_reserve`
2826        // clamps with `.max(min).min(max)`, so an inverted pair would return
2827        // the smaller value and under-reserve on every single turn.
2828        assert_eq!(policy.min_response_reserve_tokens, 1_000);
2829        assert_eq!(policy.max_response_reserve_tokens, 50_000);
2830        assert!(policy.min_response_reserve_tokens <= policy.max_response_reserve_tokens);
2831    }
2832
2833    /// `auto_threshold_percent = 0` would compact on every single turn, before
2834    /// there is anything to compact.
2835    #[test]
2836    fn zero_compaction_threshold_clamps_up() {
2837        let c: Config =
2838            toml::from_str("[compaction]\nauto_threshold_percent = 0\n").expect("parses");
2839        assert_eq!(c.compaction.policy().auto_threshold_percent, 1);
2840    }
2841
2842    #[test]
2843    fn plan_config_defaults_parse_and_do_not_freeze() {
2844        // Absent section: dialog on, nothing pinned.
2845        let c: Config = toml::from_str("").expect("empty config parses");
2846        assert!(!c.plan.auto_approve);
2847        assert!(c.plan.post_approve.is_none());
2848        // Explicit values parse.
2849        let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
2850            .expect("plan section parses");
2851        assert!(c.plan.auto_approve);
2852        assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
2853        assert_eq!(
2854            toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
2855                .expect("wait parses")
2856                .plan
2857                .post_approve,
2858            Some(PlanPostApprove::Wait)
2859        );
2860        // The unset pin is never frozen into a saved config (Option +
2861        // skip_serializing_if), so a future default change still reaches
2862        // existing files.
2863        let blob = toml::to_string(&Config::default()).expect("serialize");
2864        assert!(!blob.contains("post_approve"));
2865    }
2866}