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