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 anyhow::{Context, Result};
4use directories::ProjectDirs;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9/// Main configuration structure
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct Config {
12    /// Last used model (persisted between sessions)
13    #[serde(default)]
14    pub last_used_model: Option<String>,
15
16    /// Default model configuration
17    #[serde(default)]
18    pub default_model: ModelSettings,
19
20    /// Ollama configuration
21    #[serde(default)]
22    pub ollama: OllamaConfig,
23
24    /// Non-interactive mode configuration
25    #[serde(default)]
26    pub non_interactive: NonInteractiveConfig,
27
28    /// MCP server configurations
29    #[serde(default)]
30    pub mcp_servers: HashMap<String, McpServerConfig>,
31
32    /// User overrides + custom OpenAI-compatible providers. Keys are
33    /// provider names; matching a built-in registry entry overrides its
34    /// defaults, anything else defines a fully custom provider.
35    /// Example:
36    /// ```toml
37    /// [providers.groq]
38    /// api_key_env = "MY_GROQ_KEY"  # override default GROQ_API_KEY
39    ///
40    /// [providers.my-vllm]
41    /// base_url = "http://192.168.1.42:8000/v1"
42    /// api_key_env = "VLLM_KEY"
43    /// compat = "openai-effort"
44    /// ```
45    #[serde(default)]
46    pub providers: HashMap<String, UserProviderConfig>,
47
48    /// Per-model reasoning preferences keyed by full model ID
49    /// (`provider/name`). Set when the user runs `/reasoning <level>` or
50    /// Alt+T cycles while using a specific model — the new value sticks
51    /// for that model until changed. Falls back to
52    /// `default_model.reasoning` when no entry exists.
53    /// Example:
54    /// ```toml
55    /// [reasoning_per_model]
56    /// "anthropic/claude-sonnet-4-6" = "high"
57    /// "ollama/qwen3-coder:30b" = "low"
58    /// ```
59    #[serde(default)]
60    pub reasoning_per_model: HashMap<String, ReasoningLevel>,
61}
62
63/// User-supplied OpenAI-compatible provider configuration. All fields are
64/// optional — when matching a built-in registry entry, only the supplied
65/// fields override; the rest fall back to the registry defaults. For
66/// fully custom providers, `base_url` and `api_key_env` are required.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct UserProviderConfig {
69    /// Override base URL for `/chat/completions` (None = use built-in
70    /// registry default; required for fully custom providers).
71    #[serde(default)]
72    pub base_url: Option<String>,
73    /// Env var name to read the API key from (None = use the built-in
74    /// registry default like `GROQ_API_KEY`; required for fully custom
75    /// providers).
76    #[serde(default)]
77    pub api_key_env: Option<String>,
78    /// Extra HTTP headers sent on every request to this provider.
79    #[serde(default)]
80    pub extra_headers: HashMap<String, String>,
81    /// For fully custom providers (no built-in registry entry), declares
82    /// which OpenAI-compatible shape the endpoint speaks. Ignored when
83    /// the provider name matches a built-in registry entry. Values:
84    /// `"openai"` (no reasoning), `"openai-effort"` (`reasoning_effort`
85    /// field), `"openrouter"` (nested `reasoning: {effort}` object).
86    #[serde(default)]
87    pub compat: Option<String>,
88    /// Optional preferred model — surfaced by `mermaid status` and used
89    /// as the default when the user picks this provider with no model
90    /// suffix.
91    #[serde(default)]
92    pub default_model: Option<String>,
93}
94
95/// MCP server configuration
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct McpServerConfig {
98    /// Command to execute (e.g., "npx", "node", "python")
99    pub command: String,
100    /// Command-line arguments
101    #[serde(default)]
102    pub args: Vec<String>,
103    /// Environment variables for the server process
104    #[serde(default)]
105    pub env: HashMap<String, String>,
106}
107
108/// Default model settings
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(default)]
111pub struct ModelSettings {
112    /// Model provider (ollama, openai, anthropic)
113    pub provider: String,
114    /// Model name
115    pub name: String,
116    /// Temperature for generation
117    pub temperature: f32,
118    /// Maximum tokens to generate
119    pub max_tokens: usize,
120    /// Default reasoning depth used for new sessions when no `--reasoning`
121    /// flag is given. Each adapter snaps this onto the closest level the
122    /// model actually supports via `nearest_effort()`.
123    pub reasoning: ReasoningLevel,
124}
125
126impl Default for ModelSettings {
127    fn default() -> Self {
128        Self {
129            provider: String::new(),
130            name: String::new(),
131            temperature: DEFAULT_TEMPERATURE,
132            max_tokens: DEFAULT_MAX_TOKENS,
133            reasoning: ReasoningLevel::default(),
134        }
135    }
136}
137
138/// Ollama configuration
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141pub struct OllamaConfig {
142    /// Ollama server host
143    pub host: String,
144    /// Ollama server port
145    pub port: u16,
146    /// Ollama cloud API key (for :cloud models)
147    /// Set this to use Ollama's cloud inference service
148    /// Get your key at: https://ollama.com/cloud
149    pub cloud_api_key: Option<String>,
150    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
151    /// Lower values free up VRAM for larger models at the cost of speed
152    pub num_gpu: Option<i32>,
153    /// Number of CPU threads for processing offloaded layers
154    /// Higher values improve CPU inference speed for large models
155    pub num_thread: Option<i32>,
156    /// Context window size (number of tokens)
157    /// Larger values allow longer conversations but use more memory
158    pub num_ctx: Option<i32>,
159    /// Enable NUMA optimization for multi-CPU systems
160    pub numa: Option<bool>,
161}
162
163impl Default for OllamaConfig {
164    fn default() -> Self {
165        Self {
166            host: String::from("localhost"),
167            port: DEFAULT_OLLAMA_PORT,
168            cloud_api_key: None,
169            num_gpu: None,    // Let Ollama auto-detect
170            num_thread: None, // Let Ollama auto-detect
171            num_ctx: None,    // Use model default
172            numa: None,       // Auto-detect
173        }
174    }
175}
176
177/// Non-interactive mode configuration
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(default)]
180pub struct NonInteractiveConfig {
181    /// Output format (text, json, markdown)
182    pub output_format: String,
183    /// Maximum tokens to generate
184    pub max_tokens: usize,
185    /// Don't execute agent actions (dry run)
186    pub no_execute: bool,
187}
188
189impl Default for NonInteractiveConfig {
190    fn default() -> Self {
191        Self {
192            output_format: String::from("text"),
193            max_tokens: DEFAULT_MAX_TOKENS,
194            no_execute: false,
195        }
196    }
197}
198
199/// Load configuration from single config file
200/// Priority: config file > defaults (that's it - no merging, no env vars)
201pub fn load_config() -> Result<Config> {
202    let config_path = get_config_path()?;
203
204    if config_path.exists() {
205        let toml_str = std::fs::read_to_string(&config_path)
206            .with_context(|| format!("Failed to read {}", config_path.display()))?;
207        let config: Config = toml::from_str(&toml_str).with_context(|| {
208            format!(
209                "Failed to parse {}. Run 'mermaid init' to regenerate.",
210                config_path.display()
211            )
212        })?;
213        Ok(config)
214    } else {
215        Ok(Config::default())
216    }
217}
218
219/// Get the path to the single config file
220pub fn get_config_path() -> Result<PathBuf> {
221    Ok(get_config_dir()?.join("config.toml"))
222}
223
224/// Get the configuration directory
225pub fn get_config_dir() -> Result<PathBuf> {
226    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
227        let config_dir = proj_dirs.config_dir();
228        std::fs::create_dir_all(config_dir)?;
229        Ok(config_dir.to_path_buf())
230    } else {
231        // Fallback to home directory
232        let home = std::env::var("HOME")
233            .or_else(|_| std::env::var("USERPROFILE"))
234            .context("Could not determine home directory")?;
235        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
236        std::fs::create_dir_all(&config_dir)?;
237        Ok(config_dir)
238    }
239}
240
241/// Save configuration to file
242pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
243    let path = if let Some(p) = path {
244        p
245    } else {
246        get_config_dir()?.join("config.toml")
247    };
248
249    let toml_string = toml::to_string_pretty(config)?;
250    std::fs::write(&path, toml_string)
251        .with_context(|| format!("Failed to write config to {}", path.display()))?;
252
253    Ok(())
254}
255
256/// Create a default configuration file if it doesn't exist
257pub fn init_config() -> Result<()> {
258    let config_file = get_config_path()?;
259
260    if config_file.exists() {
261        println!("Configuration already exists at: {}", config_file.display());
262    } else {
263        let default_config = Config::default();
264        save_config(&default_config, Some(config_file.clone()))?;
265        println!("Created configuration at: {}", config_file.display());
266    }
267
268    Ok(())
269}
270
271/// Persist the last used model to config file
272pub fn persist_last_model(model: &str) -> Result<()> {
273    let mut config = load_config().unwrap_or_default();
274    config.last_used_model = Some(model.to_string());
275    save_config(&config, None)
276}
277
278/// Persist the user's default reasoning level to config file. Mirrors
279/// `persist_last_model` — used by the `/reasoning` slash command and the
280/// Alt+T cycle handler so the choice survives across sessions.
281pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
282    let mut config = load_config().unwrap_or_default();
283    config.default_model.reasoning = level;
284    save_config(&config, None)
285}
286
287/// Persist a reasoning level for a specific model ID
288/// (e.g. `anthropic/claude-sonnet-4-6`). The TUI calls this from Alt+T,
289/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
290/// the choice sticks per-model rather than bleeding into other models on
291/// next session start.
292pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
293    let mut config = load_config().unwrap_or_default();
294    config
295        .reasoning_per_model
296        .insert(model_id.to_string(), level);
297    save_config(&config, None)
298}
299
300/// Resolve which model to use: CLI arg > last_used > default_model > any available
301pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
302    if let Some(model) = cli_model {
303        return Ok(model.to_string());
304    }
305    if let Some(last_model) = &config.last_used_model {
306        return Ok(last_model.clone());
307    }
308    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
309        return Ok(format!(
310            "{}/{}",
311            config.default_model.provider, config.default_model.name
312        ));
313    }
314    let available = crate::ollama::require_any_model(config).await?;
315    // `require_any_model` already errors on empty, so this `.first()` is
316    // never `None` in practice. Use `.first()` over `[0]` so the precondition
317    // is enforced by the type system instead of by a comment.
318    let first = available
319        .first()
320        .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
321    Ok(format!("ollama/{}", first))
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    /// Configs persisted before Step 4 don't have a `reasoning` field on
329    /// `[default_model]`. Loading them must succeed and yield the
330    /// `Medium` default — otherwise existing user configs break on
331    /// upgrade.
332    #[test]
333    fn model_settings_deserializes_without_reasoning_field() {
334        let toml_blob = r#"
335            provider = "ollama"
336            name = "qwen3-coder:30b"
337            temperature = 0.7
338            max_tokens = 4096
339        "#;
340        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
341        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
342        assert_eq!(settings.provider, "ollama");
343    }
344
345    #[test]
346    fn model_settings_round_trips_reasoning_high() {
347        let original = ModelSettings {
348            provider: "anthropic".to_string(),
349            name: "claude-sonnet-4-6".to_string(),
350            temperature: 0.5,
351            max_tokens: 8192,
352            reasoning: ReasoningLevel::High,
353        };
354        let toml_blob = toml::to_string(&original).expect("serialize");
355        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
356        assert_eq!(back.reasoning, ReasoningLevel::High);
357        assert_eq!(back.name, "claude-sonnet-4-6");
358    }
359
360    /// `persist_default_reasoning` writes to the real config path, so
361    /// this test goes through `save_config(_, Some(path))` directly to
362    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
363    /// Uses `std::env::temp_dir` (matching the pattern in
364    /// `session::conversation` and `utils::logger`) — no external
365    /// `tempfile` crate dependency.
366    #[test]
367    fn save_and_reload_preserves_reasoning_field() {
368        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
369        std::fs::create_dir_all(&dir).expect("create temp dir");
370        let path = dir.join("config.toml");
371
372        let mut cfg = Config::default();
373        cfg.default_model.provider = "ollama".to_string();
374        cfg.default_model.name = "qwen3-coder:30b".to_string();
375        cfg.default_model.reasoning = ReasoningLevel::Low;
376
377        save_config(&cfg, Some(path.clone())).expect("save");
378
379        let blob = std::fs::read_to_string(&path).expect("read");
380        let loaded: Config = toml::from_str(&blob).expect("parse back");
381        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
382
383        let _ = std::fs::remove_dir_all(&dir);
384    }
385
386    /// Per-model entries serialize as a TOML table with quoted keys (the
387    /// model IDs contain `/`). This test verifies the round-trip works
388    /// through both serialization and deserialization, matching what
389    /// `persist_reasoning_for_model` would produce in real use.
390    #[test]
391    fn save_and_reload_preserves_reasoning_per_model_table() {
392        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
393        std::fs::create_dir_all(&dir).expect("create temp dir");
394        let path = dir.join("config.toml");
395
396        let mut cfg = Config::default();
397        cfg.reasoning_per_model.insert(
398            "anthropic/claude-sonnet-4-6".to_string(),
399            ReasoningLevel::High,
400        );
401        cfg.reasoning_per_model
402            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
403
404        save_config(&cfg, Some(path.clone())).expect("save");
405
406        let blob = std::fs::read_to_string(&path).expect("read");
407        let loaded: Config = toml::from_str(&blob).expect("parse back");
408        assert_eq!(
409            loaded
410                .reasoning_per_model
411                .get("anthropic/claude-sonnet-4-6"),
412            Some(&ReasoningLevel::High)
413        );
414        assert_eq!(
415            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
416            Some(&ReasoningLevel::Low)
417        );
418
419        let _ = std::fs::remove_dir_all(&dir);
420    }
421
422    /// Configs from before Step 5b don't have a `reasoning_per_model`
423    /// section. Loading them must succeed with an empty map — otherwise
424    /// upgrade breaks every existing user.
425    #[test]
426    fn config_deserializes_without_reasoning_per_model() {
427        let toml_blob = r#"
428            last_used_model = "ollama/qwen3-coder:30b"
429
430            [default_model]
431            provider = "ollama"
432            name = "qwen3-coder:30b"
433            temperature = 0.7
434            max_tokens = 4096
435        "#;
436        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
437        assert!(cfg.reasoning_per_model.is_empty());
438    }
439}