Skip to main content

mermaid_cli/app/
config.rs

1use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE};
2use anyhow::{Context, Result};
3use directories::ProjectDirs;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8/// Main configuration structure
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct Config {
11    /// Last used model (persisted between sessions)
12    #[serde(default)]
13    pub last_used_model: Option<String>,
14
15    /// Default model configuration
16    #[serde(default)]
17    pub default_model: ModelSettings,
18
19    /// Ollama configuration
20    #[serde(default)]
21    pub ollama: OllamaConfig,
22
23    /// Non-interactive mode configuration
24    #[serde(default)]
25    pub non_interactive: NonInteractiveConfig,
26
27    /// MCP server configurations
28    #[serde(default)]
29    pub mcp_servers: HashMap<String, McpServerConfig>,
30}
31
32/// MCP server configuration
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct McpServerConfig {
35    /// Command to execute (e.g., "npx", "node", "python")
36    pub command: String,
37    /// Command-line arguments
38    #[serde(default)]
39    pub args: Vec<String>,
40    /// Environment variables for the server process
41    #[serde(default)]
42    pub env: HashMap<String, String>,
43}
44
45/// Default model settings
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct ModelSettings {
49    /// Model provider (ollama, openai, anthropic)
50    pub provider: String,
51    /// Model name
52    pub name: String,
53    /// Temperature for generation
54    pub temperature: f32,
55    /// Maximum tokens to generate
56    pub max_tokens: usize,
57}
58
59impl Default for ModelSettings {
60    fn default() -> Self {
61        Self {
62            provider: String::new(),
63            name: String::new(),
64            temperature: DEFAULT_TEMPERATURE,
65            max_tokens: DEFAULT_MAX_TOKENS,
66        }
67    }
68}
69
70/// Ollama configuration
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(default)]
73pub struct OllamaConfig {
74    /// Ollama server host
75    pub host: String,
76    /// Ollama server port
77    pub port: u16,
78    /// Ollama cloud API key (for :cloud models)
79    /// Set this to use Ollama's cloud inference service
80    /// Get your key at: https://ollama.com/cloud
81    pub cloud_api_key: Option<String>,
82    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
83    /// Lower values free up VRAM for larger models at the cost of speed
84    pub num_gpu: Option<i32>,
85    /// Number of CPU threads for processing offloaded layers
86    /// Higher values improve CPU inference speed for large models
87    pub num_thread: Option<i32>,
88    /// Context window size (number of tokens)
89    /// Larger values allow longer conversations but use more memory
90    pub num_ctx: Option<i32>,
91    /// Enable NUMA optimization for multi-CPU systems
92    pub numa: Option<bool>,
93}
94
95impl Default for OllamaConfig {
96    fn default() -> Self {
97        Self {
98            host: String::from("localhost"),
99            port: DEFAULT_OLLAMA_PORT,
100            cloud_api_key: None,
101            num_gpu: None,    // Let Ollama auto-detect
102            num_thread: None, // Let Ollama auto-detect
103            num_ctx: None,    // Use model default
104            numa: None,       // Auto-detect
105        }
106    }
107}
108
109/// Non-interactive mode configuration
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(default)]
112pub struct NonInteractiveConfig {
113    /// Output format (text, json, markdown)
114    pub output_format: String,
115    /// Maximum tokens to generate
116    pub max_tokens: usize,
117    /// Don't execute agent actions (dry run)
118    pub no_execute: bool,
119}
120
121impl Default for NonInteractiveConfig {
122    fn default() -> Self {
123        Self {
124            output_format: String::from("text"),
125            max_tokens: DEFAULT_MAX_TOKENS,
126            no_execute: false,
127        }
128    }
129}
130
131/// Load configuration from single config file
132/// Priority: config file > defaults (that's it - no merging, no env vars)
133pub fn load_config() -> Result<Config> {
134    let config_path = get_config_path()?;
135
136    if config_path.exists() {
137        let toml_str = std::fs::read_to_string(&config_path)
138            .with_context(|| format!("Failed to read {}", config_path.display()))?;
139        let config: Config = toml::from_str(&toml_str).with_context(|| {
140            format!(
141                "Failed to parse {}. Run 'mermaid init' to regenerate.",
142                config_path.display()
143            )
144        })?;
145        Ok(config)
146    } else {
147        Ok(Config::default())
148    }
149}
150
151/// Get the path to the single config file
152pub fn get_config_path() -> Result<PathBuf> {
153    Ok(get_config_dir()?.join("config.toml"))
154}
155
156/// Get the configuration directory
157pub fn get_config_dir() -> Result<PathBuf> {
158    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
159        let config_dir = proj_dirs.config_dir();
160        std::fs::create_dir_all(config_dir)?;
161        Ok(config_dir.to_path_buf())
162    } else {
163        // Fallback to home directory
164        let home = std::env::var("HOME")
165            .or_else(|_| std::env::var("USERPROFILE"))
166            .context("Could not determine home directory")?;
167        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
168        std::fs::create_dir_all(&config_dir)?;
169        Ok(config_dir)
170    }
171}
172
173/// Save configuration to file
174pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
175    let path = if let Some(p) = path {
176        p
177    } else {
178        get_config_dir()?.join("config.toml")
179    };
180
181    let toml_string = toml::to_string_pretty(config)?;
182    std::fs::write(&path, toml_string)
183        .with_context(|| format!("Failed to write config to {}", path.display()))?;
184
185    Ok(())
186}
187
188/// Create a default configuration file if it doesn't exist
189pub fn init_config() -> Result<()> {
190    let config_file = get_config_path()?;
191
192    if config_file.exists() {
193        println!("Configuration already exists at: {}", config_file.display());
194    } else {
195        let default_config = Config::default();
196        save_config(&default_config, Some(config_file.clone()))?;
197        println!("Created configuration at: {}", config_file.display());
198    }
199
200    Ok(())
201}
202
203/// Persist the last used model to config file
204pub fn persist_last_model(model: &str) -> Result<()> {
205    let mut config = load_config().unwrap_or_default();
206    config.last_used_model = Some(model.to_string());
207    save_config(&config, None)
208}
209
210/// Resolve which model to use: CLI arg > last_used > default_model > any available
211pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
212    if let Some(model) = cli_model {
213        return Ok(model.to_string());
214    }
215    if let Some(last_model) = &config.last_used_model {
216        return Ok(last_model.clone());
217    }
218    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
219        return Ok(format!(
220            "{}/{}",
221            config.default_model.provider, config.default_model.name
222        ));
223    }
224    let available = crate::ollama::require_any_model().await?;
225    Ok(format!("ollama/{}", available[0]))
226}