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)]
72 pub ollama_num_ctx_per_model: HashMap<String, u32>,
73
74 #[serde(default)]
86 pub model_profiles: HashMap<String, String>,
87
88 #[serde(default)]
92 pub safety: SafetyConfig,
93
94 #[serde(default)]
96 pub memory: MemoryConfig,
97
98 #[serde(default)]
100 pub compaction: CompactionConfig,
101
102 #[serde(default)]
104 pub computer_use: ComputerUseConfig,
105
106 #[serde(skip)]
110 pub prompt: PromptConfig,
111}
112
113#[derive(Debug, Clone, Default)]
114pub struct PromptConfig {
115 pub system_prompt: Option<String>,
116 pub append_system_prompt: Vec<String>,
117}
118
119impl PromptConfig {
120 pub fn render_system_prompt(&self, default_prompt: &str) -> String {
121 let mut rendered = self
122 .system_prompt
123 .as_deref()
124 .unwrap_or(default_prompt)
125 .trim_end()
126 .to_string();
127
128 for extra in &self.append_system_prompt {
129 let extra = extra.trim();
130 if extra.is_empty() {
131 continue;
132 }
133 if !rendered.is_empty() {
134 rendered.push_str("\n\n");
135 }
136 rendered.push_str(extra);
137 }
138
139 rendered
140 }
141
142 pub fn is_customized(&self) -> bool {
143 self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(default)]
149pub struct SafetyConfig {
150 pub mode: SafetyMode,
151 pub checkpoint_on_mutation: bool,
152 #[serde(default)]
153 pub overrides: Vec<PolicyOverride>,
154 #[serde(default)]
158 pub auto_classifier_model: Option<String>,
159 #[serde(default)]
165 pub allow_untrusted_headless_tools: bool,
166}
167
168impl Default for SafetyConfig {
169 fn default() -> Self {
170 Self {
171 mode: SafetyMode::Ask,
175 checkpoint_on_mutation: true,
176 overrides: Vec::new(),
177 auto_classifier_model: None,
178 allow_untrusted_headless_tools: false,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(default)]
186pub struct MemoryConfig {
187 pub enabled: bool,
190 pub index_cap_bytes: usize,
192}
193
194impl Default for MemoryConfig {
195 fn default() -> Self {
196 Self {
197 enabled: true,
198 index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
199 }
200 }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(default)]
206pub struct CompactionConfig {
207 pub max_truncation_recoveries: u8,
219}
220
221impl Default for CompactionConfig {
222 fn default() -> Self {
223 Self {
224 max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(default)]
232pub struct ComputerUseConfig {
233 pub auto_screenshot: bool,
239}
240
241impl Default for ComputerUseConfig {
242 fn default() -> Self {
243 Self {
244 auto_screenshot: true,
245 }
246 }
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct UserProviderConfig {
255 #[serde(default)]
258 pub base_url: Option<String>,
259 #[serde(default)]
263 pub api_key_env: Option<String>,
264 #[serde(default)]
266 pub extra_headers: HashMap<String, String>,
267 #[serde(default)]
273 pub compat: Option<String>,
274 #[serde(default)]
278 pub default_model: Option<String>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct McpServerConfig {
284 pub command: String,
286 #[serde(default)]
288 pub args: Vec<String>,
289 #[serde(default)]
291 pub env: HashMap<String, String>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(default)]
297pub struct ModelSettings {
298 pub provider: String,
300 pub name: String,
302 pub temperature: f32,
304 pub max_tokens: usize,
306 pub reasoning: ReasoningLevel,
310}
311
312impl Default for ModelSettings {
313 fn default() -> Self {
314 Self {
315 provider: String::new(),
316 name: String::new(),
317 temperature: DEFAULT_TEMPERATURE,
318 max_tokens: DEFAULT_MAX_TOKENS,
319 reasoning: ReasoningLevel::default(),
320 }
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326#[serde(default)]
327pub struct OllamaConfig {
328 pub host: String,
330 pub port: u16,
332 pub num_gpu: Option<i32>,
335 pub num_thread: Option<i32>,
338 pub num_ctx: Option<i32>,
341 pub numa: Option<bool>,
343 pub allow_ram_offload: bool,
349 pub max_auto_num_ctx: Option<usize>,
353}
354
355impl Default for OllamaConfig {
356 fn default() -> Self {
357 Self {
358 host: String::from("localhost"),
359 port: DEFAULT_OLLAMA_PORT,
360 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, }
367 }
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(default)]
373pub struct NonInteractiveConfig {
374 pub output_format: String,
376 pub max_tokens: usize,
378 pub no_execute: bool,
380}
381
382impl Default for NonInteractiveConfig {
383 fn default() -> Self {
384 Self {
385 output_format: String::from("text"),
386 max_tokens: DEFAULT_MAX_TOKENS,
387 no_execute: false,
388 }
389 }
390}
391
392pub fn load_config() -> Result<Config> {
395 let config_path = get_config_path()?;
396
397 if config_path.exists() {
398 let toml_str = std::fs::read_to_string(&config_path)
399 .with_context(|| format!("Failed to read {}", config_path.display()))?;
400 let config: Config = toml::from_str(&toml_str).with_context(|| {
401 format!(
402 "Failed to parse {}. Run 'mermaid init' to regenerate.",
403 config_path.display()
404 )
405 })?;
406 Ok(config)
407 } else {
408 Ok(Config::default())
409 }
410}
411
412pub fn load_config_or_warn() -> Config {
418 match load_config() {
419 Ok(config) => config,
420 Err(e) => {
421 eprintln!("mermaid: {e:#}");
422 Config::default()
423 },
424 }
425}
426
427pub fn get_config_path() -> Result<PathBuf> {
429 Ok(get_config_dir()?.join("config.toml"))
430}
431
432pub fn get_config_dir() -> Result<PathBuf> {
434 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
435 let config_dir = proj_dirs.config_dir();
436 std::fs::create_dir_all(config_dir)?;
437 Ok(config_dir.to_path_buf())
438 } else {
439 let home = std::env::var("HOME")
441 .or_else(|_| std::env::var("USERPROFILE"))
442 .context("Could not determine home directory")?;
443 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
444 std::fs::create_dir_all(&config_dir)?;
445 Ok(config_dir)
446 }
447}
448
449pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
451 let path = if let Some(p) = path {
452 p
453 } else {
454 get_config_dir()?.join("config.toml")
455 };
456
457 let toml_string = toml::to_string_pretty(config)?;
458 std::fs::write(&path, toml_string)
459 .with_context(|| format!("Failed to write config to {}", path.display()))?;
460
461 Ok(())
462}
463
464pub fn init_config() -> Result<()> {
466 let config_file = get_config_path()?;
467
468 if config_file.exists() {
469 println!("Configuration already exists at: {}", config_file.display());
470 } else {
471 let default_config = Config::default();
472 save_config(&default_config, Some(config_file.clone()))?;
473 println!("Created configuration at: {}", config_file.display());
474 }
475
476 Ok(())
477}
478
479pub fn persist_last_model(model: &str) -> Result<()> {
483 let mut config = load_config()?;
484 config.last_used_model = Some(model.to_string());
485 save_config(&config, None)
486}
487
488pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
492 let mut config = load_config()?;
493 config.default_model.reasoning = level;
494 save_config(&config, None)
495}
496
497pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
503 let mut config = load_config()?;
504 config
505 .reasoning_per_model
506 .insert(model_id.to_string(), level);
507 save_config(&config, None)
508}
509
510pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
513 let mut config = load_config()?;
514 match num_ctx {
515 Some(n) => {
516 config
517 .ollama_num_ctx_per_model
518 .insert(model_id.to_string(), n);
519 },
520 None => {
521 config.ollama_num_ctx_per_model.remove(model_id);
522 },
523 }
524 save_config(&config, None)
525}
526
527pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
529 let mut config = load_config()?;
530 config.ollama.allow_ram_offload = enabled;
531 save_config(&config, None)
532}
533
534pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
536 if let Some(model) = cli_model {
537 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
538 return Ok(resolved);
539 }
540 return Ok(model.to_string());
541 }
542 if let Some(last_model) = &config.last_used_model {
543 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
544 return Ok(resolved);
545 }
546 return Ok(last_model.clone());
547 }
548 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
549 return Ok(format!(
550 "{}/{}",
551 config.default_model.provider, config.default_model.name
552 ));
553 }
554 let available = crate::ollama::require_any_model(config).await?;
555 let first = available
559 .first()
560 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
561 Ok(format!("ollama/{}", first))
562}
563
564fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
565 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
566 if let Some(model) = config.model_profiles.get(profile) {
567 anyhow::ensure!(
568 !model.trim().is_empty(),
569 "model profile `{}` is configured with an empty model id",
570 profile
571 );
572 return Ok(Some(model.clone()));
573 }
574 if requested.starts_with("profile:") {
575 anyhow::bail!(
576 "model profile `{}` is not configured; add it under [model_profiles]",
577 profile
578 );
579 }
580 Ok(None)
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
592 fn model_settings_deserializes_without_reasoning_field() {
593 let toml_blob = r#"
594 provider = "ollama"
595 name = "qwen3-coder:30b"
596 temperature = 0.7
597 max_tokens = 4096
598 "#;
599 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
600 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
601 assert_eq!(settings.provider, "ollama");
602 }
603
604 #[test]
605 fn model_settings_round_trips_reasoning_high() {
606 let original = ModelSettings {
607 provider: "anthropic".to_string(),
608 name: "claude-sonnet-4-6".to_string(),
609 temperature: 0.5,
610 max_tokens: 8192,
611 reasoning: ReasoningLevel::High,
612 };
613 let toml_blob = toml::to_string(&original).expect("serialize");
614 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
615 assert_eq!(back.reasoning, ReasoningLevel::High);
616 assert_eq!(back.name, "claude-sonnet-4-6");
617 }
618
619 #[test]
620 fn configured_model_profile_resolves_explicit_alias() {
621 let mut config = Config::default();
622 config
623 .model_profiles
624 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
625 assert_eq!(
626 resolve_model_profile_alias("fast", &config).unwrap(),
627 Some("ollama/qwen3-coder:14b".to_string())
628 );
629 assert_eq!(
630 resolve_model_profile_alias("profile:fast", &config).unwrap(),
631 Some("ollama/qwen3-coder:14b".to_string())
632 );
633 }
634
635 #[test]
636 fn profile_prefix_requires_configuration() {
637 let config = Config::default();
638 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
639 assert_eq!(
640 resolve_model_profile_alias("vision", &config).unwrap(),
641 None
642 );
643 }
644
645 #[test]
652 fn save_and_reload_preserves_reasoning_field() {
653 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
654 std::fs::create_dir_all(&dir).expect("create temp dir");
655 let path = dir.join("config.toml");
656
657 let mut cfg = Config::default();
658 cfg.default_model.provider = "ollama".to_string();
659 cfg.default_model.name = "qwen3-coder:30b".to_string();
660 cfg.default_model.reasoning = ReasoningLevel::Low;
661
662 save_config(&cfg, Some(path.clone())).expect("save");
663
664 let blob = std::fs::read_to_string(&path).expect("read");
665 let loaded: Config = toml::from_str(&blob).expect("parse back");
666 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
667
668 let _ = std::fs::remove_dir_all(&dir);
669 }
670
671 #[test]
676 fn save_and_reload_preserves_reasoning_per_model_table() {
677 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
678 std::fs::create_dir_all(&dir).expect("create temp dir");
679 let path = dir.join("config.toml");
680
681 let mut cfg = Config::default();
682 cfg.reasoning_per_model.insert(
683 "anthropic/claude-sonnet-4-6".to_string(),
684 ReasoningLevel::High,
685 );
686 cfg.reasoning_per_model
687 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
688
689 save_config(&cfg, Some(path.clone())).expect("save");
690
691 let blob = std::fs::read_to_string(&path).expect("read");
692 let loaded: Config = toml::from_str(&blob).expect("parse back");
693 assert_eq!(
694 loaded
695 .reasoning_per_model
696 .get("anthropic/claude-sonnet-4-6"),
697 Some(&ReasoningLevel::High)
698 );
699 assert_eq!(
700 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
701 Some(&ReasoningLevel::Low)
702 );
703
704 let _ = std::fs::remove_dir_all(&dir);
705 }
706
707 #[test]
710 fn save_and_reload_preserves_ollama_context_overrides() {
711 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
712 std::fs::create_dir_all(&dir).expect("create temp dir");
713 let path = dir.join("config.toml");
714
715 let mut cfg = Config::default();
716 cfg.ollama_num_ctx_per_model
717 .insert("ollama/ornith:9b".to_string(), 131_072);
718 cfg.ollama.allow_ram_offload = true;
719 cfg.ollama.max_auto_num_ctx = Some(65_536);
720
721 save_config(&cfg, Some(path.clone())).expect("save");
722 let blob = std::fs::read_to_string(&path).expect("read");
723 let loaded: Config = toml::from_str(&blob).expect("parse back");
724
725 assert_eq!(
726 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
727 Some(&131_072)
728 );
729 assert!(loaded.ollama.allow_ram_offload);
730 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
731
732 let _ = std::fs::remove_dir_all(&dir);
733 }
734
735 #[test]
738 fn config_deserializes_without_ollama_context_keys() {
739 let toml_blob = r#"
740[ollama]
741host = "localhost"
742port = 11434
743"#;
744 let cfg: Config = toml::from_str(toml_blob).expect("parse");
745 assert!(cfg.ollama_num_ctx_per_model.is_empty());
746 assert!(!cfg.ollama.allow_ram_offload);
747 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
748 }
749
750 #[test]
754 fn config_deserializes_without_reasoning_per_model() {
755 let toml_blob = r#"
756 last_used_model = "ollama/qwen3-coder:30b"
757
758 [default_model]
759 provider = "ollama"
760 name = "qwen3-coder:30b"
761 temperature = 0.7
762 max_tokens = 4096
763 "#;
764 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
765 assert!(cfg.reasoning_per_model.is_empty());
766 assert!(!cfg.prompt.is_customized());
767 }
768
769 #[test]
770 fn config_defaults_computer_use_auto_screenshot_on() {
771 let cfg: Config = toml::from_str("").expect("empty config");
773 assert!(cfg.computer_use.auto_screenshot);
774 }
775
776 #[test]
777 fn prompt_config_replaces_and_appends_without_persisting() {
778 let mut cfg = Config::default();
779 cfg.prompt.system_prompt = Some("base".to_string());
780 cfg.prompt
781 .append_system_prompt
782 .push("extra instructions".to_string());
783
784 assert_eq!(
785 cfg.prompt.render_system_prompt("default"),
786 "base\n\nextra instructions"
787 );
788
789 let blob = toml::to_string(&cfg).expect("serialize");
790 assert!(!blob.contains("extra instructions"));
791 let loaded: Config = toml::from_str(&blob).expect("deserialize");
792 assert!(!loaded.prompt.is_customized());
793 }
794}