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(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(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
294fn debug_masked_map(
298 map: &HashMap<String, String>,
299) -> std::collections::BTreeMap<&str, &'static str> {
300 map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
301}
302
303impl std::fmt::Debug for McpServerConfig {
308 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309 f.debug_struct("McpServerConfig")
310 .field("command", &self.command)
311 .field(
313 "args",
314 &self
315 .args
316 .iter()
317 .map(|a| crate::utils::redact_secrets(a))
318 .collect::<Vec<_>>(),
319 )
320 .field("env", &debug_masked_map(&self.env))
321 .finish()
322 }
323}
324
325impl std::fmt::Debug for UserProviderConfig {
326 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327 f.debug_struct("UserProviderConfig")
328 .field("base_url", &self.base_url)
329 .field("api_key_env", &self.api_key_env)
330 .field("extra_headers", &debug_masked_map(&self.extra_headers))
331 .field("compat", &self.compat)
332 .field("default_model", &self.default_model)
333 .finish()
334 }
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(default)]
340pub struct ModelSettings {
341 pub provider: String,
343 pub name: String,
345 pub temperature: f32,
347 pub max_tokens: usize,
349 pub reasoning: ReasoningLevel,
353}
354
355impl Default for ModelSettings {
356 fn default() -> Self {
357 Self {
358 provider: String::new(),
359 name: String::new(),
360 temperature: DEFAULT_TEMPERATURE,
361 max_tokens: DEFAULT_MAX_TOKENS,
362 reasoning: ReasoningLevel::default(),
363 }
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369#[serde(default)]
370pub struct OllamaConfig {
371 pub host: String,
373 pub port: u16,
375 pub num_gpu: Option<i32>,
378 pub num_thread: Option<i32>,
381 pub num_ctx: Option<i32>,
384 pub numa: Option<bool>,
386 pub allow_ram_offload: bool,
392 pub max_auto_num_ctx: Option<usize>,
396}
397
398impl Default for OllamaConfig {
399 fn default() -> Self {
400 Self {
401 host: String::from("localhost"),
402 port: DEFAULT_OLLAMA_PORT,
403 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, }
410 }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize)]
415#[serde(default)]
416pub struct NonInteractiveConfig {
417 pub output_format: String,
419 pub max_tokens: usize,
421 pub no_execute: bool,
423}
424
425impl Default for NonInteractiveConfig {
426 fn default() -> Self {
427 Self {
428 output_format: String::from("text"),
429 max_tokens: DEFAULT_MAX_TOKENS,
430 no_execute: false,
431 }
432 }
433}
434
435pub fn load_config() -> Result<Config> {
438 let config_path = get_config_path()?;
439
440 if config_path.exists() {
441 let toml_str = std::fs::read_to_string(&config_path)
442 .with_context(|| format!("Failed to read {}", config_path.display()))?;
443 let config: Config = toml::from_str(&toml_str).with_context(|| {
444 format!(
445 "Failed to parse {}. Run 'mermaid init' to regenerate.",
446 config_path.display()
447 )
448 })?;
449 Ok(config)
450 } else {
451 Ok(Config::default())
452 }
453}
454
455pub fn load_config_or_warn() -> Config {
461 match load_config() {
462 Ok(config) => config,
463 Err(e) => {
464 eprintln!(
468 "mermaid: {}",
469 crate::utils::redact_secrets(&format!("{e:#}"))
470 );
471 Config::default()
472 },
473 }
474}
475
476pub fn get_config_path() -> Result<PathBuf> {
478 Ok(get_config_dir()?.join("config.toml"))
479}
480
481pub fn get_config_dir() -> Result<PathBuf> {
483 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
484 let config_dir = proj_dirs.config_dir();
485 std::fs::create_dir_all(config_dir)?;
486 Ok(config_dir.to_path_buf())
487 } else {
488 let home = std::env::var("HOME")
490 .or_else(|_| std::env::var("USERPROFILE"))
491 .context("Could not determine home directory")?;
492 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
493 std::fs::create_dir_all(&config_dir)?;
494 Ok(config_dir)
495 }
496}
497
498pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
500 let path = if let Some(p) = path {
501 p
502 } else {
503 get_config_dir()?.join("config.toml")
504 };
505
506 let toml_string = toml::to_string_pretty(config)?;
507
508 #[cfg(unix)]
515 {
516 use std::io::Write;
517 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
518 let mut file = std::fs::OpenOptions::new()
519 .write(true)
520 .create(true)
521 .truncate(true)
522 .mode(0o600)
523 .open(&path)
524 .with_context(|| format!("Failed to write config to {}", path.display()))?;
525 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
527 file.write_all(toml_string.as_bytes())
528 .with_context(|| format!("Failed to write config to {}", path.display()))?;
529 }
530 #[cfg(not(unix))]
531 {
532 std::fs::write(&path, toml_string)
533 .with_context(|| format!("Failed to write config to {}", path.display()))?;
534 }
535
536 Ok(())
537}
538
539pub fn init_config() -> Result<()> {
541 let config_file = get_config_path()?;
542
543 if config_file.exists() {
544 println!("Configuration already exists at: {}", config_file.display());
545 } else {
546 let default_config = Config::default();
547 save_config(&default_config, Some(config_file.clone()))?;
548 println!("Created configuration at: {}", config_file.display());
549 }
550
551 Ok(())
552}
553
554static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
560
561fn update_config(mutate: impl FnOnce(&mut Config)) -> Result<()> {
566 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
567 let mut config = load_config()?;
568 mutate(&mut config);
569 save_config(&config, None)
570}
571
572pub fn persist_last_model(model: &str) -> Result<()> {
574 update_config(|config| config.last_used_model = Some(model.to_string()))
575}
576
577pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
581 update_config(|config| config.default_model.reasoning = level)
582}
583
584pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
590 update_config(|config| {
591 config
592 .reasoning_per_model
593 .insert(model_id.to_string(), level);
594 })
595}
596
597pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
600 update_config(|config| match num_ctx {
601 Some(n) => {
602 config
603 .ollama_num_ctx_per_model
604 .insert(model_id.to_string(), n);
605 },
606 None => {
607 config.ollama_num_ctx_per_model.remove(model_id);
608 },
609 })
610}
611
612pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
614 update_config(|config| config.ollama.allow_ram_offload = enabled)
615}
616
617pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
619 if let Some(model) = cli_model {
620 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
621 return Ok(resolved);
622 }
623 return Ok(model.to_string());
624 }
625 if let Some(last_model) = &config.last_used_model {
626 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
627 return Ok(resolved);
628 }
629 return Ok(last_model.clone());
630 }
631 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
632 return Ok(format!(
633 "{}/{}",
634 config.default_model.provider, config.default_model.name
635 ));
636 }
637 let available = crate::ollama::require_any_model(config).await?;
638 let first = available
642 .first()
643 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
644 Ok(format!("ollama/{}", first))
645}
646
647fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
648 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
649 if let Some(model) = config.model_profiles.get(profile) {
650 anyhow::ensure!(
651 !model.trim().is_empty(),
652 "model profile `{}` is configured with an empty model id",
653 profile
654 );
655 return Ok(Some(model.clone()));
656 }
657 if requested.starts_with("profile:") {
658 anyhow::bail!(
659 "model profile `{}` is not configured; add it under [model_profiles]",
660 profile
661 );
662 }
663 Ok(None)
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 #[test]
675 fn model_settings_deserializes_without_reasoning_field() {
676 let toml_blob = r#"
677 provider = "ollama"
678 name = "qwen3-coder:30b"
679 temperature = 0.7
680 max_tokens = 4096
681 "#;
682 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
683 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
684 assert_eq!(settings.provider, "ollama");
685 }
686
687 #[test]
688 fn model_settings_round_trips_reasoning_high() {
689 let original = ModelSettings {
690 provider: "anthropic".to_string(),
691 name: "claude-sonnet-4-6".to_string(),
692 temperature: 0.5,
693 max_tokens: 8192,
694 reasoning: ReasoningLevel::High,
695 };
696 let toml_blob = toml::to_string(&original).expect("serialize");
697 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
698 assert_eq!(back.reasoning, ReasoningLevel::High);
699 assert_eq!(back.name, "claude-sonnet-4-6");
700 }
701
702 #[test]
703 fn configured_model_profile_resolves_explicit_alias() {
704 let mut config = Config::default();
705 config
706 .model_profiles
707 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
708 assert_eq!(
709 resolve_model_profile_alias("fast", &config).unwrap(),
710 Some("ollama/qwen3-coder:14b".to_string())
711 );
712 assert_eq!(
713 resolve_model_profile_alias("profile:fast", &config).unwrap(),
714 Some("ollama/qwen3-coder:14b".to_string())
715 );
716 }
717
718 #[test]
719 fn profile_prefix_requires_configuration() {
720 let config = Config::default();
721 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
722 assert_eq!(
723 resolve_model_profile_alias("vision", &config).unwrap(),
724 None
725 );
726 }
727
728 #[test]
735 fn save_and_reload_preserves_reasoning_field() {
736 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
737 std::fs::create_dir_all(&dir).expect("create temp dir");
738 let path = dir.join("config.toml");
739
740 let mut cfg = Config::default();
741 cfg.default_model.provider = "ollama".to_string();
742 cfg.default_model.name = "qwen3-coder:30b".to_string();
743 cfg.default_model.reasoning = ReasoningLevel::Low;
744
745 save_config(&cfg, Some(path.clone())).expect("save");
746
747 let blob = std::fs::read_to_string(&path).expect("read");
748 let loaded: Config = toml::from_str(&blob).expect("parse back");
749 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
750
751 let _ = std::fs::remove_dir_all(&dir);
752 }
753
754 #[test]
759 fn save_and_reload_preserves_reasoning_per_model_table() {
760 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
761 std::fs::create_dir_all(&dir).expect("create temp dir");
762 let path = dir.join("config.toml");
763
764 let mut cfg = Config::default();
765 cfg.reasoning_per_model.insert(
766 "anthropic/claude-sonnet-4-6".to_string(),
767 ReasoningLevel::High,
768 );
769 cfg.reasoning_per_model
770 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
771
772 save_config(&cfg, Some(path.clone())).expect("save");
773
774 let blob = std::fs::read_to_string(&path).expect("read");
775 let loaded: Config = toml::from_str(&blob).expect("parse back");
776 assert_eq!(
777 loaded
778 .reasoning_per_model
779 .get("anthropic/claude-sonnet-4-6"),
780 Some(&ReasoningLevel::High)
781 );
782 assert_eq!(
783 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
784 Some(&ReasoningLevel::Low)
785 );
786
787 let _ = std::fs::remove_dir_all(&dir);
788 }
789
790 #[test]
793 fn save_and_reload_preserves_ollama_context_overrides() {
794 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
795 std::fs::create_dir_all(&dir).expect("create temp dir");
796 let path = dir.join("config.toml");
797
798 let mut cfg = Config::default();
799 cfg.ollama_num_ctx_per_model
800 .insert("ollama/ornith:9b".to_string(), 131_072);
801 cfg.ollama.allow_ram_offload = true;
802 cfg.ollama.max_auto_num_ctx = Some(65_536);
803
804 save_config(&cfg, Some(path.clone())).expect("save");
805 let blob = std::fs::read_to_string(&path).expect("read");
806 let loaded: Config = toml::from_str(&blob).expect("parse back");
807
808 assert_eq!(
809 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
810 Some(&131_072)
811 );
812 assert!(loaded.ollama.allow_ram_offload);
813 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
814
815 let _ = std::fs::remove_dir_all(&dir);
816 }
817
818 #[test]
821 fn config_deserializes_without_ollama_context_keys() {
822 let toml_blob = r#"
823[ollama]
824host = "localhost"
825port = 11434
826"#;
827 let cfg: Config = toml::from_str(toml_blob).expect("parse");
828 assert!(cfg.ollama_num_ctx_per_model.is_empty());
829 assert!(!cfg.ollama.allow_ram_offload);
830 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
831 }
832
833 #[test]
837 fn config_deserializes_without_reasoning_per_model() {
838 let toml_blob = r#"
839 last_used_model = "ollama/qwen3-coder:30b"
840
841 [default_model]
842 provider = "ollama"
843 name = "qwen3-coder:30b"
844 temperature = 0.7
845 max_tokens = 4096
846 "#;
847 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
848 assert!(cfg.reasoning_per_model.is_empty());
849 assert!(!cfg.prompt.is_customized());
850 }
851
852 #[cfg(unix)]
856 #[test]
857 fn save_config_writes_owner_only_perms() {
858 use std::os::unix::fs::PermissionsExt;
859 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
860 std::fs::create_dir_all(&dir).expect("create temp dir");
861 let path = dir.join("config.toml");
862 std::fs::write(&path, "stale").expect("seed");
864 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
865
866 save_config(&Config::default(), Some(path.clone())).expect("save");
867 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
868 assert_eq!(mode, 0o600, "config must be written owner-only");
869
870 let _ = std::fs::remove_dir_all(&dir);
871 }
872
873 #[test]
874 fn config_defaults_computer_use_auto_screenshot_on() {
875 let cfg: Config = toml::from_str("").expect("empty config");
877 assert!(cfg.computer_use.auto_screenshot);
878 }
879
880 #[test]
881 fn prompt_config_replaces_and_appends_without_persisting() {
882 let mut cfg = Config::default();
883 cfg.prompt.system_prompt = Some("base".to_string());
884 cfg.prompt
885 .append_system_prompt
886 .push("extra instructions".to_string());
887
888 assert_eq!(
889 cfg.prompt.render_system_prompt("default"),
890 "base\n\nextra instructions"
891 );
892
893 let blob = toml::to_string(&cfg).expect("serialize");
894 assert!(!blob.contains("extra instructions"));
895 let loaded: Config = toml::from_str(&blob).expect("deserialize");
896 assert!(!loaded.prompt.is_customized());
897 }
898}