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