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