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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Config {
13 #[serde(default)]
15 pub last_used_model: Option<String>,
16
17 #[serde(default)]
19 pub default_model: ModelSettings,
20
21 #[serde(default)]
23 pub ollama: OllamaConfig,
24
25 #[serde(default)]
27 pub non_interactive: NonInteractiveConfig,
28
29 #[serde(default)]
31 pub mcp_servers: HashMap<String, McpServerConfig>,
32
33 #[serde(default)]
47 pub providers: HashMap<String, UserProviderConfig>,
48
49 #[serde(default)]
61 pub reasoning_per_model: HashMap<String, ReasoningLevel>,
62
63 #[serde(default)]
75 pub model_profiles: HashMap<String, String>,
76
77 #[serde(default)]
81 pub safety: SafetyConfig,
82
83 #[serde(skip)]
87 pub prompt: PromptConfig,
88}
89
90#[derive(Debug, Clone, Default)]
91pub struct PromptConfig {
92 pub system_prompt: Option<String>,
93 pub append_system_prompt: Vec<String>,
94}
95
96impl PromptConfig {
97 pub fn render_system_prompt(&self, default_prompt: &str) -> String {
98 let mut rendered = self
99 .system_prompt
100 .as_deref()
101 .unwrap_or(default_prompt)
102 .trim_end()
103 .to_string();
104
105 for extra in &self.append_system_prompt {
106 let extra = extra.trim();
107 if extra.is_empty() {
108 continue;
109 }
110 if !rendered.is_empty() {
111 rendered.push_str("\n\n");
112 }
113 rendered.push_str(extra);
114 }
115
116 rendered
117 }
118
119 pub fn is_customized(&self) -> bool {
120 self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(default)]
126pub struct SafetyConfig {
127 pub mode: SafetyMode,
128 pub checkpoint_on_mutation: bool,
129 #[serde(default)]
130 pub overrides: Vec<PolicyOverride>,
131}
132
133impl Default for SafetyConfig {
134 fn default() -> Self {
135 Self {
136 mode: SafetyMode::Ask,
140 checkpoint_on_mutation: true,
141 overrides: Vec::new(),
142 }
143 }
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
151pub struct UserProviderConfig {
152 #[serde(default)]
155 pub base_url: Option<String>,
156 #[serde(default)]
160 pub api_key_env: Option<String>,
161 #[serde(default)]
163 pub extra_headers: HashMap<String, String>,
164 #[serde(default)]
170 pub compat: Option<String>,
171 #[serde(default)]
175 pub default_model: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct McpServerConfig {
181 pub command: String,
183 #[serde(default)]
185 pub args: Vec<String>,
186 #[serde(default)]
188 pub env: HashMap<String, String>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(default)]
194pub struct ModelSettings {
195 pub provider: String,
197 pub name: String,
199 pub temperature: f32,
201 pub max_tokens: usize,
203 pub reasoning: ReasoningLevel,
207}
208
209impl Default for ModelSettings {
210 fn default() -> Self {
211 Self {
212 provider: String::new(),
213 name: String::new(),
214 temperature: DEFAULT_TEMPERATURE,
215 max_tokens: DEFAULT_MAX_TOKENS,
216 reasoning: ReasoningLevel::default(),
217 }
218 }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(default)]
224pub struct OllamaConfig {
225 pub host: String,
227 pub port: u16,
229 pub cloud_api_key: Option<String>,
233 pub num_gpu: Option<i32>,
236 pub num_thread: Option<i32>,
239 pub num_ctx: Option<i32>,
242 pub numa: Option<bool>,
244}
245
246impl Default for OllamaConfig {
247 fn default() -> Self {
248 Self {
249 host: String::from("localhost"),
250 port: DEFAULT_OLLAMA_PORT,
251 cloud_api_key: None,
252 num_gpu: None, num_thread: None, num_ctx: None, numa: None, }
257 }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(default)]
263pub struct NonInteractiveConfig {
264 pub output_format: String,
266 pub max_tokens: usize,
268 pub no_execute: bool,
270}
271
272impl Default for NonInteractiveConfig {
273 fn default() -> Self {
274 Self {
275 output_format: String::from("text"),
276 max_tokens: DEFAULT_MAX_TOKENS,
277 no_execute: false,
278 }
279 }
280}
281
282pub fn load_config() -> Result<Config> {
285 let config_path = get_config_path()?;
286
287 if config_path.exists() {
288 let toml_str = std::fs::read_to_string(&config_path)
289 .with_context(|| format!("Failed to read {}", config_path.display()))?;
290 let config: Config = toml::from_str(&toml_str).with_context(|| {
291 format!(
292 "Failed to parse {}. Run 'mermaid init' to regenerate.",
293 config_path.display()
294 )
295 })?;
296 Ok(config)
297 } else {
298 Ok(Config::default())
299 }
300}
301
302pub fn get_config_path() -> Result<PathBuf> {
304 Ok(get_config_dir()?.join("config.toml"))
305}
306
307pub fn get_config_dir() -> Result<PathBuf> {
309 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
310 let config_dir = proj_dirs.config_dir();
311 std::fs::create_dir_all(config_dir)?;
312 Ok(config_dir.to_path_buf())
313 } else {
314 let home = std::env::var("HOME")
316 .or_else(|_| std::env::var("USERPROFILE"))
317 .context("Could not determine home directory")?;
318 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
319 std::fs::create_dir_all(&config_dir)?;
320 Ok(config_dir)
321 }
322}
323
324pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
326 let path = if let Some(p) = path {
327 p
328 } else {
329 get_config_dir()?.join("config.toml")
330 };
331
332 let toml_string = toml::to_string_pretty(config)?;
333 std::fs::write(&path, toml_string)
334 .with_context(|| format!("Failed to write config to {}", path.display()))?;
335
336 Ok(())
337}
338
339pub fn init_config() -> Result<()> {
341 let config_file = get_config_path()?;
342
343 if config_file.exists() {
344 println!("Configuration already exists at: {}", config_file.display());
345 } else {
346 let default_config = Config::default();
347 save_config(&default_config, Some(config_file.clone()))?;
348 println!("Created configuration at: {}", config_file.display());
349 }
350
351 Ok(())
352}
353
354pub fn persist_last_model(model: &str) -> Result<()> {
356 let mut config = load_config().unwrap_or_default();
357 config.last_used_model = Some(model.to_string());
358 save_config(&config, None)
359}
360
361pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
365 let mut config = load_config().unwrap_or_default();
366 config.default_model.reasoning = level;
367 save_config(&config, None)
368}
369
370pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
376 let mut config = load_config().unwrap_or_default();
377 config
378 .reasoning_per_model
379 .insert(model_id.to_string(), level);
380 save_config(&config, None)
381}
382
383pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
385 if let Some(model) = cli_model {
386 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
387 return Ok(resolved);
388 }
389 return Ok(model.to_string());
390 }
391 if let Some(last_model) = &config.last_used_model {
392 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
393 return Ok(resolved);
394 }
395 return Ok(last_model.clone());
396 }
397 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
398 return Ok(format!(
399 "{}/{}",
400 config.default_model.provider, config.default_model.name
401 ));
402 }
403 let available = crate::ollama::require_any_model(config).await?;
404 let first = available
408 .first()
409 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
410 Ok(format!("ollama/{}", first))
411}
412
413fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
414 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
415 if let Some(model) = config.model_profiles.get(profile) {
416 anyhow::ensure!(
417 !model.trim().is_empty(),
418 "model profile `{}` is configured with an empty model id",
419 profile
420 );
421 return Ok(Some(model.clone()));
422 }
423 if requested.starts_with("profile:") {
424 anyhow::bail!(
425 "model profile `{}` is not configured; add it under [model_profiles]",
426 profile
427 );
428 }
429 Ok(None)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
441 fn model_settings_deserializes_without_reasoning_field() {
442 let toml_blob = r#"
443 provider = "ollama"
444 name = "qwen3-coder:30b"
445 temperature = 0.7
446 max_tokens = 4096
447 "#;
448 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
449 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
450 assert_eq!(settings.provider, "ollama");
451 }
452
453 #[test]
454 fn model_settings_round_trips_reasoning_high() {
455 let original = ModelSettings {
456 provider: "anthropic".to_string(),
457 name: "claude-sonnet-4-6".to_string(),
458 temperature: 0.5,
459 max_tokens: 8192,
460 reasoning: ReasoningLevel::High,
461 };
462 let toml_blob = toml::to_string(&original).expect("serialize");
463 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
464 assert_eq!(back.reasoning, ReasoningLevel::High);
465 assert_eq!(back.name, "claude-sonnet-4-6");
466 }
467
468 #[test]
469 fn configured_model_profile_resolves_explicit_alias() {
470 let mut config = Config::default();
471 config
472 .model_profiles
473 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
474 assert_eq!(
475 resolve_model_profile_alias("fast", &config).unwrap(),
476 Some("ollama/qwen3-coder:14b".to_string())
477 );
478 assert_eq!(
479 resolve_model_profile_alias("profile:fast", &config).unwrap(),
480 Some("ollama/qwen3-coder:14b".to_string())
481 );
482 }
483
484 #[test]
485 fn profile_prefix_requires_configuration() {
486 let config = Config::default();
487 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
488 assert_eq!(
489 resolve_model_profile_alias("vision", &config).unwrap(),
490 None
491 );
492 }
493
494 #[test]
501 fn save_and_reload_preserves_reasoning_field() {
502 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
503 std::fs::create_dir_all(&dir).expect("create temp dir");
504 let path = dir.join("config.toml");
505
506 let mut cfg = Config::default();
507 cfg.default_model.provider = "ollama".to_string();
508 cfg.default_model.name = "qwen3-coder:30b".to_string();
509 cfg.default_model.reasoning = ReasoningLevel::Low;
510
511 save_config(&cfg, Some(path.clone())).expect("save");
512
513 let blob = std::fs::read_to_string(&path).expect("read");
514 let loaded: Config = toml::from_str(&blob).expect("parse back");
515 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
516
517 let _ = std::fs::remove_dir_all(&dir);
518 }
519
520 #[test]
525 fn save_and_reload_preserves_reasoning_per_model_table() {
526 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
527 std::fs::create_dir_all(&dir).expect("create temp dir");
528 let path = dir.join("config.toml");
529
530 let mut cfg = Config::default();
531 cfg.reasoning_per_model.insert(
532 "anthropic/claude-sonnet-4-6".to_string(),
533 ReasoningLevel::High,
534 );
535 cfg.reasoning_per_model
536 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
537
538 save_config(&cfg, Some(path.clone())).expect("save");
539
540 let blob = std::fs::read_to_string(&path).expect("read");
541 let loaded: Config = toml::from_str(&blob).expect("parse back");
542 assert_eq!(
543 loaded
544 .reasoning_per_model
545 .get("anthropic/claude-sonnet-4-6"),
546 Some(&ReasoningLevel::High)
547 );
548 assert_eq!(
549 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
550 Some(&ReasoningLevel::Low)
551 );
552
553 let _ = std::fs::remove_dir_all(&dir);
554 }
555
556 #[test]
560 fn config_deserializes_without_reasoning_per_model() {
561 let toml_blob = r#"
562 last_used_model = "ollama/qwen3-coder:30b"
563
564 [default_model]
565 provider = "ollama"
566 name = "qwen3-coder:30b"
567 temperature = 0.7
568 max_tokens = 4096
569 "#;
570 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
571 assert!(cfg.reasoning_per_model.is_empty());
572 assert!(!cfg.prompt.is_customized());
573 }
574
575 #[test]
576 fn prompt_config_replaces_and_appends_without_persisting() {
577 let mut cfg = Config::default();
578 cfg.prompt.system_prompt = Some("base".to_string());
579 cfg.prompt
580 .append_system_prompt
581 .push("extra instructions".to_string());
582
583 assert_eq!(
584 cfg.prompt.render_system_prompt("default"),
585 "base\n\nextra instructions"
586 );
587
588 let blob = toml::to_string(&cfg).expect("serialize");
589 assert!(!blob.contains("extra instructions"));
590 let loaded: Config = toml::from_str(&blob).expect("deserialize");
591 assert!(!loaded.prompt.is_customized());
592 }
593}