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(skip)]
91 pub prompt: PromptConfig,
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct PromptConfig {
96 pub system_prompt: Option<String>,
97 pub append_system_prompt: Vec<String>,
98}
99
100impl PromptConfig {
101 pub fn render_system_prompt(&self, default_prompt: &str) -> String {
102 let mut rendered = self
103 .system_prompt
104 .as_deref()
105 .unwrap_or(default_prompt)
106 .trim_end()
107 .to_string();
108
109 for extra in &self.append_system_prompt {
110 let extra = extra.trim();
111 if extra.is_empty() {
112 continue;
113 }
114 if !rendered.is_empty() {
115 rendered.push_str("\n\n");
116 }
117 rendered.push_str(extra);
118 }
119
120 rendered
121 }
122
123 pub fn is_customized(&self) -> bool {
124 self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129#[serde(default)]
130pub struct SafetyConfig {
131 pub mode: SafetyMode,
132 pub checkpoint_on_mutation: bool,
133 #[serde(default)]
134 pub overrides: Vec<PolicyOverride>,
135 #[serde(default)]
139 pub auto_classifier_model: Option<String>,
140}
141
142impl Default for SafetyConfig {
143 fn default() -> Self {
144 Self {
145 mode: SafetyMode::Ask,
149 checkpoint_on_mutation: true,
150 overrides: Vec::new(),
151 auto_classifier_model: None,
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(default)]
159pub struct MemoryConfig {
160 pub enabled: bool,
163 pub index_cap_bytes: usize,
165}
166
167impl Default for MemoryConfig {
168 fn default() -> Self {
169 Self {
170 enabled: true,
171 index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
172 }
173 }
174}
175
176#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct UserProviderConfig {
182 #[serde(default)]
185 pub base_url: Option<String>,
186 #[serde(default)]
190 pub api_key_env: Option<String>,
191 #[serde(default)]
193 pub extra_headers: HashMap<String, String>,
194 #[serde(default)]
200 pub compat: Option<String>,
201 #[serde(default)]
205 pub default_model: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct McpServerConfig {
211 pub command: String,
213 #[serde(default)]
215 pub args: Vec<String>,
216 #[serde(default)]
218 pub env: HashMap<String, String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223#[serde(default)]
224pub struct ModelSettings {
225 pub provider: String,
227 pub name: String,
229 pub temperature: f32,
231 pub max_tokens: usize,
233 pub reasoning: ReasoningLevel,
237}
238
239impl Default for ModelSettings {
240 fn default() -> Self {
241 Self {
242 provider: String::new(),
243 name: String::new(),
244 temperature: DEFAULT_TEMPERATURE,
245 max_tokens: DEFAULT_MAX_TOKENS,
246 reasoning: ReasoningLevel::default(),
247 }
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(default)]
254pub struct OllamaConfig {
255 pub host: String,
257 pub port: u16,
259 pub cloud_api_key: Option<String>,
263 pub num_gpu: Option<i32>,
266 pub num_thread: Option<i32>,
269 pub num_ctx: Option<i32>,
272 pub numa: Option<bool>,
274}
275
276impl Default for OllamaConfig {
277 fn default() -> Self {
278 Self {
279 host: String::from("localhost"),
280 port: DEFAULT_OLLAMA_PORT,
281 cloud_api_key: None,
282 num_gpu: None, num_thread: None, num_ctx: None, numa: None, }
287 }
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292#[serde(default)]
293pub struct NonInteractiveConfig {
294 pub output_format: String,
296 pub max_tokens: usize,
298 pub no_execute: bool,
300}
301
302impl Default for NonInteractiveConfig {
303 fn default() -> Self {
304 Self {
305 output_format: String::from("text"),
306 max_tokens: DEFAULT_MAX_TOKENS,
307 no_execute: false,
308 }
309 }
310}
311
312pub fn load_config() -> Result<Config> {
315 let config_path = get_config_path()?;
316
317 if config_path.exists() {
318 let toml_str = std::fs::read_to_string(&config_path)
319 .with_context(|| format!("Failed to read {}", config_path.display()))?;
320 let config: Config = toml::from_str(&toml_str).with_context(|| {
321 format!(
322 "Failed to parse {}. Run 'mermaid init' to regenerate.",
323 config_path.display()
324 )
325 })?;
326 Ok(config)
327 } else {
328 Ok(Config::default())
329 }
330}
331
332pub fn get_config_path() -> Result<PathBuf> {
334 Ok(get_config_dir()?.join("config.toml"))
335}
336
337pub fn get_config_dir() -> Result<PathBuf> {
339 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
340 let config_dir = proj_dirs.config_dir();
341 std::fs::create_dir_all(config_dir)?;
342 Ok(config_dir.to_path_buf())
343 } else {
344 let home = std::env::var("HOME")
346 .or_else(|_| std::env::var("USERPROFILE"))
347 .context("Could not determine home directory")?;
348 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
349 std::fs::create_dir_all(&config_dir)?;
350 Ok(config_dir)
351 }
352}
353
354pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
356 let path = if let Some(p) = path {
357 p
358 } else {
359 get_config_dir()?.join("config.toml")
360 };
361
362 let toml_string = toml::to_string_pretty(config)?;
363 std::fs::write(&path, toml_string)
364 .with_context(|| format!("Failed to write config to {}", path.display()))?;
365
366 Ok(())
367}
368
369pub fn init_config() -> Result<()> {
371 let config_file = get_config_path()?;
372
373 if config_file.exists() {
374 println!("Configuration already exists at: {}", config_file.display());
375 } else {
376 let default_config = Config::default();
377 save_config(&default_config, Some(config_file.clone()))?;
378 println!("Created configuration at: {}", config_file.display());
379 }
380
381 Ok(())
382}
383
384pub fn persist_last_model(model: &str) -> Result<()> {
386 let mut config = load_config().unwrap_or_default();
387 config.last_used_model = Some(model.to_string());
388 save_config(&config, None)
389}
390
391pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
395 let mut config = load_config().unwrap_or_default();
396 config.default_model.reasoning = level;
397 save_config(&config, None)
398}
399
400pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
406 let mut config = load_config().unwrap_or_default();
407 config
408 .reasoning_per_model
409 .insert(model_id.to_string(), level);
410 save_config(&config, None)
411}
412
413pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
415 if let Some(model) = cli_model {
416 if let Some(resolved) = resolve_model_profile_alias(model, config)? {
417 return Ok(resolved);
418 }
419 return Ok(model.to_string());
420 }
421 if let Some(last_model) = &config.last_used_model {
422 if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
423 return Ok(resolved);
424 }
425 return Ok(last_model.clone());
426 }
427 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
428 return Ok(format!(
429 "{}/{}",
430 config.default_model.provider, config.default_model.name
431 ));
432 }
433 let available = crate::ollama::require_any_model(config).await?;
434 let first = available
438 .first()
439 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
440 Ok(format!("ollama/{}", first))
441}
442
443fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
444 let profile = requested.strip_prefix("profile:").unwrap_or(requested);
445 if let Some(model) = config.model_profiles.get(profile) {
446 anyhow::ensure!(
447 !model.trim().is_empty(),
448 "model profile `{}` is configured with an empty model id",
449 profile
450 );
451 return Ok(Some(model.clone()));
452 }
453 if requested.starts_with("profile:") {
454 anyhow::bail!(
455 "model profile `{}` is not configured; add it under [model_profiles]",
456 profile
457 );
458 }
459 Ok(None)
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
471 fn model_settings_deserializes_without_reasoning_field() {
472 let toml_blob = r#"
473 provider = "ollama"
474 name = "qwen3-coder:30b"
475 temperature = 0.7
476 max_tokens = 4096
477 "#;
478 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
479 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
480 assert_eq!(settings.provider, "ollama");
481 }
482
483 #[test]
484 fn model_settings_round_trips_reasoning_high() {
485 let original = ModelSettings {
486 provider: "anthropic".to_string(),
487 name: "claude-sonnet-4-6".to_string(),
488 temperature: 0.5,
489 max_tokens: 8192,
490 reasoning: ReasoningLevel::High,
491 };
492 let toml_blob = toml::to_string(&original).expect("serialize");
493 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
494 assert_eq!(back.reasoning, ReasoningLevel::High);
495 assert_eq!(back.name, "claude-sonnet-4-6");
496 }
497
498 #[test]
499 fn configured_model_profile_resolves_explicit_alias() {
500 let mut config = Config::default();
501 config
502 .model_profiles
503 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
504 assert_eq!(
505 resolve_model_profile_alias("fast", &config).unwrap(),
506 Some("ollama/qwen3-coder:14b".to_string())
507 );
508 assert_eq!(
509 resolve_model_profile_alias("profile:fast", &config).unwrap(),
510 Some("ollama/qwen3-coder:14b".to_string())
511 );
512 }
513
514 #[test]
515 fn profile_prefix_requires_configuration() {
516 let config = Config::default();
517 assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
518 assert_eq!(
519 resolve_model_profile_alias("vision", &config).unwrap(),
520 None
521 );
522 }
523
524 #[test]
531 fn save_and_reload_preserves_reasoning_field() {
532 let dir = std::env::temp_dir().join("mermaid_test_config_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.default_model.provider = "ollama".to_string();
538 cfg.default_model.name = "qwen3-coder:30b".to_string();
539 cfg.default_model.reasoning = ReasoningLevel::Low;
540
541 save_config(&cfg, Some(path.clone())).expect("save");
542
543 let blob = std::fs::read_to_string(&path).expect("read");
544 let loaded: Config = toml::from_str(&blob).expect("parse back");
545 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
546
547 let _ = std::fs::remove_dir_all(&dir);
548 }
549
550 #[test]
555 fn save_and_reload_preserves_reasoning_per_model_table() {
556 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
557 std::fs::create_dir_all(&dir).expect("create temp dir");
558 let path = dir.join("config.toml");
559
560 let mut cfg = Config::default();
561 cfg.reasoning_per_model.insert(
562 "anthropic/claude-sonnet-4-6".to_string(),
563 ReasoningLevel::High,
564 );
565 cfg.reasoning_per_model
566 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
567
568 save_config(&cfg, Some(path.clone())).expect("save");
569
570 let blob = std::fs::read_to_string(&path).expect("read");
571 let loaded: Config = toml::from_str(&blob).expect("parse back");
572 assert_eq!(
573 loaded
574 .reasoning_per_model
575 .get("anthropic/claude-sonnet-4-6"),
576 Some(&ReasoningLevel::High)
577 );
578 assert_eq!(
579 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
580 Some(&ReasoningLevel::Low)
581 );
582
583 let _ = std::fs::remove_dir_all(&dir);
584 }
585
586 #[test]
590 fn config_deserializes_without_reasoning_per_model() {
591 let toml_blob = r#"
592 last_used_model = "ollama/qwen3-coder:30b"
593
594 [default_model]
595 provider = "ollama"
596 name = "qwen3-coder:30b"
597 temperature = 0.7
598 max_tokens = 4096
599 "#;
600 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
601 assert!(cfg.reasoning_per_model.is_empty());
602 assert!(!cfg.prompt.is_customized());
603 }
604
605 #[test]
606 fn prompt_config_replaces_and_appends_without_persisting() {
607 let mut cfg = Config::default();
608 cfg.prompt.system_prompt = Some("base".to_string());
609 cfg.prompt
610 .append_system_prompt
611 .push("extra instructions".to_string());
612
613 assert_eq!(
614 cfg.prompt.render_system_prompt("default"),
615 "base\n\nextra instructions"
616 );
617
618 let blob = toml::to_string(&cfg).expect("serialize");
619 assert!(!blob.contains("extra instructions"));
620 let loaded: Config = toml::from_str(&blob).expect("deserialize");
621 assert!(!loaded.prompt.is_customized());
622 }
623}