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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct Config {
12 #[serde(default)]
14 pub last_used_model: Option<String>,
15
16 #[serde(default)]
18 pub default_model: ModelSettings,
19
20 #[serde(default)]
22 pub ollama: OllamaConfig,
23
24 #[serde(default)]
26 pub non_interactive: NonInteractiveConfig,
27
28 #[serde(default)]
30 pub mcp_servers: HashMap<String, McpServerConfig>,
31
32 #[serde(default)]
46 pub providers: HashMap<String, UserProviderConfig>,
47
48 #[serde(default)]
60 pub reasoning_per_model: HashMap<String, ReasoningLevel>,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct UserProviderConfig {
69 #[serde(default)]
72 pub base_url: Option<String>,
73 #[serde(default)]
77 pub api_key_env: Option<String>,
78 #[serde(default)]
80 pub extra_headers: HashMap<String, String>,
81 #[serde(default)]
87 pub compat: Option<String>,
88 #[serde(default)]
92 pub default_model: Option<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct McpServerConfig {
98 pub command: String,
100 #[serde(default)]
102 pub args: Vec<String>,
103 #[serde(default)]
105 pub env: HashMap<String, String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(default)]
111pub struct ModelSettings {
112 pub provider: String,
114 pub name: String,
116 pub temperature: f32,
118 pub max_tokens: usize,
120 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#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(default)]
141pub struct OllamaConfig {
142 pub host: String,
144 pub port: u16,
146 pub cloud_api_key: Option<String>,
150 pub num_gpu: Option<i32>,
153 pub num_thread: Option<i32>,
156 pub num_ctx: Option<i32>,
159 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, num_thread: None, num_ctx: None, numa: None, }
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179#[serde(default)]
180pub struct NonInteractiveConfig {
181 pub output_format: String,
183 pub max_tokens: usize,
185 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
199pub 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
219pub fn get_config_path() -> Result<PathBuf> {
221 Ok(get_config_dir()?.join("config.toml"))
222}
223
224pub 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 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
241pub 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
256pub 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
271pub 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
278pub 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
287pub 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
300pub 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 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 #[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 #[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 #[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 #[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}