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