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(default)]
85 pub memory: MemoryConfig,
86
87 #[serde(default)]
89 pub computer_use: ComputerUseConfig,
90
91 #[serde(skip)]
95 pub prompt: PromptConfig,
96}
97
98#[derive(Debug, Clone, Default)]
99pub struct PromptConfig {
100 pub system_prompt: Option<String>,
101 pub append_system_prompt: Vec<String>,
102}
103
104impl PromptConfig {
105 pub fn render_system_prompt(&self, default_prompt: &str) -> String {
106 let mut rendered = self
107 .system_prompt
108 .as_deref()
109 .unwrap_or(default_prompt)
110 .trim_end()
111 .to_string();
112
113 for extra in &self.append_system_prompt {
114 let extra = extra.trim();
115 if extra.is_empty() {
116 continue;
117 }
118 if !rendered.is_empty() {
119 rendered.push_str("\n\n");
120 }
121 rendered.push_str(extra);
122 }
123
124 rendered
125 }
126
127 pub fn is_customized(&self) -> bool {
128 self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(default)]
134pub struct SafetyConfig {
135 pub mode: SafetyMode,
136 pub checkpoint_on_mutation: bool,
137 #[serde(default)]
138 pub overrides: Vec<PolicyOverride>,
139 #[serde(default)]
143 pub auto_classifier_model: Option<String>,
144 #[serde(default)]
150 pub allow_untrusted_headless_tools: bool,
151}
152
153impl Default for SafetyConfig {
154 fn default() -> Self {
155 Self {
156 mode: SafetyMode::Ask,
160 checkpoint_on_mutation: true,
161 overrides: Vec::new(),
162 auto_classifier_model: None,
163 allow_untrusted_headless_tools: false,
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(default)]
171pub struct MemoryConfig {
172 pub enabled: bool,
175 pub index_cap_bytes: usize,
177}
178
179impl Default for MemoryConfig {
180 fn default() -> Self {
181 Self {
182 enabled: true,
183 index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(default)]
191pub struct ComputerUseConfig {
192 pub auto_screenshot: bool,
198}
199
200impl Default for ComputerUseConfig {
201 fn default() -> Self {
202 Self {
203 auto_screenshot: true,
204 }
205 }
206}
207
208#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213pub struct UserProviderConfig {
214 #[serde(default)]
217 pub base_url: Option<String>,
218 #[serde(default)]
222 pub api_key_env: Option<String>,
223 #[serde(default)]
225 pub extra_headers: HashMap<String, String>,
226 #[serde(default)]
232 pub compat: Option<String>,
233 #[serde(default)]
237 pub default_model: Option<String>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct McpServerConfig {
243 pub command: String,
245 #[serde(default)]
247 pub args: Vec<String>,
248 #[serde(default)]
250 pub env: HashMap<String, String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(default)]
256pub struct ModelSettings {
257 pub provider: String,
259 pub name: String,
261 pub temperature: f32,
263 pub max_tokens: usize,
265 pub reasoning: ReasoningLevel,
269}
270
271impl Default for ModelSettings {
272 fn default() -> Self {
273 Self {
274 provider: String::new(),
275 name: String::new(),
276 temperature: DEFAULT_TEMPERATURE,
277 max_tokens: DEFAULT_MAX_TOKENS,
278 reasoning: ReasoningLevel::default(),
279 }
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(default)]
286pub struct OllamaConfig {
287 pub host: String,
289 pub port: u16,
291 pub num_gpu: Option<i32>,
294 pub num_thread: Option<i32>,
297 pub num_ctx: Option<i32>,
300 pub numa: Option<bool>,
302}
303
304impl Default for OllamaConfig {
305 fn default() -> Self {
306 Self {
307 host: String::from("localhost"),
308 port: DEFAULT_OLLAMA_PORT,
309 num_gpu: None, num_thread: None, num_ctx: None, numa: None, }
314 }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(default)]
320pub struct NonInteractiveConfig {
321 pub output_format: String,
323 pub max_tokens: usize,
325 pub no_execute: bool,
327}
328
329impl Default for NonInteractiveConfig {
330 fn default() -> Self {
331 Self {
332 output_format: String::from("text"),
333 max_tokens: DEFAULT_MAX_TOKENS,
334 no_execute: false,
335 }
336 }
337}
338
339pub fn load_config() -> Result<Config> {
342 let config_path = get_config_path()?;
343
344 if config_path.exists() {
345 let toml_str = std::fs::read_to_string(&config_path)
346 .with_context(|| format!("Failed to read {}", config_path.display()))?;
347 let config: Config = toml::from_str(&toml_str).with_context(|| {
348 format!(
349 "Failed to parse {}. Run 'mermaid init' to regenerate.",
350 config_path.display()
351 )
352 })?;
353 Ok(config)
354 } else {
355 Ok(Config::default())
356 }
357}
358
359pub fn load_config_or_warn() -> Config {
365 match load_config() {
366 Ok(config) => config,
367 Err(e) => {
368 eprintln!("mermaid: {e:#}");
369 Config::default()
370 },
371 }
372}
373
374pub fn get_config_path() -> Result<PathBuf> {
376 Ok(get_config_dir()?.join("config.toml"))
377}
378
379pub fn get_config_dir() -> Result<PathBuf> {
381 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
382 let config_dir = proj_dirs.config_dir();
383 std::fs::create_dir_all(config_dir)?;
384 Ok(config_dir.to_path_buf())
385 } else {
386 let home = std::env::var("HOME")
388 .or_else(|_| std::env::var("USERPROFILE"))
389 .context("Could not determine home directory")?;
390 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
391 std::fs::create_dir_all(&config_dir)?;
392 Ok(config_dir)
393 }
394}
395
396pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
398 let path = if let Some(p) = path {
399 p
400 } else {
401 get_config_dir()?.join("config.toml")
402 };
403
404 let toml_string = toml::to_string_pretty(config)?;
405 std::fs::write(&path, toml_string)
406 .with_context(|| format!("Failed to write config to {}", path.display()))?;
407
408 Ok(())
409}
410
411pub fn init_config() -> Result<()> {
413 let config_file = get_config_path()?;
414
415 if config_file.exists() {
416 println!("Configuration already exists at: {}", config_file.display());
417 } else {
418 let default_config = Config::default();
419 save_config(&default_config, Some(config_file.clone()))?;
420 println!("Created configuration at: {}", config_file.display());
421 }
422
423 Ok(())
424}
425
426pub fn persist_last_model(model: &str) -> Result<()> {
430 let mut config = load_config()?;
431 config.last_used_model = Some(model.to_string());
432 save_config(&config, None)
433}
434
435pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
439 let mut config = load_config()?;
440 config.default_model.reasoning = level;
441 save_config(&config, None)
442}
443
444pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
450 let mut config = load_config()?;
451 config
452 .reasoning_per_model
453 .insert(model_id.to_string(), level);
454 save_config(&config, None)
455}
456
457pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
459 if let Some(model) = cli_model {
460 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
461 return Ok(resolved);
462 }
463 return Ok(model.to_string());
464 }
465 if let Some(last_model) = &config.last_used_model {
466 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
467 return Ok(resolved);
468 }
469 return Ok(last_model.clone());
470 }
471 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
472 return Ok(format!(
473 "{}/{}",
474 config.default_model.provider, config.default_model.name
475 ));
476 }
477 let available = crate::ollama::require_any_model(config).await?;
478 let first = available
482 .first()
483 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
484 Ok(format!("ollama/{}", first))
485}
486
487fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
488 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
489 if let Some(model) = config.model_profiles.get(profile) {
490 anyhow::ensure!(
491 !model.trim().is_empty(),
492 "model profile `{}` is configured with an empty model id",
493 profile
494 );
495 return Ok(Some(model.clone()));
496 }
497 if requested.starts_with("profile:") {
498 anyhow::bail!(
499 "model profile `{}` is not configured; add it under [model_profiles]",
500 profile
501 );
502 }
503 Ok(None)
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
515 fn model_settings_deserializes_without_reasoning_field() {
516 let toml_blob = r#"
517 provider = "ollama"
518 name = "qwen3-coder:30b"
519 temperature = 0.7
520 max_tokens = 4096
521 "#;
522 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
523 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
524 assert_eq!(settings.provider, "ollama");
525 }
526
527 #[test]
528 fn model_settings_round_trips_reasoning_high() {
529 let original = ModelSettings {
530 provider: "anthropic".to_string(),
531 name: "claude-sonnet-4-6".to_string(),
532 temperature: 0.5,
533 max_tokens: 8192,
534 reasoning: ReasoningLevel::High,
535 };
536 let toml_blob = toml::to_string(&original).expect("serialize");
537 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
538 assert_eq!(back.reasoning, ReasoningLevel::High);
539 assert_eq!(back.name, "claude-sonnet-4-6");
540 }
541
542 #[test]
543 fn configured_model_profile_resolves_explicit_alias() {
544 let mut config = Config::default();
545 config
546 .model_profiles
547 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
548 assert_eq!(
549 resolve_model_profile_alias("fast", &config).unwrap(),
550 Some("ollama/qwen3-coder:14b".to_string())
551 );
552 assert_eq!(
553 resolve_model_profile_alias("profile:fast", &config).unwrap(),
554 Some("ollama/qwen3-coder:14b".to_string())
555 );
556 }
557
558 #[test]
559 fn profile_prefix_requires_configuration() {
560 let config = Config::default();
561 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
562 assert_eq!(
563 resolve_model_profile_alias("vision", &config).unwrap(),
564 None
565 );
566 }
567
568 #[test]
575 fn save_and_reload_preserves_reasoning_field() {
576 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
577 std::fs::create_dir_all(&dir).expect("create temp dir");
578 let path = dir.join("config.toml");
579
580 let mut cfg = Config::default();
581 cfg.default_model.provider = "ollama".to_string();
582 cfg.default_model.name = "qwen3-coder:30b".to_string();
583 cfg.default_model.reasoning = ReasoningLevel::Low;
584
585 save_config(&cfg, Some(path.clone())).expect("save");
586
587 let blob = std::fs::read_to_string(&path).expect("read");
588 let loaded: Config = toml::from_str(&blob).expect("parse back");
589 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
590
591 let _ = std::fs::remove_dir_all(&dir);
592 }
593
594 #[test]
599 fn save_and_reload_preserves_reasoning_per_model_table() {
600 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
601 std::fs::create_dir_all(&dir).expect("create temp dir");
602 let path = dir.join("config.toml");
603
604 let mut cfg = Config::default();
605 cfg.reasoning_per_model.insert(
606 "anthropic/claude-sonnet-4-6".to_string(),
607 ReasoningLevel::High,
608 );
609 cfg.reasoning_per_model
610 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
611
612 save_config(&cfg, Some(path.clone())).expect("save");
613
614 let blob = std::fs::read_to_string(&path).expect("read");
615 let loaded: Config = toml::from_str(&blob).expect("parse back");
616 assert_eq!(
617 loaded
618 .reasoning_per_model
619 .get("anthropic/claude-sonnet-4-6"),
620 Some(&ReasoningLevel::High)
621 );
622 assert_eq!(
623 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
624 Some(&ReasoningLevel::Low)
625 );
626
627 let _ = std::fs::remove_dir_all(&dir);
628 }
629
630 #[test]
634 fn config_deserializes_without_reasoning_per_model() {
635 let toml_blob = r#"
636 last_used_model = "ollama/qwen3-coder:30b"
637
638 [default_model]
639 provider = "ollama"
640 name = "qwen3-coder:30b"
641 temperature = 0.7
642 max_tokens = 4096
643 "#;
644 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
645 assert!(cfg.reasoning_per_model.is_empty());
646 assert!(!cfg.prompt.is_customized());
647 }
648
649 #[test]
650 fn config_defaults_computer_use_auto_screenshot_on() {
651 let cfg: Config = toml::from_str("").expect("empty config");
653 assert!(cfg.computer_use.auto_screenshot);
654 }
655
656 #[test]
657 fn prompt_config_replaces_and_appends_without_persisting() {
658 let mut cfg = Config::default();
659 cfg.prompt.system_prompt = Some("base".to_string());
660 cfg.prompt
661 .append_system_prompt
662 .push("extra instructions".to_string());
663
664 assert_eq!(
665 cfg.prompt.render_system_prompt("default"),
666 "base\n\nextra instructions"
667 );
668
669 let blob = toml::to_string(&cfg).expect("serialize");
670 assert!(!blob.contains("extra instructions"));
671 let loaded: Config = toml::from_str(&blob).expect("deserialize");
672 assert!(!loaded.prompt.is_customized());
673 }
674}