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 #[serde(default)]
135 pub auto_classifier_model: Option<String>,
136}
137
138impl Default for SafetyConfig {
139 fn default() -> Self {
140 Self {
141 mode: SafetyMode::Ask,
145 checkpoint_on_mutation: true,
146 overrides: Vec::new(),
147 auto_classifier_model: None,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct UserProviderConfig {
158 #[serde(default)]
161 pub base_url: Option<String>,
162 #[serde(default)]
166 pub api_key_env: Option<String>,
167 #[serde(default)]
169 pub extra_headers: HashMap<String, String>,
170 #[serde(default)]
176 pub compat: Option<String>,
177 #[serde(default)]
181 pub default_model: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct McpServerConfig {
187 pub command: String,
189 #[serde(default)]
191 pub args: Vec<String>,
192 #[serde(default)]
194 pub env: HashMap<String, String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(default)]
200pub struct ModelSettings {
201 pub provider: String,
203 pub name: String,
205 pub temperature: f32,
207 pub max_tokens: usize,
209 pub reasoning: ReasoningLevel,
213}
214
215impl Default for ModelSettings {
216 fn default() -> Self {
217 Self {
218 provider: String::new(),
219 name: String::new(),
220 temperature: DEFAULT_TEMPERATURE,
221 max_tokens: DEFAULT_MAX_TOKENS,
222 reasoning: ReasoningLevel::default(),
223 }
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229#[serde(default)]
230pub struct OllamaConfig {
231 pub host: String,
233 pub port: u16,
235 pub cloud_api_key: Option<String>,
239 pub num_gpu: Option<i32>,
242 pub num_thread: Option<i32>,
245 pub num_ctx: Option<i32>,
248 pub numa: Option<bool>,
250}
251
252impl Default for OllamaConfig {
253 fn default() -> Self {
254 Self {
255 host: String::from("localhost"),
256 port: DEFAULT_OLLAMA_PORT,
257 cloud_api_key: None,
258 num_gpu: None, num_thread: None, num_ctx: None, numa: None, }
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268#[serde(default)]
269pub struct NonInteractiveConfig {
270 pub output_format: String,
272 pub max_tokens: usize,
274 pub no_execute: bool,
276}
277
278impl Default for NonInteractiveConfig {
279 fn default() -> Self {
280 Self {
281 output_format: String::from("text"),
282 max_tokens: DEFAULT_MAX_TOKENS,
283 no_execute: false,
284 }
285 }
286}
287
288pub fn load_config() -> Result<Config> {
291 let config_path = get_config_path()?;
292
293 if config_path.exists() {
294 let toml_str = std::fs::read_to_string(&config_path)
295 .with_context(|| format!("Failed to read {}", config_path.display()))?;
296 let config: Config = toml::from_str(&toml_str).with_context(|| {
297 format!(
298 "Failed to parse {}. Run 'mermaid init' to regenerate.",
299 config_path.display()
300 )
301 })?;
302 Ok(config)
303 } else {
304 Ok(Config::default())
305 }
306}
307
308pub fn get_config_path() -> Result<PathBuf> {
310 Ok(get_config_dir()?.join("config.toml"))
311}
312
313pub fn get_config_dir() -> Result<PathBuf> {
315 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
316 let config_dir = proj_dirs.config_dir();
317 std::fs::create_dir_all(config_dir)?;
318 Ok(config_dir.to_path_buf())
319 } else {
320 let home = std::env::var("HOME")
322 .or_else(|_| std::env::var("USERPROFILE"))
323 .context("Could not determine home directory")?;
324 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
325 std::fs::create_dir_all(&config_dir)?;
326 Ok(config_dir)
327 }
328}
329
330pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
332 let path = if let Some(p) = path {
333 p
334 } else {
335 get_config_dir()?.join("config.toml")
336 };
337
338 let toml_string = toml::to_string_pretty(config)?;
339 std::fs::write(&path, toml_string)
340 .with_context(|| format!("Failed to write config to {}", path.display()))?;
341
342 Ok(())
343}
344
345pub fn init_config() -> Result<()> {
347 let config_file = get_config_path()?;
348
349 if config_file.exists() {
350 println!("Configuration already exists at: {}", config_file.display());
351 } else {
352 let default_config = Config::default();
353 save_config(&default_config, Some(config_file.clone()))?;
354 println!("Created configuration at: {}", config_file.display());
355 }
356
357 Ok(())
358}
359
360pub fn persist_last_model(model: &str) -> Result<()> {
362 let mut config = load_config().unwrap_or_default();
363 config.last_used_model = Some(model.to_string());
364 save_config(&config, None)
365}
366
367pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
371 let mut config = load_config().unwrap_or_default();
372 config.default_model.reasoning = level;
373 save_config(&config, None)
374}
375
376pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
382 let mut config = load_config().unwrap_or_default();
383 config
384 .reasoning_per_model
385 .insert(model_id.to_string(), level);
386 save_config(&config, None)
387}
388
389pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
391 if let Some(model) = cli_model {
392 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
393 return Ok(resolved);
394 }
395 return Ok(model.to_string());
396 }
397 if let Some(last_model) = &config.last_used_model {
398 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
399 return Ok(resolved);
400 }
401 return Ok(last_model.clone());
402 }
403 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
404 return Ok(format!(
405 "{}/{}",
406 config.default_model.provider, config.default_model.name
407 ));
408 }
409 let available = crate::ollama::require_any_model(config).await?;
410 let first = available
414 .first()
415 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
416 Ok(format!("ollama/{}", first))
417}
418
419fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
420 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
421 if let Some(model) = config.model_profiles.get(profile) {
422 anyhow::ensure!(
423 !model.trim().is_empty(),
424 "model profile `{}` is configured with an empty model id",
425 profile
426 );
427 return Ok(Some(model.clone()));
428 }
429 if requested.starts_with("profile:") {
430 anyhow::bail!(
431 "model profile `{}` is not configured; add it under [model_profiles]",
432 profile
433 );
434 }
435 Ok(None)
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 #[test]
447 fn model_settings_deserializes_without_reasoning_field() {
448 let toml_blob = r#"
449 provider = "ollama"
450 name = "qwen3-coder:30b"
451 temperature = 0.7
452 max_tokens = 4096
453 "#;
454 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
455 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
456 assert_eq!(settings.provider, "ollama");
457 }
458
459 #[test]
460 fn model_settings_round_trips_reasoning_high() {
461 let original = ModelSettings {
462 provider: "anthropic".to_string(),
463 name: "claude-sonnet-4-6".to_string(),
464 temperature: 0.5,
465 max_tokens: 8192,
466 reasoning: ReasoningLevel::High,
467 };
468 let toml_blob = toml::to_string(&original).expect("serialize");
469 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
470 assert_eq!(back.reasoning, ReasoningLevel::High);
471 assert_eq!(back.name, "claude-sonnet-4-6");
472 }
473
474 #[test]
475 fn configured_model_profile_resolves_explicit_alias() {
476 let mut config = Config::default();
477 config
478 .model_profiles
479 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
480 assert_eq!(
481 resolve_model_profile_alias("fast", &config).unwrap(),
482 Some("ollama/qwen3-coder:14b".to_string())
483 );
484 assert_eq!(
485 resolve_model_profile_alias("profile:fast", &config).unwrap(),
486 Some("ollama/qwen3-coder:14b".to_string())
487 );
488 }
489
490 #[test]
491 fn profile_prefix_requires_configuration() {
492 let config = Config::default();
493 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
494 assert_eq!(
495 resolve_model_profile_alias("vision", &config).unwrap(),
496 None
497 );
498 }
499
500 #[test]
507 fn save_and_reload_preserves_reasoning_field() {
508 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
509 std::fs::create_dir_all(&dir).expect("create temp dir");
510 let path = dir.join("config.toml");
511
512 let mut cfg = Config::default();
513 cfg.default_model.provider = "ollama".to_string();
514 cfg.default_model.name = "qwen3-coder:30b".to_string();
515 cfg.default_model.reasoning = ReasoningLevel::Low;
516
517 save_config(&cfg, Some(path.clone())).expect("save");
518
519 let blob = std::fs::read_to_string(&path).expect("read");
520 let loaded: Config = toml::from_str(&blob).expect("parse back");
521 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
522
523 let _ = std::fs::remove_dir_all(&dir);
524 }
525
526 #[test]
531 fn save_and_reload_preserves_reasoning_per_model_table() {
532 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_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.reasoning_per_model.insert(
538 "anthropic/claude-sonnet-4-6".to_string(),
539 ReasoningLevel::High,
540 );
541 cfg.reasoning_per_model
542 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
543
544 save_config(&cfg, Some(path.clone())).expect("save");
545
546 let blob = std::fs::read_to_string(&path).expect("read");
547 let loaded: Config = toml::from_str(&blob).expect("parse back");
548 assert_eq!(
549 loaded
550 .reasoning_per_model
551 .get("anthropic/claude-sonnet-4-6"),
552 Some(&ReasoningLevel::High)
553 );
554 assert_eq!(
555 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
556 Some(&ReasoningLevel::Low)
557 );
558
559 let _ = std::fs::remove_dir_all(&dir);
560 }
561
562 #[test]
566 fn config_deserializes_without_reasoning_per_model() {
567 let toml_blob = r#"
568 last_used_model = "ollama/qwen3-coder:30b"
569
570 [default_model]
571 provider = "ollama"
572 name = "qwen3-coder:30b"
573 temperature = 0.7
574 max_tokens = 4096
575 "#;
576 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
577 assert!(cfg.reasoning_per_model.is_empty());
578 assert!(!cfg.prompt.is_customized());
579 }
580
581 #[test]
582 fn prompt_config_replaces_and_appends_without_persisting() {
583 let mut cfg = Config::default();
584 cfg.prompt.system_prompt = Some("base".to_string());
585 cfg.prompt
586 .append_system_prompt
587 .push("extra instructions".to_string());
588
589 assert_eq!(
590 cfg.prompt.render_system_prompt("default"),
591 "base\n\nextra instructions"
592 );
593
594 let blob = toml::to_string(&cfg).expect("serialize");
595 assert!(!blob.contains("extra instructions"));
596 let loaded: Config = toml::from_str(&blob).expect("deserialize");
597 assert!(!loaded.prompt.is_customized());
598 }
599}