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(Debug, 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(Debug, 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/// Default model settings
295#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(default)]
297pub struct ModelSettings {
298    /// Model provider (ollama, openai, anthropic)
299    pub provider: String,
300    /// Model name
301    pub name: String,
302    /// Temperature for generation
303    pub temperature: f32,
304    /// Maximum tokens to generate
305    pub max_tokens: usize,
306    /// Default reasoning depth used for new sessions when no `--reasoning`
307    /// flag is given. Each adapter snaps this onto the closest level the
308    /// model actually supports via `nearest_effort()`.
309    pub reasoning: ReasoningLevel,
310}
311
312impl Default for ModelSettings {
313    fn default() -> Self {
314        Self {
315            provider: String::new(),
316            name: String::new(),
317            temperature: DEFAULT_TEMPERATURE,
318            max_tokens: DEFAULT_MAX_TOKENS,
319            reasoning: ReasoningLevel::default(),
320        }
321    }
322}
323
324/// Ollama configuration
325#[derive(Debug, Clone, Serialize, Deserialize)]
326#[serde(default)]
327pub struct OllamaConfig {
328    /// Ollama server host
329    pub host: String,
330    /// Ollama server port
331    pub port: u16,
332    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
333    /// Lower values free up VRAM for larger models at the cost of speed
334    pub num_gpu: Option<i32>,
335    /// Number of CPU threads for processing offloaded layers
336    /// Higher values improve CPU inference speed for large models
337    pub num_thread: Option<i32>,
338    /// Context window size (number of tokens)
339    /// Larger values allow longer conversations but use more memory
340    pub num_ctx: Option<i32>,
341    /// Enable NUMA optimization for multi-CPU systems
342    pub numa: Option<bool>,
343    /// Allow Ollama to offload the model/KV cache to system RAM when it doesn't
344    /// fit VRAM. **Disabled by default**: RAM offload is 5–20× slower, so by
345    /// default Mermaid auto-fits `num_ctx` to VRAM (keeping the model on the
346    /// GPU). Enable to trade speed for a larger context window. Toggle in-app
347    /// with `/context offload on|off`.
348    pub allow_ram_offload: bool,
349    /// Optional hard cap on the auto-fitted context window (in tokens). `None`
350    /// lets auto-fit use the full memory budget up to the model's max; set this
351    /// to bound it (e.g. to leave VRAM headroom for other apps).
352    pub max_auto_num_ctx: Option<usize>,
353}
354
355impl Default for OllamaConfig {
356    fn default() -> Self {
357        Self {
358            host: String::from("localhost"),
359            port: DEFAULT_OLLAMA_PORT,
360            num_gpu: None,            // Let Ollama auto-detect
361            num_thread: None,         // Let Ollama auto-detect
362            num_ctx: None,            // Use model default (overrides auto-fit)
363            numa: None,               // Auto-detect
364            allow_ram_offload: false, // VRAM-only by default (RAM is slow)
365            max_auto_num_ctx: None,   // No cap; auto-fit to the memory budget
366        }
367    }
368}
369
370/// Non-interactive mode configuration
371#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(default)]
373pub struct NonInteractiveConfig {
374    /// Output format (text, json, markdown)
375    pub output_format: String,
376    /// Maximum tokens to generate
377    pub max_tokens: usize,
378    /// Don't execute agent actions (dry run)
379    pub no_execute: bool,
380}
381
382impl Default for NonInteractiveConfig {
383    fn default() -> Self {
384        Self {
385            output_format: String::from("text"),
386            max_tokens: DEFAULT_MAX_TOKENS,
387            no_execute: false,
388        }
389    }
390}
391
392/// Load configuration from single config file
393/// Priority: config file > defaults (that's it - no merging, no env vars)
394pub fn load_config() -> Result<Config> {
395    let config_path = get_config_path()?;
396
397    if config_path.exists() {
398        let toml_str = std::fs::read_to_string(&config_path)
399            .with_context(|| format!("Failed to read {}", config_path.display()))?;
400        let config: Config = toml::from_str(&toml_str).with_context(|| {
401            format!(
402                "Failed to parse {}. Run 'mermaid init' to regenerate.",
403                config_path.display()
404            )
405        })?;
406        Ok(config)
407    } else {
408        Ok(Config::default())
409    }
410}
411
412/// Like [`load_config`] but never fails: if a config file exists yet is
413/// malformed, warn on stderr and fall back to defaults — instead of silently
414/// swallowing the error (#111). An *absent* file is not an error (`load_config`
415/// returns defaults for it), so the warning fires only for a genuine
416/// read/parse failure the user should know about.
417pub fn load_config_or_warn() -> Config {
418    match load_config() {
419        Ok(config) => config,
420        Err(e) => {
421            eprintln!("mermaid: {e:#}");
422            Config::default()
423        },
424    }
425}
426
427/// Get the path to the single config file
428pub fn get_config_path() -> Result<PathBuf> {
429    Ok(get_config_dir()?.join("config.toml"))
430}
431
432/// Get the configuration directory
433pub fn get_config_dir() -> Result<PathBuf> {
434    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
435        let config_dir = proj_dirs.config_dir();
436        std::fs::create_dir_all(config_dir)?;
437        Ok(config_dir.to_path_buf())
438    } else {
439        // Fallback to home directory
440        let home = std::env::var("HOME")
441            .or_else(|_| std::env::var("USERPROFILE"))
442            .context("Could not determine home directory")?;
443        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
444        std::fs::create_dir_all(&config_dir)?;
445        Ok(config_dir)
446    }
447}
448
449/// Save configuration to file
450pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
451    let path = if let Some(p) = path {
452        p
453    } else {
454        get_config_dir()?.join("config.toml")
455    };
456
457    let toml_string = toml::to_string_pretty(config)?;
458    std::fs::write(&path, toml_string)
459        .with_context(|| format!("Failed to write config to {}", path.display()))?;
460
461    Ok(())
462}
463
464/// Create a default configuration file if it doesn't exist
465pub fn init_config() -> Result<()> {
466    let config_file = get_config_path()?;
467
468    if config_file.exists() {
469        println!("Configuration already exists at: {}", config_file.display());
470    } else {
471        let default_config = Config::default();
472        save_config(&default_config, Some(config_file.clone()))?;
473        println!("Created configuration at: {}", config_file.display());
474    }
475
476    Ok(())
477}
478
479/// Persist the last used model to config file. On a malformed config it
480/// propagates the error (the caller drops it) rather than clobbering the file
481/// with defaults — the three `persist_*` helpers all do this (#111).
482pub fn persist_last_model(model: &str) -> Result<()> {
483    let mut config = load_config()?;
484    config.last_used_model = Some(model.to_string());
485    save_config(&config, None)
486}
487
488/// Persist the user's default reasoning level to config file. Mirrors
489/// `persist_last_model` — used by the `/reasoning` slash command and the
490/// Alt+T cycle handler so the choice survives across sessions.
491pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
492    let mut config = load_config()?;
493    config.default_model.reasoning = level;
494    save_config(&config, None)
495}
496
497/// Persist a reasoning level for a specific model ID
498/// (e.g. `anthropic/claude-sonnet-4-6`). The TUI calls this from Alt+T,
499/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
500/// the choice sticks per-model rather than bleeding into other models on
501/// next session start.
502pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
503    let mut config = load_config()?;
504    config
505        .reasoning_per_model
506        .insert(model_id.to_string(), level);
507    save_config(&config, None)
508}
509
510/// Persist (or clear) a per-model Ollama `num_ctx` override. `Some(n)` sets it,
511/// `None` removes the entry (returning that model to auto-fit).
512pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
513    let mut config = load_config()?;
514    match num_ctx {
515        Some(n) => {
516            config
517                .ollama_num_ctx_per_model
518                .insert(model_id.to_string(), n);
519        },
520        None => {
521            config.ollama_num_ctx_per_model.remove(model_id);
522        },
523    }
524    save_config(&config, None)
525}
526
527/// Persist the Ollama RAM-offload toggle (`/context offload on|off`).
528pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
529    let mut config = load_config()?;
530    config.ollama.allow_ram_offload = enabled;
531    save_config(&config, None)
532}
533
534/// Resolve which model to use: CLI arg > last_used > default_model > any available
535pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
536    if let Some(model) = cli_model {
537        if let Some(resolved) = resolve_model_profile_alias(model, config)? {
538            return Ok(resolved);
539        }
540        return Ok(model.to_string());
541    }
542    if let Some(last_model) = &config.last_used_model {
543        if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
544            return Ok(resolved);
545        }
546        return Ok(last_model.clone());
547    }
548    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
549        return Ok(format!(
550            "{}/{}",
551            config.default_model.provider, config.default_model.name
552        ));
553    }
554    let available = crate::ollama::require_any_model(config).await?;
555    // `require_any_model` already errors on empty, so this `.first()` is
556    // never `None` in practice. Use `.first()` over `[0]` so the precondition
557    // is enforced by the type system instead of by a comment.
558    let first = available
559        .first()
560        .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
561    Ok(format!("ollama/{}", first))
562}
563
564fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
565    let profile = requested.strip_prefix("profile:").unwrap_or(requested);
566    if let Some(model) = config.model_profiles.get(profile) {
567        anyhow::ensure!(
568            !model.trim().is_empty(),
569            "model profile `{}` is configured with an empty model id",
570            profile
571        );
572        return Ok(Some(model.clone()));
573    }
574    if requested.starts_with("profile:") {
575        anyhow::bail!(
576            "model profile `{}` is not configured; add it under [model_profiles]",
577            profile
578        );
579    }
580    Ok(None)
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    /// Configs persisted before Step 4 don't have a `reasoning` field on
588    /// `[default_model]`. Loading them must succeed and yield the
589    /// `Medium` default — otherwise existing user configs break on
590    /// upgrade.
591    #[test]
592    fn model_settings_deserializes_without_reasoning_field() {
593        let toml_blob = r#"
594            provider = "ollama"
595            name = "qwen3-coder:30b"
596            temperature = 0.7
597            max_tokens = 4096
598        "#;
599        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
600        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
601        assert_eq!(settings.provider, "ollama");
602    }
603
604    #[test]
605    fn model_settings_round_trips_reasoning_high() {
606        let original = ModelSettings {
607            provider: "anthropic".to_string(),
608            name: "claude-sonnet-4-6".to_string(),
609            temperature: 0.5,
610            max_tokens: 8192,
611            reasoning: ReasoningLevel::High,
612        };
613        let toml_blob = toml::to_string(&original).expect("serialize");
614        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
615        assert_eq!(back.reasoning, ReasoningLevel::High);
616        assert_eq!(back.name, "claude-sonnet-4-6");
617    }
618
619    #[test]
620    fn configured_model_profile_resolves_explicit_alias() {
621        let mut config = Config::default();
622        config
623            .model_profiles
624            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
625        assert_eq!(
626            resolve_model_profile_alias("fast", &config).unwrap(),
627            Some("ollama/qwen3-coder:14b".to_string())
628        );
629        assert_eq!(
630            resolve_model_profile_alias("profile:fast", &config).unwrap(),
631            Some("ollama/qwen3-coder:14b".to_string())
632        );
633    }
634
635    #[test]
636    fn profile_prefix_requires_configuration() {
637        let config = Config::default();
638        assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
639        assert_eq!(
640            resolve_model_profile_alias("vision", &config).unwrap(),
641            None
642        );
643    }
644
645    /// `persist_default_reasoning` writes to the real config path, so
646    /// this test goes through `save_config(_, Some(path))` directly to
647    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
648    /// Uses `std::env::temp_dir` (matching the pattern in
649    /// `session::conversation` and `utils::logger`) — no external
650    /// `tempfile` crate dependency.
651    #[test]
652    fn save_and_reload_preserves_reasoning_field() {
653        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
654        std::fs::create_dir_all(&dir).expect("create temp dir");
655        let path = dir.join("config.toml");
656
657        let mut cfg = Config::default();
658        cfg.default_model.provider = "ollama".to_string();
659        cfg.default_model.name = "qwen3-coder:30b".to_string();
660        cfg.default_model.reasoning = ReasoningLevel::Low;
661
662        save_config(&cfg, Some(path.clone())).expect("save");
663
664        let blob = std::fs::read_to_string(&path).expect("read");
665        let loaded: Config = toml::from_str(&blob).expect("parse back");
666        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
667
668        let _ = std::fs::remove_dir_all(&dir);
669    }
670
671    /// Per-model entries serialize as a TOML table with quoted keys (the
672    /// model IDs contain `/`). This test verifies the round-trip works
673    /// through both serialization and deserialization, matching what
674    /// `persist_reasoning_for_model` would produce in real use.
675    #[test]
676    fn save_and_reload_preserves_reasoning_per_model_table() {
677        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
678        std::fs::create_dir_all(&dir).expect("create temp dir");
679        let path = dir.join("config.toml");
680
681        let mut cfg = Config::default();
682        cfg.reasoning_per_model.insert(
683            "anthropic/claude-sonnet-4-6".to_string(),
684            ReasoningLevel::High,
685        );
686        cfg.reasoning_per_model
687            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
688
689        save_config(&cfg, Some(path.clone())).expect("save");
690
691        let blob = std::fs::read_to_string(&path).expect("read");
692        let loaded: Config = toml::from_str(&blob).expect("parse back");
693        assert_eq!(
694            loaded
695                .reasoning_per_model
696                .get("anthropic/claude-sonnet-4-6"),
697            Some(&ReasoningLevel::High)
698        );
699        assert_eq!(
700            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
701            Some(&ReasoningLevel::Low)
702        );
703
704        let _ = std::fs::remove_dir_all(&dir);
705    }
706
707    /// `/context <n>` overrides round-trip through the per-model TOML table, and
708    /// the offload toggle persists on `[ollama]`.
709    #[test]
710    fn save_and_reload_preserves_ollama_context_overrides() {
711        let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
712        std::fs::create_dir_all(&dir).expect("create temp dir");
713        let path = dir.join("config.toml");
714
715        let mut cfg = Config::default();
716        cfg.ollama_num_ctx_per_model
717            .insert("ollama/ornith:9b".to_string(), 131_072);
718        cfg.ollama.allow_ram_offload = true;
719        cfg.ollama.max_auto_num_ctx = Some(65_536);
720
721        save_config(&cfg, Some(path.clone())).expect("save");
722        let blob = std::fs::read_to_string(&path).expect("read");
723        let loaded: Config = toml::from_str(&blob).expect("parse back");
724
725        assert_eq!(
726            loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
727            Some(&131_072)
728        );
729        assert!(loaded.ollama.allow_ram_offload);
730        assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
731
732        let _ = std::fs::remove_dir_all(&dir);
733    }
734
735    /// Older configs have neither the per-model num_ctx table nor the new
736    /// `[ollama]` keys; loading must default cleanly (empty map, offload off).
737    #[test]
738    fn config_deserializes_without_ollama_context_keys() {
739        let toml_blob = r#"
740[ollama]
741host = "localhost"
742port = 11434
743"#;
744        let cfg: Config = toml::from_str(toml_blob).expect("parse");
745        assert!(cfg.ollama_num_ctx_per_model.is_empty());
746        assert!(!cfg.ollama.allow_ram_offload);
747        assert_eq!(cfg.ollama.max_auto_num_ctx, None);
748    }
749
750    /// Configs from before Step 5b don't have a `reasoning_per_model`
751    /// section. Loading them must succeed with an empty map — otherwise
752    /// upgrade breaks every existing user.
753    #[test]
754    fn config_deserializes_without_reasoning_per_model() {
755        let toml_blob = r#"
756            last_used_model = "ollama/qwen3-coder:30b"
757
758            [default_model]
759            provider = "ollama"
760            name = "qwen3-coder:30b"
761            temperature = 0.7
762            max_tokens = 4096
763        "#;
764        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
765        assert!(cfg.reasoning_per_model.is_empty());
766        assert!(!cfg.prompt.is_customized());
767    }
768
769    #[test]
770    fn config_defaults_computer_use_auto_screenshot_on() {
771        // An empty/legacy config must keep the auto-screenshot behavior (#98).
772        let cfg: Config = toml::from_str("").expect("empty config");
773        assert!(cfg.computer_use.auto_screenshot);
774    }
775
776    #[test]
777    fn prompt_config_replaces_and_appends_without_persisting() {
778        let mut cfg = Config::default();
779        cfg.prompt.system_prompt = Some("base".to_string());
780        cfg.prompt
781            .append_system_prompt
782            .push("extra instructions".to_string());
783
784        assert_eq!(
785            cfg.prompt.render_system_prompt("default"),
786            "base\n\nextra instructions"
787        );
788
789        let blob = toml::to_string(&cfg).expect("serialize");
790        assert!(!blob.contains("extra instructions"));
791        let loaded: Config = toml::from_str(&blob).expect("deserialize");
792        assert!(!loaded.prompt.is_customized());
793    }
794}