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}
457
458impl Default for OllamaConfig {
459    fn default() -> Self {
460        Self {
461            host: String::from("localhost"),
462            port: DEFAULT_OLLAMA_PORT,
463            num_gpu: None,            // Let Ollama auto-detect
464            num_thread: None,         // Let Ollama auto-detect
465            num_ctx: None,            // Use model default (overrides auto-fit)
466            numa: None,               // Auto-detect
467            allow_ram_offload: false, // VRAM-only by default (RAM is slow)
468            max_auto_num_ctx: None,   // No cap; auto-fit to the memory budget
469        }
470    }
471}
472
473/// Backend for the `web_fetch` tool.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
475#[serde(rename_all = "lowercase")]
476pub enum FetchBackend {
477    /// Fetch the URL directly from this machine and convert it to markdown.
478    /// No API key, no third party — works for any user with network access.
479    #[default]
480    Native,
481    /// Route through Ollama Cloud's `/api/web_fetch` (needs `OLLAMA_API_KEY`).
482    Ollama,
483}
484
485/// Backend for the `web_search` tool.
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
487#[serde(rename_all = "lowercase")]
488pub enum SearchBackend {
489    /// Zero-config default: Ollama Cloud when `OLLAMA_API_KEY` is set, otherwise
490    /// an auto-managed local SearXNG container (mermaid starts it on the first
491    /// search and tears it down on exit). The user configures nothing.
492    #[default]
493    Auto,
494    /// Ollama Cloud's `/api/web_search` (needs `OLLAMA_API_KEY`).
495    Ollama,
496    /// A self-hosted SearXNG instance queried at `searxng_url` — keyless.
497    Searxng,
498}
499
500/// Web tool backend configuration.
501///
502/// ```toml
503/// [web]
504/// fetch_backend = "native"   # or "ollama"
505/// search_backend = "auto"    # or "ollama" / "searxng"
506/// searxng_url = "http://localhost:8080"
507/// ```
508#[derive(Debug, Clone, Serialize, Deserialize)]
509#[serde(default)]
510pub struct WebConfig {
511    /// Backend for `web_fetch`. `native` (default) fetches the URL from this
512    /// machine and needs no key; `ollama` uses Ollama Cloud.
513    pub fetch_backend: FetchBackend,
514    /// Backend for `web_search`. `auto` (default) uses Ollama Cloud when
515    /// `OLLAMA_API_KEY` is set and otherwise auto-manages a local SearXNG
516    /// container. `ollama` forces Ollama Cloud; `searxng` forces a self-hosted
517    /// instance at `searxng_url`.
518    pub search_backend: SearchBackend,
519    /// SearXNG base URL, used when `search_backend = "searxng"` (your own
520    /// instance). The instance must have the JSON output format enabled
521    /// (`search.formats` includes `json`). The `auto` managed instance ignores
522    /// this and picks its own port.
523    pub searxng_url: String,
524}
525
526impl Default for WebConfig {
527    fn default() -> Self {
528        Self {
529            fetch_backend: FetchBackend::Native,
530            search_backend: SearchBackend::Auto,
531            searxng_url: String::from("http://localhost:8080"),
532        }
533    }
534}
535
536/// Non-interactive mode configuration
537#[derive(Debug, Clone, Serialize, Deserialize)]
538#[serde(default)]
539pub struct NonInteractiveConfig {
540    /// Output format (text, json, markdown)
541    pub output_format: String,
542    /// Maximum tokens to generate
543    pub max_tokens: usize,
544    /// Don't execute agent actions (dry run)
545    pub no_execute: bool,
546}
547
548impl Default for NonInteractiveConfig {
549    fn default() -> Self {
550        Self {
551            output_format: String::from("text"),
552            max_tokens: DEFAULT_MAX_TOKENS,
553            no_execute: false,
554        }
555    }
556}
557
558/// Load configuration from single config file
559/// Priority: config file > defaults (that's it - no merging, no env vars)
560pub fn load_config() -> Result<Config> {
561    let config_path = get_config_path()?;
562
563    if config_path.exists() {
564        let toml_str = std::fs::read_to_string(&config_path)
565            .with_context(|| format!("Failed to read {}", config_path.display()))?;
566        let config: Config = toml::from_str(&toml_str).with_context(|| {
567            format!(
568                "Failed to parse {}. Run 'mermaid init' to regenerate.",
569                config_path.display()
570            )
571        })?;
572        Ok(config)
573    } else {
574        Ok(Config::default())
575    }
576}
577
578/// Like [`load_config`] but never fails: if a config file exists yet is
579/// malformed, warn on stderr and fall back to defaults — instead of silently
580/// swallowing the error (#111). An *absent* file is not an error (`load_config`
581/// returns defaults for it), so the warning fires only for a genuine
582/// read/parse failure the user should know about.
583pub fn load_config_or_warn() -> Config {
584    match load_config() {
585        Ok(config) => config,
586        Err(e) => {
587            // A TOML parse error renders the offending source line, which can be
588            // a secret-bearing one (`extra_headers`/`env`/`api_key_env`); scrub
589            // credential-shaped content before it reaches stderr (#F13).
590            eprintln!(
591                "mermaid: {}",
592                crate::utils::redact_secrets(&format!("{e:#}"))
593            );
594            Config::default()
595        },
596    }
597}
598
599/// Get the path to the single config file
600pub fn get_config_path() -> Result<PathBuf> {
601    Ok(get_config_dir()?.join("config.toml"))
602}
603
604/// Get the configuration directory
605pub fn get_config_dir() -> Result<PathBuf> {
606    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
607        let config_dir = proj_dirs.config_dir();
608        std::fs::create_dir_all(config_dir)?;
609        Ok(config_dir.to_path_buf())
610    } else {
611        // Fallback to home directory
612        let home = std::env::var("HOME")
613            .or_else(|_| std::env::var("USERPROFILE"))
614            .context("Could not determine home directory")?;
615        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
616        std::fs::create_dir_all(&config_dir)?;
617        Ok(config_dir)
618    }
619}
620
621/// Save configuration to file
622pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
623    let path = if let Some(p) = path {
624        p
625    } else {
626        get_config_dir()?.join("config.toml")
627    };
628
629    let toml_string = toml::to_string_pretty(config)?;
630
631    // The config can carry literal secrets — `mcp_servers[].env`,
632    // `mcp_servers[].args`, and `providers[].extra_headers` all accept inline
633    // credential values — so it must not be left world-readable at umask. On
634    // Unix, create it 0600 from the start (and tighten an already-existing
635    // file, whose perms a fresh `mode()` would not touch). Windows uses ACLs;
636    // leave its default.
637    #[cfg(unix)]
638    {
639        use std::io::Write;
640        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
641        let mut file = std::fs::OpenOptions::new()
642            .write(true)
643            .create(true)
644            .truncate(true)
645            .mode(0o600)
646            .open(&path)
647            .with_context(|| format!("Failed to write config to {}", path.display()))?;
648        // `mode()` only applies on create; tighten a pre-existing config too.
649        let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
650        file.write_all(toml_string.as_bytes())
651            .with_context(|| format!("Failed to write config to {}", path.display()))?;
652    }
653    #[cfg(not(unix))]
654    {
655        std::fs::write(&path, toml_string)
656            .with_context(|| format!("Failed to write config to {}", path.display()))?;
657    }
658
659    Ok(())
660}
661
662/// Create a default configuration file if it doesn't exist
663pub fn init_config() -> Result<()> {
664    let config_file = get_config_path()?;
665
666    if config_file.exists() {
667        println!("Configuration already exists at: {}", config_file.display());
668    } else {
669        let default_config = Config::default();
670        save_config(&default_config, Some(config_file.clone()))?;
671        println!("Created configuration at: {}", config_file.display());
672    }
673
674    Ok(())
675}
676
677/// Serializes the read-modify-write persistence path. The `persist_*` helpers
678/// run as concurrent detached tasks (dispatched by the effect runner) that all
679/// load → mutate → save the same file; without a lock two quick toggles
680/// (`/model` then Alt+T) can interleave their loads and lose one write. Held
681/// only across the synchronous fs work — never across an `.await`.
682static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
683
684/// Load the config, apply `mutate`, and save it back — under `PERSIST_LOCK` so
685/// concurrent persists can't clobber each other. On a malformed config the error
686/// propagates (the caller drops it) rather than overwriting the file with
687/// defaults (#111).
688fn update_config(mutate: impl FnOnce(&mut Config)) -> Result<()> {
689    let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
690    let mut config = load_config()?;
691    mutate(&mut config);
692    save_config(&config, None)
693}
694
695/// Persist the last used model to config file.
696pub fn persist_last_model(model: &str) -> Result<()> {
697    update_config(|config| config.last_used_model = Some(model.to_string()))
698}
699
700/// Persist the user's default reasoning level to config file. Used by the
701/// `/reasoning` slash command and the Alt+T cycle handler so the choice survives
702/// across sessions.
703pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
704    update_config(|config| config.default_model.reasoning = level)
705}
706
707/// Persist a reasoning level for a specific model ID
708/// (e.g. `<provider>/<model>`). The TUI calls this from Alt+T,
709/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
710/// the choice sticks per-model rather than bleeding into other models on
711/// next session start.
712pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
713    update_config(|config| {
714        config
715            .reasoning_per_model
716            .insert(model_id.to_string(), level);
717    })
718}
719
720/// Persist (or clear) a per-model Ollama `num_ctx` override. `Some(n)` sets it,
721/// `None` removes the entry (returning that model to auto-fit).
722pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
723    update_config(|config| match num_ctx {
724        Some(n) => {
725            config
726                .ollama_num_ctx_per_model
727                .insert(model_id.to_string(), n);
728        },
729        None => {
730            config.ollama_num_ctx_per_model.remove(model_id);
731        },
732    })
733}
734
735/// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
736pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
737    update_config(|config| config.ollama.allow_ram_offload = enabled)
738}
739
740/// Resolve which model to use: CLI arg > last_used > default_model > any available
741pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
742    if let Some(model) = cli_model {
743        if let Some(resolved) = resolve_model_profile_alias(model, config)? {
744            return Ok(resolved);
745        }
746        return Ok(model.to_string());
747    }
748    if let Some(last_model) = &config.last_used_model {
749        if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
750            return Ok(resolved);
751        }
752        return Ok(last_model.clone());
753    }
754    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
755        return Ok(format!(
756            "{}/{}",
757            config.default_model.provider, config.default_model.name
758        ));
759    }
760    let available = crate::ollama::require_any_model(config).await?;
761    // `require_any_model` already errors on empty, so this `.first()` is
762    // never `None` in practice. Use `.first()` over `[0]` so the precondition
763    // is enforced by the type system instead of by a comment.
764    let first = available
765        .first()
766        .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
767    Ok(format!("ollama/{}", first))
768}
769
770fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
771    let profile = requested.strip_prefix("profile:").unwrap_or(requested);
772    if let Some(model) = config.model_profiles.get(profile) {
773        anyhow::ensure!(
774            !model.trim().is_empty(),
775            "model profile `{}` is configured with an empty model id",
776            profile
777        );
778        return Ok(Some(model.clone()));
779    }
780    if requested.starts_with("profile:") {
781        anyhow::bail!(
782            "model profile `{}` is not configured; add it under [model_profiles]",
783            profile
784        );
785    }
786    Ok(None)
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    /// Configs persisted before Step 4 don't have a `reasoning` field on
794    /// `[default_model]`. Loading them must succeed and yield the
795    /// `Medium` default — otherwise existing user configs break on
796    /// upgrade.
797    #[test]
798    fn model_settings_deserializes_without_reasoning_field() {
799        let toml_blob = r#"
800            provider = "ollama"
801            name = "qwen3-coder:30b"
802            temperature = 0.7
803            max_tokens = 4096
804        "#;
805        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
806        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
807        assert_eq!(settings.provider, "ollama");
808    }
809
810    #[test]
811    fn model_settings_round_trips_reasoning_high() {
812        let original = ModelSettings {
813            provider: "anthropic".to_string(),
814            name: "claude-sonnet-4-6".to_string(),
815            temperature: 0.5,
816            max_tokens: 8192,
817            reasoning: ReasoningLevel::High,
818        };
819        let toml_blob = toml::to_string(&original).expect("serialize");
820        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
821        assert_eq!(back.reasoning, ReasoningLevel::High);
822        assert_eq!(back.name, "claude-sonnet-4-6");
823    }
824
825    #[test]
826    fn agents_config_defaults_and_parses_custom_types() {
827        // Absent section → defaults (20-minute timeout, no custom types).
828        let config: Config = toml::from_str("").expect("empty config parses");
829        assert_eq!(config.agents.timeout_secs, 1200);
830        assert!(config.agents.types.is_empty());
831
832        let config: Config = toml::from_str(
833            r#"
834[agents]
835timeout_secs = 300
836
837[agents.types.scout]
838tools = ["read_file", "execute_command"]
839safety = "read_only"
840preamble = "You are a scout."
841model = "ollama/qwen3:8b"
842"#,
843        )
844        .expect("agents section parses");
845        assert_eq!(config.agents.timeout_secs, 300);
846        let scout = &config.agents.types["scout"];
847        assert_eq!(
848            scout.tools.as_deref(),
849            Some(&["read_file".to_string(), "execute_command".to_string()][..])
850        );
851        assert_eq!(scout.safety.as_deref(), Some("read_only"));
852        assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
853    }
854
855    #[test]
856    fn configured_model_profile_resolves_explicit_alias() {
857        let mut config = Config::default();
858        config
859            .model_profiles
860            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
861        assert_eq!(
862            resolve_model_profile_alias("fast", &config).unwrap(),
863            Some("ollama/qwen3-coder:14b".to_string())
864        );
865        assert_eq!(
866            resolve_model_profile_alias("profile:fast", &config).unwrap(),
867            Some("ollama/qwen3-coder:14b".to_string())
868        );
869    }
870
871    #[test]
872    fn profile_prefix_requires_configuration() {
873        let config = Config::default();
874        assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
875        assert_eq!(
876            resolve_model_profile_alias("vision", &config).unwrap(),
877            None
878        );
879    }
880
881    /// `persist_default_reasoning` writes to the real config path, so
882    /// this test goes through `save_config(_, Some(path))` directly to
883    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
884    /// Uses `std::env::temp_dir` (matching the pattern in
885    /// `session::conversation` and `utils::logger`) — no external
886    /// `tempfile` crate dependency.
887    #[test]
888    fn save_and_reload_preserves_reasoning_field() {
889        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
890        std::fs::create_dir_all(&dir).expect("create temp dir");
891        let path = dir.join("config.toml");
892
893        let mut cfg = Config::default();
894        cfg.default_model.provider = "ollama".to_string();
895        cfg.default_model.name = "qwen3-coder:30b".to_string();
896        cfg.default_model.reasoning = ReasoningLevel::Low;
897
898        save_config(&cfg, Some(path.clone())).expect("save");
899
900        let blob = std::fs::read_to_string(&path).expect("read");
901        let loaded: Config = toml::from_str(&blob).expect("parse back");
902        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
903
904        let _ = std::fs::remove_dir_all(&dir);
905    }
906
907    /// Per-model entries serialize as a TOML table with quoted keys (the
908    /// model IDs contain `/`). This test verifies the round-trip works
909    /// through both serialization and deserialization, matching what
910    /// `persist_reasoning_for_model` would produce in real use.
911    #[test]
912    fn save_and_reload_preserves_reasoning_per_model_table() {
913        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
914        std::fs::create_dir_all(&dir).expect("create temp dir");
915        let path = dir.join("config.toml");
916
917        let mut cfg = Config::default();
918        cfg.reasoning_per_model.insert(
919            "anthropic/claude-sonnet-4-6".to_string(),
920            ReasoningLevel::High,
921        );
922        cfg.reasoning_per_model
923            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
924
925        save_config(&cfg, Some(path.clone())).expect("save");
926
927        let blob = std::fs::read_to_string(&path).expect("read");
928        let loaded: Config = toml::from_str(&blob).expect("parse back");
929        assert_eq!(
930            loaded
931                .reasoning_per_model
932                .get("anthropic/claude-sonnet-4-6"),
933            Some(&ReasoningLevel::High)
934        );
935        assert_eq!(
936            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
937            Some(&ReasoningLevel::Low)
938        );
939
940        let _ = std::fs::remove_dir_all(&dir);
941    }
942
943    /// `/context <n>` overrides round-trip through the per-model TOML table, and
944    /// the offload toggle persists on `[ollama]`.
945    #[test]
946    fn save_and_reload_preserves_ollama_context_overrides() {
947        let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
948        std::fs::create_dir_all(&dir).expect("create temp dir");
949        let path = dir.join("config.toml");
950
951        let mut cfg = Config::default();
952        cfg.ollama_num_ctx_per_model
953            .insert("ollama/ornith:9b".to_string(), 131_072);
954        cfg.ollama.allow_ram_offload = true;
955        cfg.ollama.max_auto_num_ctx = Some(65_536);
956
957        save_config(&cfg, Some(path.clone())).expect("save");
958        let blob = std::fs::read_to_string(&path).expect("read");
959        let loaded: Config = toml::from_str(&blob).expect("parse back");
960
961        assert_eq!(
962            loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
963            Some(&131_072)
964        );
965        assert!(loaded.ollama.allow_ram_offload);
966        assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
967
968        let _ = std::fs::remove_dir_all(&dir);
969    }
970
971    /// Older configs have neither the per-model num_ctx table nor the new
972    /// `[ollama]` keys; loading must default cleanly (empty map, offload off).
973    #[test]
974    fn config_deserializes_without_ollama_context_keys() {
975        let toml_blob = r#"
976[ollama]
977host = "localhost"
978port = 11434
979"#;
980        let cfg: Config = toml::from_str(toml_blob).expect("parse");
981        assert!(cfg.ollama_num_ctx_per_model.is_empty());
982        assert!(!cfg.ollama.allow_ram_offload);
983        assert_eq!(cfg.ollama.max_auto_num_ctx, None);
984    }
985
986    /// Configs from before Step 5b don't have a `reasoning_per_model`
987    /// section. Loading them must succeed with an empty map — otherwise
988    /// upgrade breaks every existing user.
989    #[test]
990    fn config_deserializes_without_reasoning_per_model() {
991        let toml_blob = r#"
992            last_used_model = "ollama/qwen3-coder:30b"
993
994            [default_model]
995            provider = "ollama"
996            name = "qwen3-coder:30b"
997            temperature = 0.7
998            max_tokens = 4096
999        "#;
1000        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
1001        assert!(cfg.reasoning_per_model.is_empty());
1002        assert!(!cfg.prompt.is_customized());
1003    }
1004
1005    /// Config holds inline-secret-capable fields (`mcp_servers[].env`, `args`,
1006    /// `providers[].extra_headers`), so it must be written owner-only rather
1007    /// than inheriting a world-readable umask.
1008    #[cfg(unix)]
1009    #[test]
1010    fn save_config_writes_owner_only_perms() {
1011        use std::os::unix::fs::PermissionsExt;
1012        let dir = std::env::temp_dir().join("mermaid_test_config_perms");
1013        std::fs::create_dir_all(&dir).expect("create temp dir");
1014        let path = dir.join("config.toml");
1015        // Pre-create a world-readable file to prove we also tighten existing.
1016        std::fs::write(&path, "stale").expect("seed");
1017        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
1018
1019        save_config(&Config::default(), Some(path.clone())).expect("save");
1020        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1021        assert_eq!(mode, 0o600, "config must be written owner-only");
1022
1023        let _ = std::fs::remove_dir_all(&dir);
1024    }
1025
1026    #[test]
1027    fn config_defaults_computer_use_auto_screenshot_on() {
1028        // An empty/legacy config must keep the auto-screenshot behavior (#98).
1029        let cfg: Config = toml::from_str("").expect("empty config");
1030        assert!(cfg.computer_use.auto_screenshot);
1031    }
1032
1033    #[test]
1034    fn prompt_config_replaces_and_appends_without_persisting() {
1035        let mut cfg = Config::default();
1036        cfg.prompt.system_prompt = Some("base".to_string());
1037        cfg.prompt
1038            .append_system_prompt
1039            .push("extra instructions".to_string());
1040
1041        assert_eq!(
1042            cfg.prompt.render_system_prompt("default"),
1043            "base\n\nextra instructions"
1044        );
1045
1046        let blob = toml::to_string(&cfg).expect("serialize");
1047        assert!(!blob.contains("extra instructions"));
1048        let loaded: Config = toml::from_str(&blob).expect("deserialize");
1049        assert!(!loaded.prompt.is_customized());
1050    }
1051}