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    /// Named model profiles that agents/plugins can request without
64    /// hardcoding a concrete provider model. Values are full model IDs.
65    /// Example:
66    /// ```toml
67    /// [model_profiles]
68    /// fast = "ollama/qwen3-coder:14b"
69    /// large-context = "openai/gpt-5.2"
70    /// tool-strong = "anthropic/claude-sonnet-4-6"
71    /// vision = "gemini/gemini-2.5-pro"
72    /// cheap = "groq/llama-3.3-70b-versatile"
73    /// ```
74    #[serde(default)]
75    pub model_profiles: HashMap<String, String>,
76
77    /// Runtime safety policy. Defaults to `Ask` so mutations / shell /
78    /// network actions require approval out of the box; users opt into
79    /// `Auto` (LLM-vetted) or `FullAccess` deliberately.
80    #[serde(default)]
81    pub safety: SafetyConfig,
82
83    /// Durable semantic memory settings.
84    #[serde(default)]
85    pub memory: MemoryConfig,
86
87    /// Runtime-only prompt customizations supplied by CLI flags. These are
88    /// deliberately skipped when saving config so one-off agent personas do
89    /// not pollute the user's persistent Mermaid settings.
90    #[serde(skip)]
91    pub prompt: PromptConfig,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct PromptConfig {
96    pub system_prompt: Option<String>,
97    pub append_system_prompt: Vec<String>,
98}
99
100impl PromptConfig {
101    pub fn render_system_prompt(&self, default_prompt: &str) -> String {
102        let mut rendered = self
103            .system_prompt
104            .as_deref()
105            .unwrap_or(default_prompt)
106            .trim_end()
107            .to_string();
108
109        for extra in &self.append_system_prompt {
110            let extra = extra.trim();
111            if extra.is_empty() {
112                continue;
113            }
114            if !rendered.is_empty() {
115                rendered.push_str("\n\n");
116            }
117            rendered.push_str(extra);
118        }
119
120        rendered
121    }
122
123    pub fn is_customized(&self) -> bool {
124        self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
125    }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(default)]
130pub struct SafetyConfig {
131    pub mode: SafetyMode,
132    pub checkpoint_on_mutation: bool,
133    #[serde(default)]
134    pub overrides: Vec<PolicyOverride>,
135    /// Model id the `Auto`-mode safety classifier uses to vet borderline
136    /// actions. `None` ⇒ vet with the session's active model. Set this to
137    /// point the vet at a cheaper/faster model than the one driving the work.
138    #[serde(default)]
139    pub auto_classifier_model: Option<String>,
140}
141
142impl Default for SafetyConfig {
143    fn default() -> Self {
144        Self {
145            // Safe-by-default: the first run prompts for approval on
146            // mutations / shell / network rather than silently auto-allowing
147            // everything. FullAccess remains available via config.
148            mode: SafetyMode::Ask,
149            checkpoint_on_mutation: true,
150            overrides: Vec::new(),
151            auto_classifier_model: None,
152        }
153    }
154}
155
156/// Durable semantic memory settings (v0.10.0).
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(default)]
159pub struct MemoryConfig {
160    /// Master switch for agent memory (the tool, the always-loaded index, and
161    /// the slash commands). On by default.
162    pub enabled: bool,
163    /// Byte cap on the always-loaded memory index before it's truncated.
164    pub index_cap_bytes: usize,
165}
166
167impl Default for MemoryConfig {
168    fn default() -> Self {
169        Self {
170            enabled: true,
171            index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
172        }
173    }
174}
175
176/// User-supplied OpenAI-compatible provider configuration. All fields are
177/// optional — when matching a built-in registry entry, only the supplied
178/// fields override; the rest fall back to the registry defaults. For
179/// fully custom providers, `base_url` and `api_key_env` are required.
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct UserProviderConfig {
182    /// Override base URL for `/chat/completions` (None = use built-in
183    /// registry default; required for fully custom providers).
184    #[serde(default)]
185    pub base_url: Option<String>,
186    /// Env var name to read the API key from (None = use the built-in
187    /// registry default like `GROQ_API_KEY`; required for fully custom
188    /// providers).
189    #[serde(default)]
190    pub api_key_env: Option<String>,
191    /// Extra HTTP headers sent on every request to this provider.
192    #[serde(default)]
193    pub extra_headers: HashMap<String, String>,
194    /// For fully custom providers (no built-in registry entry), declares
195    /// which OpenAI-compatible shape the endpoint speaks. Ignored when
196    /// the provider name matches a built-in registry entry. Values:
197    /// `"openai"` (no reasoning), `"openai-effort"` (`reasoning_effort`
198    /// field), `"openrouter"` (nested `reasoning: {effort}` object).
199    #[serde(default)]
200    pub compat: Option<String>,
201    /// Optional preferred model — surfaced by `mermaid status` and used
202    /// as the default when the user picks this provider with no model
203    /// suffix.
204    #[serde(default)]
205    pub default_model: Option<String>,
206}
207
208/// MCP server configuration
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct McpServerConfig {
211    /// Command to execute (e.g., "npx", "node", "python")
212    pub command: String,
213    /// Command-line arguments
214    #[serde(default)]
215    pub args: Vec<String>,
216    /// Environment variables for the server process
217    #[serde(default)]
218    pub env: HashMap<String, String>,
219}
220
221/// Default model settings
222#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(default)]
224pub struct ModelSettings {
225    /// Model provider (ollama, openai, anthropic)
226    pub provider: String,
227    /// Model name
228    pub name: String,
229    /// Temperature for generation
230    pub temperature: f32,
231    /// Maximum tokens to generate
232    pub max_tokens: usize,
233    /// Default reasoning depth used for new sessions when no `--reasoning`
234    /// flag is given. Each adapter snaps this onto the closest level the
235    /// model actually supports via `nearest_effort()`.
236    pub reasoning: ReasoningLevel,
237}
238
239impl Default for ModelSettings {
240    fn default() -> Self {
241        Self {
242            provider: String::new(),
243            name: String::new(),
244            temperature: DEFAULT_TEMPERATURE,
245            max_tokens: DEFAULT_MAX_TOKENS,
246            reasoning: ReasoningLevel::default(),
247        }
248    }
249}
250
251/// Ollama configuration
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(default)]
254pub struct OllamaConfig {
255    /// Ollama server host
256    pub host: String,
257    /// Ollama server port
258    pub port: u16,
259    /// Ollama cloud API key (for :cloud models)
260    /// Set this to use Ollama's cloud inference service
261    /// Get your key at: https://ollama.com/cloud
262    pub cloud_api_key: Option<String>,
263    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
264    /// Lower values free up VRAM for larger models at the cost of speed
265    pub num_gpu: Option<i32>,
266    /// Number of CPU threads for processing offloaded layers
267    /// Higher values improve CPU inference speed for large models
268    pub num_thread: Option<i32>,
269    /// Context window size (number of tokens)
270    /// Larger values allow longer conversations but use more memory
271    pub num_ctx: Option<i32>,
272    /// Enable NUMA optimization for multi-CPU systems
273    pub numa: Option<bool>,
274}
275
276impl Default for OllamaConfig {
277    fn default() -> Self {
278        Self {
279            host: String::from("localhost"),
280            port: DEFAULT_OLLAMA_PORT,
281            cloud_api_key: None,
282            num_gpu: None,    // Let Ollama auto-detect
283            num_thread: None, // Let Ollama auto-detect
284            num_ctx: None,    // Use model default
285            numa: None,       // Auto-detect
286        }
287    }
288}
289
290/// Non-interactive mode configuration
291#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(default)]
293pub struct NonInteractiveConfig {
294    /// Output format (text, json, markdown)
295    pub output_format: String,
296    /// Maximum tokens to generate
297    pub max_tokens: usize,
298    /// Don't execute agent actions (dry run)
299    pub no_execute: bool,
300}
301
302impl Default for NonInteractiveConfig {
303    fn default() -> Self {
304        Self {
305            output_format: String::from("text"),
306            max_tokens: DEFAULT_MAX_TOKENS,
307            no_execute: false,
308        }
309    }
310}
311
312/// Load configuration from single config file
313/// Priority: config file > defaults (that's it - no merging, no env vars)
314pub fn load_config() -> Result<Config> {
315    let config_path = get_config_path()?;
316
317    if config_path.exists() {
318        let toml_str = std::fs::read_to_string(&config_path)
319            .with_context(|| format!("Failed to read {}", config_path.display()))?;
320        let config: Config = toml::from_str(&toml_str).with_context(|| {
321            format!(
322                "Failed to parse {}. Run 'mermaid init' to regenerate.",
323                config_path.display()
324            )
325        })?;
326        Ok(config)
327    } else {
328        Ok(Config::default())
329    }
330}
331
332/// Get the path to the single config file
333pub fn get_config_path() -> Result<PathBuf> {
334    Ok(get_config_dir()?.join("config.toml"))
335}
336
337/// Get the configuration directory
338pub fn get_config_dir() -> Result<PathBuf> {
339    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
340        let config_dir = proj_dirs.config_dir();
341        std::fs::create_dir_all(config_dir)?;
342        Ok(config_dir.to_path_buf())
343    } else {
344        // Fallback to home directory
345        let home = std::env::var("HOME")
346            .or_else(|_| std::env::var("USERPROFILE"))
347            .context("Could not determine home directory")?;
348        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
349        std::fs::create_dir_all(&config_dir)?;
350        Ok(config_dir)
351    }
352}
353
354/// Save configuration to file
355pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
356    let path = if let Some(p) = path {
357        p
358    } else {
359        get_config_dir()?.join("config.toml")
360    };
361
362    let toml_string = toml::to_string_pretty(config)?;
363    std::fs::write(&path, toml_string)
364        .with_context(|| format!("Failed to write config to {}", path.display()))?;
365
366    Ok(())
367}
368
369/// Create a default configuration file if it doesn't exist
370pub fn init_config() -> Result<()> {
371    let config_file = get_config_path()?;
372
373    if config_file.exists() {
374        println!("Configuration already exists at: {}", config_file.display());
375    } else {
376        let default_config = Config::default();
377        save_config(&default_config, Some(config_file.clone()))?;
378        println!("Created configuration at: {}", config_file.display());
379    }
380
381    Ok(())
382}
383
384/// Persist the last used model to config file
385pub fn persist_last_model(model: &str) -> Result<()> {
386    let mut config = load_config().unwrap_or_default();
387    config.last_used_model = Some(model.to_string());
388    save_config(&config, None)
389}
390
391/// Persist the user's default reasoning level to config file. Mirrors
392/// `persist_last_model` — used by the `/reasoning` slash command and the
393/// Alt+T cycle handler so the choice survives across sessions.
394pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
395    let mut config = load_config().unwrap_or_default();
396    config.default_model.reasoning = level;
397    save_config(&config, None)
398}
399
400/// Persist a reasoning level for a specific model ID
401/// (e.g. `anthropic/claude-sonnet-4-6`). The TUI calls this from Alt+T,
402/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
403/// the choice sticks per-model rather than bleeding into other models on
404/// next session start.
405pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
406    let mut config = load_config().unwrap_or_default();
407    config
408        .reasoning_per_model
409        .insert(model_id.to_string(), level);
410    save_config(&config, None)
411}
412
413/// Resolve which model to use: CLI arg > last_used > default_model > any available
414pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
415    if let Some(model) = cli_model {
416        if let Some(resolved) = resolve_model_profile_alias(model, config)? {
417            return Ok(resolved);
418        }
419        return Ok(model.to_string());
420    }
421    if let Some(last_model) = &config.last_used_model {
422        if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
423            return Ok(resolved);
424        }
425        return Ok(last_model.clone());
426    }
427    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
428        return Ok(format!(
429            "{}/{}",
430            config.default_model.provider, config.default_model.name
431        ));
432    }
433    let available = crate::ollama::require_any_model(config).await?;
434    // `require_any_model` already errors on empty, so this `.first()` is
435    // never `None` in practice. Use `.first()` over `[0]` so the precondition
436    // is enforced by the type system instead of by a comment.
437    let first = available
438        .first()
439        .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
440    Ok(format!("ollama/{}", first))
441}
442
443fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
444    let profile = requested.strip_prefix("profile:").unwrap_or(requested);
445    if let Some(model) = config.model_profiles.get(profile) {
446        anyhow::ensure!(
447            !model.trim().is_empty(),
448            "model profile `{}` is configured with an empty model id",
449            profile
450        );
451        return Ok(Some(model.clone()));
452    }
453    if requested.starts_with("profile:") {
454        anyhow::bail!(
455            "model profile `{}` is not configured; add it under [model_profiles]",
456            profile
457        );
458    }
459    Ok(None)
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    /// Configs persisted before Step 4 don't have a `reasoning` field on
467    /// `[default_model]`. Loading them must succeed and yield the
468    /// `Medium` default — otherwise existing user configs break on
469    /// upgrade.
470    #[test]
471    fn model_settings_deserializes_without_reasoning_field() {
472        let toml_blob = r#"
473            provider = "ollama"
474            name = "qwen3-coder:30b"
475            temperature = 0.7
476            max_tokens = 4096
477        "#;
478        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
479        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
480        assert_eq!(settings.provider, "ollama");
481    }
482
483    #[test]
484    fn model_settings_round_trips_reasoning_high() {
485        let original = ModelSettings {
486            provider: "anthropic".to_string(),
487            name: "claude-sonnet-4-6".to_string(),
488            temperature: 0.5,
489            max_tokens: 8192,
490            reasoning: ReasoningLevel::High,
491        };
492        let toml_blob = toml::to_string(&original).expect("serialize");
493        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
494        assert_eq!(back.reasoning, ReasoningLevel::High);
495        assert_eq!(back.name, "claude-sonnet-4-6");
496    }
497
498    #[test]
499    fn configured_model_profile_resolves_explicit_alias() {
500        let mut config = Config::default();
501        config
502            .model_profiles
503            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
504        assert_eq!(
505            resolve_model_profile_alias("fast", &config).unwrap(),
506            Some("ollama/qwen3-coder:14b".to_string())
507        );
508        assert_eq!(
509            resolve_model_profile_alias("profile:fast", &config).unwrap(),
510            Some("ollama/qwen3-coder:14b".to_string())
511        );
512    }
513
514    #[test]
515    fn profile_prefix_requires_configuration() {
516        let config = Config::default();
517        assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
518        assert_eq!(
519            resolve_model_profile_alias("vision", &config).unwrap(),
520            None
521        );
522    }
523
524    /// `persist_default_reasoning` writes to the real config path, so
525    /// this test goes through `save_config(_, Some(path))` directly to
526    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
527    /// Uses `std::env::temp_dir` (matching the pattern in
528    /// `session::conversation` and `utils::logger`) — no external
529    /// `tempfile` crate dependency.
530    #[test]
531    fn save_and_reload_preserves_reasoning_field() {
532        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
533        std::fs::create_dir_all(&dir).expect("create temp dir");
534        let path = dir.join("config.toml");
535
536        let mut cfg = Config::default();
537        cfg.default_model.provider = "ollama".to_string();
538        cfg.default_model.name = "qwen3-coder:30b".to_string();
539        cfg.default_model.reasoning = ReasoningLevel::Low;
540
541        save_config(&cfg, Some(path.clone())).expect("save");
542
543        let blob = std::fs::read_to_string(&path).expect("read");
544        let loaded: Config = toml::from_str(&blob).expect("parse back");
545        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
546
547        let _ = std::fs::remove_dir_all(&dir);
548    }
549
550    /// Per-model entries serialize as a TOML table with quoted keys (the
551    /// model IDs contain `/`). This test verifies the round-trip works
552    /// through both serialization and deserialization, matching what
553    /// `persist_reasoning_for_model` would produce in real use.
554    #[test]
555    fn save_and_reload_preserves_reasoning_per_model_table() {
556        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
557        std::fs::create_dir_all(&dir).expect("create temp dir");
558        let path = dir.join("config.toml");
559
560        let mut cfg = Config::default();
561        cfg.reasoning_per_model.insert(
562            "anthropic/claude-sonnet-4-6".to_string(),
563            ReasoningLevel::High,
564        );
565        cfg.reasoning_per_model
566            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
567
568        save_config(&cfg, Some(path.clone())).expect("save");
569
570        let blob = std::fs::read_to_string(&path).expect("read");
571        let loaded: Config = toml::from_str(&blob).expect("parse back");
572        assert_eq!(
573            loaded
574                .reasoning_per_model
575                .get("anthropic/claude-sonnet-4-6"),
576            Some(&ReasoningLevel::High)
577        );
578        assert_eq!(
579            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
580            Some(&ReasoningLevel::Low)
581        );
582
583        let _ = std::fs::remove_dir_all(&dir);
584    }
585
586    /// Configs from before Step 5b don't have a `reasoning_per_model`
587    /// section. Loading them must succeed with an empty map — otherwise
588    /// upgrade breaks every existing user.
589    #[test]
590    fn config_deserializes_without_reasoning_per_model() {
591        let toml_blob = r#"
592            last_used_model = "ollama/qwen3-coder:30b"
593
594            [default_model]
595            provider = "ollama"
596            name = "qwen3-coder:30b"
597            temperature = 0.7
598            max_tokens = 4096
599        "#;
600        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
601        assert!(cfg.reasoning_per_model.is_empty());
602        assert!(!cfg.prompt.is_customized());
603    }
604
605    #[test]
606    fn prompt_config_replaces_and_appends_without_persisting() {
607        let mut cfg = Config::default();
608        cfg.prompt.system_prompt = Some("base".to_string());
609        cfg.prompt
610            .append_system_prompt
611            .push("extra instructions".to_string());
612
613        assert_eq!(
614            cfg.prompt.render_system_prompt("default"),
615            "base\n\nextra instructions"
616        );
617
618        let blob = toml::to_string(&cfg).expect("serialize");
619        assert!(!blob.contains("extra instructions"));
620        let loaded: Config = toml::from_str(&blob).expect("deserialize");
621        assert!(!loaded.prompt.is_customized());
622    }
623}