Skip to main content

mermaid_cli/app/
config.rs

1use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE};
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    /// Non-interactive mode configuration
30    #[serde(default)]
31    pub non_interactive: NonInteractiveConfig,
32
33    /// MCP server configurations
34    #[serde(default)]
35    pub mcp_servers: HashMap<String, McpServerConfig>,
36
37    /// User overrides + custom OpenAI-compatible providers. Keys are
38    /// provider names; matching a built-in registry entry overrides its
39    /// defaults, anything else defines a fully custom provider.
40    /// Example:
41    /// ```toml
42    /// [providers.groq]
43    /// api_key_env = "MY_GROQ_KEY"  # override default GROQ_API_KEY
44    ///
45    /// [providers.my-vllm]
46    /// base_url = "http://192.168.1.42:8000/v1"
47    /// api_key_env = "VLLM_KEY"
48    /// compat = "openai-effort"
49    /// ```
50    #[serde(default)]
51    pub providers: HashMap<String, UserProviderConfig>,
52
53    /// Per-model reasoning preferences keyed by full model ID
54    /// (`provider/name`). Set when the user runs `/reasoning <level>` or
55    /// Alt+T cycles while using a specific model — the new value sticks
56    /// for that model until changed. Falls back to
57    /// `default_model.reasoning` when no entry exists.
58    /// Example:
59    /// ```toml
60    /// [reasoning_per_model]
61    /// "<provider>/<model>" = "high"
62    /// "ollama/qwen3-coder:30b" = "low"
63    /// ```
64    #[serde(default)]
65    pub reasoning_per_model: HashMap<String, ReasoningLevel>,
66
67    /// Per-model Ollama `num_ctx` override set via `/context <n>`/`max`. Beats
68    /// auto-fit; cleared by `/context auto`. Keyed by model id.
69    ///
70    /// Example:
71    /// ```toml
72    /// [ollama_num_ctx_per_model]
73    /// "ollama/ornith:9b" = 131072
74    /// ```
75    #[serde(default)]
76    pub ollama_num_ctx_per_model: HashMap<String, u32>,
77
78    /// Named model profiles that agents/plugins can request without
79    /// hardcoding a concrete provider model. Values are full model IDs.
80    /// Example:
81    /// ```toml
82    /// [model_profiles]
83    /// fast = "ollama/qwen3-coder:14b"
84    /// large-context = "openai/<model>"
85    /// tool-strong = "anthropic/<model>"
86    /// vision = "gemini/gemini-2.5-pro"
87    /// cheap = "groq/llama-3.3-70b-versatile"
88    /// ```
89    #[serde(default)]
90    pub model_profiles: HashMap<String, String>,
91
92    /// Runtime safety policy. Defaults to `Ask` so mutations / shell /
93    /// network actions require approval out of the box; users opt into
94    /// `Auto` (LLM-vetted) or `FullAccess` deliberately.
95    #[serde(default)]
96    pub safety: SafetyConfig,
97
98    /// Durable semantic memory settings.
99    #[serde(default)]
100    pub memory: MemoryConfig,
101
102    /// Context-compaction settings.
103    #[serde(default)]
104    pub compaction: CompactionConfig,
105
106    /// Computer-use (desktop control) preferences.
107    #[serde(default)]
108    pub computer_use: ComputerUseConfig,
109
110    /// Subagent (`agent` tool) settings: drive timeout and user-defined
111    /// agent types.
112    #[serde(default)]
113    pub agents: AgentsConfig,
114
115    /// Runtime-only prompt customizations supplied by CLI flags. These are
116    /// deliberately skipped when saving config so one-off agent personas do
117    /// not pollute the user's persistent Mermaid settings.
118    #[serde(skip)]
119    pub prompt: PromptConfig,
120}
121
122#[derive(Debug, Clone, Default)]
123pub struct PromptConfig {
124    pub system_prompt: Option<String>,
125    pub append_system_prompt: Vec<String>,
126}
127
128impl PromptConfig {
129    pub fn render_system_prompt(&self, default_prompt: &str) -> String {
130        let mut rendered = self
131            .system_prompt
132            .as_deref()
133            .unwrap_or(default_prompt)
134            .trim_end()
135            .to_string();
136
137        for extra in &self.append_system_prompt {
138            let extra = extra.trim();
139            if extra.is_empty() {
140                continue;
141            }
142            if !rendered.is_empty() {
143                rendered.push_str("\n\n");
144            }
145            rendered.push_str(extra);
146        }
147
148        rendered
149    }
150
151    pub fn is_customized(&self) -> bool {
152        self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
153    }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(default)]
158pub struct SafetyConfig {
159    pub mode: SafetyMode,
160    pub checkpoint_on_mutation: bool,
161    #[serde(default)]
162    pub overrides: Vec<PolicyOverride>,
163    /// Model id the `Auto`-mode safety classifier uses to vet borderline
164    /// actions. `None` ⇒ vet with the session's active model. Set this to
165    /// point the vet at a cheaper/faster model than the one driving the work.
166    #[serde(default)]
167    pub auto_classifier_model: Option<String>,
168    /// Headless escape hatch: when true, non-replayable tools (web/mcp/
169    /// subagent/computer_use) are allowed to PROCEED on an `Ask` decision in a
170    /// headless run (no approval UI) instead of being blocked. Default `false`
171    /// — `mermaid run` in `ask` mode otherwise refuses these. Set via
172    /// `--allow-untrusted-tools` or config for CI that needs them.
173    #[serde(default)]
174    pub allow_untrusted_headless_tools: bool,
175}
176
177impl Default for SafetyConfig {
178    fn default() -> Self {
179        Self {
180            // Safe-by-default: the first run prompts for approval on
181            // mutations / shell / network rather than silently auto-allowing
182            // everything. FullAccess remains available via config.
183            mode: SafetyMode::Ask,
184            checkpoint_on_mutation: true,
185            overrides: Vec::new(),
186            auto_classifier_model: None,
187            allow_untrusted_headless_tools: false,
188        }
189    }
190}
191
192/// Durable semantic memory settings (v0.10.0).
193#[derive(Debug, Clone, Serialize, Deserialize)]
194#[serde(default)]
195pub struct MemoryConfig {
196    /// Master switch for agent memory (the tool, the always-loaded index, and
197    /// the slash commands). On by default.
198    pub enabled: bool,
199    /// Byte cap on the always-loaded memory index before it's truncated.
200    pub index_cap_bytes: usize,
201}
202
203impl Default for MemoryConfig {
204    fn default() -> Self {
205        Self {
206            enabled: true,
207            index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
208        }
209    }
210}
211
212/// Context-compaction settings.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214#[serde(default)]
215pub struct CompactionConfig {
216    /// Cap on consecutive auto-compact-and-continue recoveries after a
217    /// context-window truncation, before the run stops and shows the manual
218    /// levers (`/context max`, `/context offload on`). The counter resets
219    /// whenever the run makes progress, so this bounds only no-progress
220    /// thrashing on a too-small window. `0` means uncapped.
221    ///
222    /// Example:
223    /// ```toml
224    /// [compaction]
225    /// max_truncation_recoveries = 0  # never give up on its own
226    /// ```
227    pub max_truncation_recoveries: u8,
228}
229
230impl Default for CompactionConfig {
231    fn default() -> Self {
232        Self {
233            max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
234        }
235    }
236}
237
238/// Computer-use (desktop control) preferences.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(default)]
241pub struct ComputerUseConfig {
242    /// After a successful click / type_text / press_key, auto-capture the
243    /// focused window and attach it inline so the model can verify the result.
244    /// On by default (non-breaking); set false to cut the per-action capture
245    /// cost + image tokens when visual feedback isn't needed. The model can
246    /// still call `screenshot` explicitly.
247    pub auto_screenshot: bool,
248}
249
250impl Default for ComputerUseConfig {
251    fn default() -> Self {
252        Self {
253            auto_screenshot: true,
254        }
255    }
256}
257
258/// Subagent (`agent` tool) settings.
259#[derive(Debug, Clone, Serialize, Deserialize)]
260#[serde(default)]
261pub struct AgentsConfig {
262    /// Hard ceiling on one subagent drive's wall-clock runtime, in seconds.
263    /// `0` falls back to the built-in default (1200 = 20 minutes).
264    pub timeout_secs: u64,
265    /// User-defined agent types for the `agent` tool's `type` arg, keyed by
266    /// type name. A custom name shadows a built-in (`general`, `explore`),
267    /// so `[agents.types.explore]` retunes the built-in Explore.
268    /// ```toml
269    /// [agents.types.scout]
270    /// tools = ["read_file", "execute_command"]  # omit for the full child set
271    /// safety = "read_only"    # ceiling — the child never runs looser
272    /// preamble = "You are a scout: find and report, fast."
273    /// model = "ollama/qwen3:8b"  # default model; per-call `model` arg wins
274    /// ```
275    pub types: HashMap<String, AgentTypeConfig>,
276}
277
278impl Default for AgentsConfig {
279    fn default() -> Self {
280        Self {
281            timeout_secs: 1200,
282            types: HashMap::new(),
283        }
284    }
285}
286
287/// One user-defined agent type (see [`AgentsConfig::types`]). Every field is
288/// optional; an empty table behaves like the built-in `general` type.
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290#[serde(default)]
291pub struct AgentTypeConfig {
292    /// Tool names the child registry is filtered to. Valid names:
293    /// `read_file`, `write_file`, `edit_file`, `delete_file`,
294    /// `create_directory`, `execute_command`, `web_search`, `web_fetch`,
295    /// `mcp`. Omit for the full child set.
296    pub tools: Option<Vec<String>>,
297    /// Safety ceiling (canonical mode name: `read_only`/`ask`/`auto`/
298    /// `full_access`). The child runs at the LESS permissive of the parent's
299    /// live mode and this ceiling.
300    pub safety: Option<String>,
301    /// Extra system-prompt block appended after the child's subagent
302    /// contract.
303    pub preamble: Option<String>,
304    /// Default model id for this type (e.g. `"ollama/qwen3:8b"`); a per-call
305    /// `model` arg wins over it.
306    pub model: Option<String>,
307}
308
309/// User-supplied OpenAI-compatible provider configuration. All fields are
310/// optional — when matching a built-in registry entry, only the supplied
311/// fields override; the rest fall back to the registry defaults. For
312/// fully custom providers, `base_url` and `api_key_env` are required.
313#[derive(Clone, Default, Serialize, Deserialize)]
314pub struct UserProviderConfig {
315    /// Override base URL for `/chat/completions` (None = use built-in
316    /// registry default; required for fully custom providers).
317    #[serde(default)]
318    pub base_url: Option<String>,
319    /// Env var name to read the API key from (None = use the built-in
320    /// registry default like `GROQ_API_KEY`; required for fully custom
321    /// providers).
322    #[serde(default)]
323    pub api_key_env: Option<String>,
324    /// Extra HTTP headers sent on every request to this provider.
325    #[serde(default)]
326    pub extra_headers: HashMap<String, String>,
327    /// For fully custom providers (no built-in registry entry), declares
328    /// which OpenAI-compatible shape the endpoint speaks. Ignored when
329    /// the provider name matches a built-in registry entry. Values:
330    /// `"openai"` (no reasoning), `"openai-effort"` (`reasoning_effort`
331    /// field), `"openrouter"` (nested `reasoning: {effort}` object).
332    #[serde(default)]
333    pub compat: Option<String>,
334    /// Optional preferred model — surfaced by `mermaid status` and used
335    /// as the default when the user picks this provider with no model
336    /// suffix.
337    #[serde(default)]
338    pub default_model: Option<String>,
339}
340
341/// MCP server configuration
342#[derive(Clone, Serialize, Deserialize)]
343pub struct McpServerConfig {
344    /// Command to execute (e.g., "npx", "node", "python")
345    pub command: String,
346    /// Command-line arguments
347    #[serde(default)]
348    pub args: Vec<String>,
349    /// Environment variables for the server process
350    #[serde(default)]
351    pub env: HashMap<String, String>,
352}
353
354/// Mask a header/env map for `Debug`: keys are kept (so you can still see which
355/// vars are set) but values are never rendered — they hold secrets like API keys
356/// and `Authorization` tokens (#F12). A `BTreeMap` keeps the output deterministic.
357fn debug_masked_map(
358    map: &HashMap<String, String>,
359) -> std::collections::BTreeMap<&str, &'static str> {
360    map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
361}
362
363// Manual `Debug` for the secret-bearing config structs so a `{:?}` (into
364// tracing, a panic, or an error) cannot dump provider keys / Authorization
365// headers / MCP env secrets. `Config` keeps its derived `Debug`, which now
366// recurses through these redacting impls (#F12).
367impl std::fmt::Debug for McpServerConfig {
368    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369        f.debug_struct("McpServerConfig")
370            .field("command", &self.command)
371            // args may carry an inline secret (e.g. `--api-key=sk-...`).
372            .field(
373                "args",
374                &self
375                    .args
376                    .iter()
377                    .map(|a| crate::utils::redact_secrets(a))
378                    .collect::<Vec<_>>(),
379            )
380            .field("env", &debug_masked_map(&self.env))
381            .finish()
382    }
383}
384
385impl std::fmt::Debug for UserProviderConfig {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.debug_struct("UserProviderConfig")
388            .field("base_url", &self.base_url)
389            .field("api_key_env", &self.api_key_env)
390            .field("extra_headers", &debug_masked_map(&self.extra_headers))
391            .field("compat", &self.compat)
392            .field("default_model", &self.default_model)
393            .finish()
394    }
395}
396
397/// Default model settings
398#[derive(Debug, Clone, Serialize, Deserialize)]
399#[serde(default)]
400pub struct ModelSettings {
401    /// Model provider (ollama, openai, anthropic)
402    pub provider: String,
403    /// Model name
404    pub name: String,
405    /// Temperature for generation
406    pub temperature: f32,
407    /// Maximum tokens to generate
408    pub max_tokens: usize,
409    /// Default reasoning depth used for new sessions when no `--reasoning`
410    /// flag is given. Each adapter snaps this onto the closest level the
411    /// model actually supports via `nearest_effort()`.
412    pub reasoning: ReasoningLevel,
413}
414
415impl Default for ModelSettings {
416    fn default() -> Self {
417        Self {
418            provider: String::new(),
419            name: String::new(),
420            temperature: DEFAULT_TEMPERATURE,
421            max_tokens: DEFAULT_MAX_TOKENS,
422            reasoning: ReasoningLevel::default(),
423        }
424    }
425}
426
427/// Ollama configuration
428#[derive(Debug, Clone, Serialize, Deserialize)]
429#[serde(default)]
430pub struct OllamaConfig {
431    /// Ollama server host
432    pub host: String,
433    /// Ollama server port
434    pub port: u16,
435    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
436    /// Lower values free up VRAM for larger models at the cost of speed
437    pub num_gpu: Option<i32>,
438    /// Number of CPU threads for processing offloaded layers
439    /// Higher values improve CPU inference speed for large models
440    pub num_thread: Option<i32>,
441    /// Context window size (number of tokens)
442    /// Larger values allow longer conversations but use more memory
443    pub num_ctx: Option<i32>,
444    /// Enable NUMA optimization for multi-CPU systems
445    pub numa: Option<bool>,
446    /// Allow Ollama to offload the model/KV cache to system RAM when it doesn't
447    /// fit VRAM. **Disabled by default**: RAM offload is 5–20× slower, so by
448    /// default Mermaid auto-fits `num_ctx` to VRAM (keeping the model on the
449    /// GPU). Enable to trade speed for a larger context window. Toggle in-app
450    /// with `/context offload on|off`.
451    pub allow_ram_offload: bool,
452    /// Optional hard cap on the auto-fitted context window (in tokens). `None`
453    /// lets auto-fit use the full memory budget up to the model's max; set this
454    /// to bound it (e.g. to leave VRAM headroom for other apps).
455    pub max_auto_num_ctx: Option<usize>,
456    /// Start `ollama serve` automatically when the configured server is local
457    /// (loopback) and not running — the user should never have to leave
458    /// mermaid to start Ollama. Disable if you manage the server yourself
459    /// (e.g. systemd with custom flags). Never applies to remote hosts.
460    pub auto_start: bool,
461}
462
463impl Default for OllamaConfig {
464    fn default() -> Self {
465        Self {
466            host: String::from("localhost"),
467            port: DEFAULT_OLLAMA_PORT,
468            num_gpu: None,            // Let Ollama auto-detect
469            num_thread: None,         // Let Ollama auto-detect
470            num_ctx: None,            // Use model default (overrides auto-fit)
471            numa: None,               // Auto-detect
472            allow_ram_offload: false, // VRAM-only by default (RAM is slow)
473            max_auto_num_ctx: None,   // No cap; auto-fit to the memory budget
474            auto_start: true,         // A dead local server is mermaid's problem
475        }
476    }
477}
478
479/// Backend for the `web_fetch` tool.
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
481#[serde(rename_all = "lowercase")]
482pub enum FetchBackend {
483    /// Fetch the URL directly from this machine and convert it to markdown.
484    /// No API key, no third party — works for any user with network access.
485    #[default]
486    Native,
487    /// Route through Ollama Cloud's `/api/web_fetch` (needs `OLLAMA_API_KEY`).
488    Ollama,
489}
490
491/// Backend for the `web_search` tool.
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
493#[serde(rename_all = "lowercase")]
494pub enum SearchBackend {
495    /// Zero-config default: Ollama Cloud when `OLLAMA_API_KEY` is set, otherwise
496    /// an auto-managed local SearXNG container (mermaid starts it on the first
497    /// search and tears it down on exit). The user configures nothing.
498    #[default]
499    Auto,
500    /// Ollama Cloud's `/api/web_search` (needs `OLLAMA_API_KEY`).
501    Ollama,
502    /// A self-hosted SearXNG instance queried at `searxng_url` — keyless.
503    Searxng,
504}
505
506/// Web tool backend configuration.
507///
508/// ```toml
509/// [web]
510/// fetch_backend = "native"   # or "ollama"
511/// search_backend = "auto"    # or "ollama" / "searxng"
512/// searxng_url = "http://localhost:8080"
513/// ```
514#[derive(Debug, Clone, Serialize, Deserialize)]
515#[serde(default)]
516pub struct WebConfig {
517    /// Backend for `web_fetch`. `native` (default) fetches the URL from this
518    /// machine and needs no key; `ollama` uses Ollama Cloud.
519    pub fetch_backend: FetchBackend,
520    /// Backend for `web_search`. `auto` (default) uses Ollama Cloud when
521    /// `OLLAMA_API_KEY` is set and otherwise auto-manages a local SearXNG
522    /// container. `ollama` forces Ollama Cloud; `searxng` forces a self-hosted
523    /// instance at `searxng_url`.
524    pub search_backend: SearchBackend,
525    /// SearXNG base URL, used when `search_backend = "searxng"` (your own
526    /// instance). The instance must have the JSON output format enabled
527    /// (`search.formats` includes `json`). The `auto` managed instance ignores
528    /// this and picks its own port.
529    pub searxng_url: String,
530}
531
532impl Default for WebConfig {
533    fn default() -> Self {
534        Self {
535            fetch_backend: FetchBackend::Native,
536            search_backend: SearchBackend::Auto,
537            searxng_url: String::from("http://localhost:8080"),
538        }
539    }
540}
541
542/// Non-interactive mode configuration
543#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(default)]
545pub struct NonInteractiveConfig {
546    /// Output format (text, json, markdown)
547    pub output_format: String,
548    /// Maximum tokens to generate
549    pub max_tokens: usize,
550    /// Don't execute agent actions (dry run)
551    pub no_execute: bool,
552}
553
554impl Default for NonInteractiveConfig {
555    fn default() -> Self {
556        Self {
557            output_format: String::from("text"),
558            max_tokens: DEFAULT_MAX_TOKENS,
559            no_execute: false,
560        }
561    }
562}
563
564/// Load configuration from single config file
565/// Priority: config file > defaults (that's it - no merging, no env vars)
566pub fn load_config() -> Result<Config> {
567    let config_path = get_config_path()?;
568
569    if config_path.exists() {
570        let toml_str = std::fs::read_to_string(&config_path)
571            .with_context(|| format!("Failed to read {}", config_path.display()))?;
572        let config: Config = toml::from_str(&toml_str).with_context(|| {
573            format!(
574                "Failed to parse {}. Run 'mermaid init' to regenerate.",
575                config_path.display()
576            )
577        })?;
578        Ok(config)
579    } else {
580        Ok(Config::default())
581    }
582}
583
584/// Like [`load_config`] but never fails: if a config file exists yet is
585/// malformed, warn on stderr and fall back to defaults — instead of silently
586/// swallowing the error (#111). An *absent* file is not an error (`load_config`
587/// returns defaults for it), so the warning fires only for a genuine
588/// read/parse failure the user should know about.
589pub fn load_config_or_warn() -> Config {
590    match load_config() {
591        Ok(config) => config,
592        Err(e) => {
593            // A TOML parse error renders the offending source line, which can be
594            // a secret-bearing one (`extra_headers`/`env`/`api_key_env`); scrub
595            // credential-shaped content before it reaches stderr (#F13).
596            eprintln!(
597                "mermaid: {}",
598                crate::utils::redact_secrets(&format!("{e:#}"))
599            );
600            Config::default()
601        },
602    }
603}
604
605/// Get the path to the single config file
606pub fn get_config_path() -> Result<PathBuf> {
607    Ok(get_config_dir()?.join("config.toml"))
608}
609
610/// Get the configuration directory
611pub fn get_config_dir() -> Result<PathBuf> {
612    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
613        let config_dir = proj_dirs.config_dir();
614        std::fs::create_dir_all(config_dir)?;
615        Ok(config_dir.to_path_buf())
616    } else {
617        // Fallback to home directory
618        let home = std::env::var("HOME")
619            .or_else(|_| std::env::var("USERPROFILE"))
620            .context("Could not determine home directory")?;
621        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
622        std::fs::create_dir_all(&config_dir)?;
623        Ok(config_dir)
624    }
625}
626
627/// Save configuration to file
628pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
629    let path = if let Some(p) = path {
630        p
631    } else {
632        get_config_dir()?.join("config.toml")
633    };
634
635    let toml_string = toml::to_string_pretty(config)?;
636
637    // The config can carry literal secrets — `mcp_servers[].env`,
638    // `mcp_servers[].args`, and `providers[].extra_headers` all accept inline
639    // credential values — so it must not be left world-readable at umask. On
640    // Unix, create it 0600 from the start (and tighten an already-existing
641    // file, whose perms a fresh `mode()` would not touch). Windows uses ACLs;
642    // leave its default.
643    #[cfg(unix)]
644    {
645        use std::io::Write;
646        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
647        let mut file = std::fs::OpenOptions::new()
648            .write(true)
649            .create(true)
650            .truncate(true)
651            .mode(0o600)
652            .open(&path)
653            .with_context(|| format!("Failed to write config to {}", path.display()))?;
654        // `mode()` only applies on create; tighten a pre-existing config too.
655        let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
656        file.write_all(toml_string.as_bytes())
657            .with_context(|| format!("Failed to write config to {}", path.display()))?;
658    }
659    #[cfg(not(unix))]
660    {
661        std::fs::write(&path, toml_string)
662            .with_context(|| format!("Failed to write config to {}", path.display()))?;
663    }
664
665    Ok(())
666}
667
668/// Create a default configuration file if it doesn't exist
669pub fn init_config() -> Result<()> {
670    let config_file = get_config_path()?;
671
672    if config_file.exists() {
673        println!("Configuration already exists at: {}", config_file.display());
674    } else {
675        let default_config = Config::default();
676        save_config(&default_config, Some(config_file.clone()))?;
677        println!("Created configuration at: {}", config_file.display());
678    }
679
680    Ok(())
681}
682
683/// Serializes the read-modify-write persistence path. The `persist_*` helpers
684/// run as concurrent detached tasks (dispatched by the effect runner) that all
685/// load → mutate → save the same file; without a lock two quick toggles
686/// (`/model` then Alt+T) can interleave their loads and lose one write. Held
687/// only across the synchronous fs work — never across an `.await`.
688static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
689
690/// Load the config, apply `mutate`, and save it back — under `PERSIST_LOCK` so
691/// concurrent persists can't clobber each other. On a malformed config the error
692/// propagates (the caller drops it) rather than overwriting the file with
693/// defaults (#111).
694fn update_config(mutate: impl FnOnce(&mut Config)) -> Result<()> {
695    let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
696    let mut config = load_config()?;
697    mutate(&mut config);
698    save_config(&config, None)
699}
700
701/// Persist the last used model to config file.
702pub fn persist_last_model(model: &str) -> Result<()> {
703    update_config(|config| config.last_used_model = Some(model.to_string()))
704}
705
706/// Persist the user's default reasoning level to config file. Used by the
707/// `/reasoning` slash command and the Alt+T cycle handler so the choice survives
708/// across sessions.
709pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
710    update_config(|config| config.default_model.reasoning = level)
711}
712
713/// Persist a reasoning level for a specific model ID
714/// (e.g. `<provider>/<model>`). The TUI calls this from Alt+T,
715/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
716/// the choice sticks per-model rather than bleeding into other models on
717/// next session start.
718pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
719    update_config(|config| {
720        config
721            .reasoning_per_model
722            .insert(model_id.to_string(), level);
723    })
724}
725
726/// Persist (or clear) a per-model Ollama `num_ctx` override. `Some(n)` sets it,
727/// `None` removes the entry (returning that model to auto-fit).
728pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
729    update_config(|config| match num_ctx {
730        Some(n) => {
731            config
732                .ollama_num_ctx_per_model
733                .insert(model_id.to_string(), n);
734        },
735        None => {
736            config.ollama_num_ctx_per_model.remove(model_id);
737        },
738    })
739}
740
741/// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
742pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
743    update_config(|config| config.ollama.allow_ram_offload = enabled)
744}
745
746/// Resolve which model to use: CLI arg > last_used > default_model > any available
747pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
748    if let Some(model) = cli_model {
749        if let Some(resolved) = resolve_model_profile_alias(model, config)? {
750            return Ok(resolved);
751        }
752        return Ok(model.to_string());
753    }
754    if let Some(last_model) = &config.last_used_model {
755        if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
756            return Ok(resolved);
757        }
758        return Ok(last_model.clone());
759    }
760    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
761        return Ok(format!(
762            "{}/{}",
763            config.default_model.provider, config.default_model.name
764        ));
765    }
766    let available = crate::ollama::require_any_model(config).await?;
767    // `require_any_model` already errors on empty, so this `.first()` is
768    // never `None` in practice. Use `.first()` over `[0]` so the precondition
769    // is enforced by the type system instead of by a comment.
770    let first = available
771        .first()
772        .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
773    Ok(format!("ollama/{}", first))
774}
775
776fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
777    let profile = requested.strip_prefix("profile:").unwrap_or(requested);
778    if let Some(model) = config.model_profiles.get(profile) {
779        anyhow::ensure!(
780            !model.trim().is_empty(),
781            "model profile `{}` is configured with an empty model id",
782            profile
783        );
784        return Ok(Some(model.clone()));
785    }
786    if requested.starts_with("profile:") {
787        anyhow::bail!(
788            "model profile `{}` is not configured; add it under [model_profiles]",
789            profile
790        );
791    }
792    Ok(None)
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    /// Configs persisted before Step 4 don't have a `reasoning` field on
800    /// `[default_model]`. Loading them must succeed and yield the
801    /// `Medium` default — otherwise existing user configs break on
802    /// upgrade.
803    #[test]
804    fn model_settings_deserializes_without_reasoning_field() {
805        let toml_blob = r#"
806            provider = "ollama"
807            name = "qwen3-coder:30b"
808            temperature = 0.7
809            max_tokens = 4096
810        "#;
811        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
812        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
813        assert_eq!(settings.provider, "ollama");
814    }
815
816    #[test]
817    fn model_settings_round_trips_reasoning_high() {
818        let original = ModelSettings {
819            provider: "anthropic".to_string(),
820            name: "claude-sonnet-4-6".to_string(),
821            temperature: 0.5,
822            max_tokens: 8192,
823            reasoning: ReasoningLevel::High,
824        };
825        let toml_blob = toml::to_string(&original).expect("serialize");
826        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
827        assert_eq!(back.reasoning, ReasoningLevel::High);
828        assert_eq!(back.name, "claude-sonnet-4-6");
829    }
830
831    #[test]
832    fn agents_config_defaults_and_parses_custom_types() {
833        // Absent section → defaults (20-minute timeout, no custom types).
834        let config: Config = toml::from_str("").expect("empty config parses");
835        assert_eq!(config.agents.timeout_secs, 1200);
836        assert!(config.agents.types.is_empty());
837
838        let config: Config = toml::from_str(
839            r#"
840[agents]
841timeout_secs = 300
842
843[agents.types.scout]
844tools = ["read_file", "execute_command"]
845safety = "read_only"
846preamble = "You are a scout."
847model = "ollama/qwen3:8b"
848"#,
849        )
850        .expect("agents section parses");
851        assert_eq!(config.agents.timeout_secs, 300);
852        let scout = &config.agents.types["scout"];
853        assert_eq!(
854            scout.tools.as_deref(),
855            Some(&["read_file".to_string(), "execute_command".to_string()][..])
856        );
857        assert_eq!(scout.safety.as_deref(), Some("read_only"));
858        assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
859    }
860
861    #[test]
862    fn configured_model_profile_resolves_explicit_alias() {
863        let mut config = Config::default();
864        config
865            .model_profiles
866            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
867        assert_eq!(
868            resolve_model_profile_alias("fast", &config).unwrap(),
869            Some("ollama/qwen3-coder:14b".to_string())
870        );
871        assert_eq!(
872            resolve_model_profile_alias("profile:fast", &config).unwrap(),
873            Some("ollama/qwen3-coder:14b".to_string())
874        );
875    }
876
877    #[test]
878    fn profile_prefix_requires_configuration() {
879        let config = Config::default();
880        assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
881        assert_eq!(
882            resolve_model_profile_alias("vision", &config).unwrap(),
883            None
884        );
885    }
886
887    /// `persist_default_reasoning` writes to the real config path, so
888    /// this test goes through `save_config(_, Some(path))` directly to
889    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
890    /// Uses `std::env::temp_dir` (matching the pattern in
891    /// `session::conversation` and `utils::logger`) — no external
892    /// `tempfile` crate dependency.
893    #[test]
894    fn save_and_reload_preserves_reasoning_field() {
895        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
896        std::fs::create_dir_all(&dir).expect("create temp dir");
897        let path = dir.join("config.toml");
898
899        let mut cfg = Config::default();
900        cfg.default_model.provider = "ollama".to_string();
901        cfg.default_model.name = "qwen3-coder:30b".to_string();
902        cfg.default_model.reasoning = ReasoningLevel::Low;
903
904        save_config(&cfg, Some(path.clone())).expect("save");
905
906        let blob = std::fs::read_to_string(&path).expect("read");
907        let loaded: Config = toml::from_str(&blob).expect("parse back");
908        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
909
910        let _ = std::fs::remove_dir_all(&dir);
911    }
912
913    /// Per-model entries serialize as a TOML table with quoted keys (the
914    /// model IDs contain `/`). This test verifies the round-trip works
915    /// through both serialization and deserialization, matching what
916    /// `persist_reasoning_for_model` would produce in real use.
917    #[test]
918    fn save_and_reload_preserves_reasoning_per_model_table() {
919        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
920        std::fs::create_dir_all(&dir).expect("create temp dir");
921        let path = dir.join("config.toml");
922
923        let mut cfg = Config::default();
924        cfg.reasoning_per_model.insert(
925            "anthropic/claude-sonnet-4-6".to_string(),
926            ReasoningLevel::High,
927        );
928        cfg.reasoning_per_model
929            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
930
931        save_config(&cfg, Some(path.clone())).expect("save");
932
933        let blob = std::fs::read_to_string(&path).expect("read");
934        let loaded: Config = toml::from_str(&blob).expect("parse back");
935        assert_eq!(
936            loaded
937                .reasoning_per_model
938                .get("anthropic/claude-sonnet-4-6"),
939            Some(&ReasoningLevel::High)
940        );
941        assert_eq!(
942            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
943            Some(&ReasoningLevel::Low)
944        );
945
946        let _ = std::fs::remove_dir_all(&dir);
947    }
948
949    /// `/context <n>` overrides round-trip through the per-model TOML table, and
950    /// the offload toggle persists on `[ollama]`.
951    #[test]
952    fn save_and_reload_preserves_ollama_context_overrides() {
953        let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
954        std::fs::create_dir_all(&dir).expect("create temp dir");
955        let path = dir.join("config.toml");
956
957        let mut cfg = Config::default();
958        cfg.ollama_num_ctx_per_model
959            .insert("ollama/ornith:9b".to_string(), 131_072);
960        cfg.ollama.allow_ram_offload = true;
961        cfg.ollama.max_auto_num_ctx = Some(65_536);
962
963        save_config(&cfg, Some(path.clone())).expect("save");
964        let blob = std::fs::read_to_string(&path).expect("read");
965        let loaded: Config = toml::from_str(&blob).expect("parse back");
966
967        assert_eq!(
968            loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
969            Some(&131_072)
970        );
971        assert!(loaded.ollama.allow_ram_offload);
972        assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
973
974        let _ = std::fs::remove_dir_all(&dir);
975    }
976
977    /// Older configs have neither the per-model num_ctx table nor the new
978    /// `[ollama]` keys; loading must default cleanly (empty map, offload off).
979    #[test]
980    fn config_deserializes_without_ollama_context_keys() {
981        let toml_blob = r#"
982[ollama]
983host = "localhost"
984port = 11434
985"#;
986        let cfg: Config = toml::from_str(toml_blob).expect("parse");
987        assert!(cfg.ollama_num_ctx_per_model.is_empty());
988        assert!(!cfg.ollama.allow_ram_offload);
989        assert_eq!(cfg.ollama.max_auto_num_ctx, None);
990        // Configs from before the auto-start knob default it ON — reviving a
991        // dead local server is the out-of-the-box behavior.
992        assert!(cfg.ollama.auto_start);
993    }
994
995    /// Configs from before Step 5b don't have a `reasoning_per_model`
996    /// section. Loading them must succeed with an empty map — otherwise
997    /// upgrade breaks every existing user.
998    #[test]
999    fn config_deserializes_without_reasoning_per_model() {
1000        let toml_blob = r#"
1001            last_used_model = "ollama/qwen3-coder:30b"
1002
1003            [default_model]
1004            provider = "ollama"
1005            name = "qwen3-coder:30b"
1006            temperature = 0.7
1007            max_tokens = 4096
1008        "#;
1009        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
1010        assert!(cfg.reasoning_per_model.is_empty());
1011        assert!(!cfg.prompt.is_customized());
1012    }
1013
1014    /// Config holds inline-secret-capable fields (`mcp_servers[].env`, `args`,
1015    /// `providers[].extra_headers`), so it must be written owner-only rather
1016    /// than inheriting a world-readable umask.
1017    #[cfg(unix)]
1018    #[test]
1019    fn save_config_writes_owner_only_perms() {
1020        use std::os::unix::fs::PermissionsExt;
1021        let dir = std::env::temp_dir().join("mermaid_test_config_perms");
1022        std::fs::create_dir_all(&dir).expect("create temp dir");
1023        let path = dir.join("config.toml");
1024        // Pre-create a world-readable file to prove we also tighten existing.
1025        std::fs::write(&path, "stale").expect("seed");
1026        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
1027
1028        save_config(&Config::default(), Some(path.clone())).expect("save");
1029        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1030        assert_eq!(mode, 0o600, "config must be written owner-only");
1031
1032        let _ = std::fs::remove_dir_all(&dir);
1033    }
1034
1035    #[test]
1036    fn config_defaults_computer_use_auto_screenshot_on() {
1037        // An empty/legacy config must keep the auto-screenshot behavior (#98).
1038        let cfg: Config = toml::from_str("").expect("empty config");
1039        assert!(cfg.computer_use.auto_screenshot);
1040    }
1041
1042    #[test]
1043    fn prompt_config_replaces_and_appends_without_persisting() {
1044        let mut cfg = Config::default();
1045        cfg.prompt.system_prompt = Some("base".to_string());
1046        cfg.prompt
1047            .append_system_prompt
1048            .push("extra instructions".to_string());
1049
1050        assert_eq!(
1051            cfg.prompt.render_system_prompt("default"),
1052            "base\n\nextra instructions"
1053        );
1054
1055        let blob = toml::to_string(&cfg).expect("serialize");
1056        assert!(!blob.contains("extra instructions"));
1057        let loaded: Config = toml::from_str(&blob).expect("deserialize");
1058        assert!(!loaded.prompt.is_customized());
1059    }
1060}