use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE};
use crate::models::ReasoningLevel;
use crate::runtime::{PolicyOverride, SafetyMode};
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub last_used_model: Option<String>,
#[serde(default)]
pub default_model: ModelSettings,
#[serde(default)]
pub ollama: OllamaConfig,
#[serde(default)]
pub non_interactive: NonInteractiveConfig,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>,
#[serde(default)]
pub providers: HashMap<String, UserProviderConfig>,
#[serde(default)]
pub reasoning_per_model: HashMap<String, ReasoningLevel>,
#[serde(default)]
pub model_profiles: HashMap<String, String>,
#[serde(default)]
pub safety: SafetyConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(skip)]
pub prompt: PromptConfig,
}
#[derive(Debug, Clone, Default)]
pub struct PromptConfig {
pub system_prompt: Option<String>,
pub append_system_prompt: Vec<String>,
}
impl PromptConfig {
pub fn render_system_prompt(&self, default_prompt: &str) -> String {
let mut rendered = self
.system_prompt
.as_deref()
.unwrap_or(default_prompt)
.trim_end()
.to_string();
for extra in &self.append_system_prompt {
let extra = extra.trim();
if extra.is_empty() {
continue;
}
if !rendered.is_empty() {
rendered.push_str("\n\n");
}
rendered.push_str(extra);
}
rendered
}
pub fn is_customized(&self) -> bool {
self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SafetyConfig {
pub mode: SafetyMode,
pub checkpoint_on_mutation: bool,
#[serde(default)]
pub overrides: Vec<PolicyOverride>,
#[serde(default)]
pub auto_classifier_model: Option<String>,
}
impl Default for SafetyConfig {
fn default() -> Self {
Self {
mode: SafetyMode::Ask,
checkpoint_on_mutation: true,
overrides: Vec::new(),
auto_classifier_model: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
pub enabled: bool,
pub index_cap_bytes: usize,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
enabled: true,
index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UserProviderConfig {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub api_key_env: Option<String>,
#[serde(default)]
pub extra_headers: HashMap<String, String>,
#[serde(default)]
pub compat: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ModelSettings {
pub provider: String,
pub name: String,
pub temperature: f32,
pub max_tokens: usize,
pub reasoning: ReasoningLevel,
}
impl Default for ModelSettings {
fn default() -> Self {
Self {
provider: String::new(),
name: String::new(),
temperature: DEFAULT_TEMPERATURE,
max_tokens: DEFAULT_MAX_TOKENS,
reasoning: ReasoningLevel::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OllamaConfig {
pub host: String,
pub port: u16,
pub cloud_api_key: Option<String>,
pub num_gpu: Option<i32>,
pub num_thread: Option<i32>,
pub num_ctx: Option<i32>,
pub numa: Option<bool>,
}
impl Default for OllamaConfig {
fn default() -> Self {
Self {
host: String::from("localhost"),
port: DEFAULT_OLLAMA_PORT,
cloud_api_key: None,
num_gpu: None, num_thread: None, num_ctx: None, numa: None, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NonInteractiveConfig {
pub output_format: String,
pub max_tokens: usize,
pub no_execute: bool,
}
impl Default for NonInteractiveConfig {
fn default() -> Self {
Self {
output_format: String::from("text"),
max_tokens: DEFAULT_MAX_TOKENS,
no_execute: false,
}
}
}
pub fn load_config() -> Result<Config> {
let config_path = get_config_path()?;
if config_path.exists() {
let toml_str = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read {}", config_path.display()))?;
let config: Config = toml::from_str(&toml_str).with_context(|| {
format!(
"Failed to parse {}. Run 'mermaid init' to regenerate.",
config_path.display()
)
})?;
Ok(config)
} else {
Ok(Config::default())
}
}
pub fn get_config_path() -> Result<PathBuf> {
Ok(get_config_dir()?.join("config.toml"))
}
pub fn get_config_dir() -> Result<PathBuf> {
if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
let config_dir = proj_dirs.config_dir();
std::fs::create_dir_all(config_dir)?;
Ok(config_dir.to_path_buf())
} else {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.context("Could not determine home directory")?;
let config_dir = PathBuf::from(home).join(".config").join("mermaid");
std::fs::create_dir_all(&config_dir)?;
Ok(config_dir)
}
}
pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
let path = if let Some(p) = path {
p
} else {
get_config_dir()?.join("config.toml")
};
let toml_string = toml::to_string_pretty(config)?;
std::fs::write(&path, toml_string)
.with_context(|| format!("Failed to write config to {}", path.display()))?;
Ok(())
}
pub fn init_config() -> Result<()> {
let config_file = get_config_path()?;
if config_file.exists() {
println!("Configuration already exists at: {}", config_file.display());
} else {
let default_config = Config::default();
save_config(&default_config, Some(config_file.clone()))?;
println!("Created configuration at: {}", config_file.display());
}
Ok(())
}
pub fn persist_last_model(model: &str) -> Result<()> {
let mut config = load_config().unwrap_or_default();
config.last_used_model = Some(model.to_string());
save_config(&config, None)
}
pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
let mut config = load_config().unwrap_or_default();
config.default_model.reasoning = level;
save_config(&config, None)
}
pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
let mut config = load_config().unwrap_or_default();
config
.reasoning_per_model
.insert(model_id.to_string(), level);
save_config(&config, None)
}
pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
if let Some(model) = cli_model {
if let Some(resolved) = resolve_model_profile_alias(model, config)? {
return Ok(resolved);
}
return Ok(model.to_string());
}
if let Some(last_model) = &config.last_used_model {
if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
return Ok(resolved);
}
return Ok(last_model.clone());
}
if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
return Ok(format!(
"{}/{}",
config.default_model.provider, config.default_model.name
));
}
let available = crate::ollama::require_any_model(config).await?;
let first = available
.first()
.ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
Ok(format!("ollama/{}", first))
}
fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
let profile = requested.strip_prefix("profile:").unwrap_or(requested);
if let Some(model) = config.model_profiles.get(profile) {
anyhow::ensure!(
!model.trim().is_empty(),
"model profile `{}` is configured with an empty model id",
profile
);
return Ok(Some(model.clone()));
}
if requested.starts_with("profile:") {
anyhow::bail!(
"model profile `{}` is not configured; add it under [model_profiles]",
profile
);
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_settings_deserializes_without_reasoning_field() {
let toml_blob = r#"
provider = "ollama"
name = "qwen3-coder:30b"
temperature = 0.7
max_tokens = 4096
"#;
let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
assert_eq!(settings.reasoning, ReasoningLevel::Medium);
assert_eq!(settings.provider, "ollama");
}
#[test]
fn model_settings_round_trips_reasoning_high() {
let original = ModelSettings {
provider: "anthropic".to_string(),
name: "claude-sonnet-4-6".to_string(),
temperature: 0.5,
max_tokens: 8192,
reasoning: ReasoningLevel::High,
};
let toml_blob = toml::to_string(&original).expect("serialize");
let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
assert_eq!(back.reasoning, ReasoningLevel::High);
assert_eq!(back.name, "claude-sonnet-4-6");
}
#[test]
fn configured_model_profile_resolves_explicit_alias() {
let mut config = Config::default();
config
.model_profiles
.insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
assert_eq!(
resolve_model_profile_alias("fast", &config).unwrap(),
Some("ollama/qwen3-coder:14b".to_string())
);
assert_eq!(
resolve_model_profile_alias("profile:fast", &config).unwrap(),
Some("ollama/qwen3-coder:14b".to_string())
);
}
#[test]
fn profile_prefix_requires_configuration() {
let config = Config::default();
assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
assert_eq!(
resolve_model_profile_alias("vision", &config).unwrap(),
None
);
}
#[test]
fn save_and_reload_preserves_reasoning_field() {
let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("config.toml");
let mut cfg = Config::default();
cfg.default_model.provider = "ollama".to_string();
cfg.default_model.name = "qwen3-coder:30b".to_string();
cfg.default_model.reasoning = ReasoningLevel::Low;
save_config(&cfg, Some(path.clone())).expect("save");
let blob = std::fs::read_to_string(&path).expect("read");
let loaded: Config = toml::from_str(&blob).expect("parse back");
assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_and_reload_preserves_reasoning_per_model_table() {
let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("config.toml");
let mut cfg = Config::default();
cfg.reasoning_per_model.insert(
"anthropic/claude-sonnet-4-6".to_string(),
ReasoningLevel::High,
);
cfg.reasoning_per_model
.insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
save_config(&cfg, Some(path.clone())).expect("save");
let blob = std::fs::read_to_string(&path).expect("read");
let loaded: Config = toml::from_str(&blob).expect("parse back");
assert_eq!(
loaded
.reasoning_per_model
.get("anthropic/claude-sonnet-4-6"),
Some(&ReasoningLevel::High)
);
assert_eq!(
loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
Some(&ReasoningLevel::Low)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn config_deserializes_without_reasoning_per_model() {
let toml_blob = r#"
last_used_model = "ollama/qwen3-coder:30b"
[default_model]
provider = "ollama"
name = "qwen3-coder:30b"
temperature = 0.7
max_tokens = 4096
"#;
let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
assert!(cfg.reasoning_per_model.is_empty());
assert!(!cfg.prompt.is_customized());
}
#[test]
fn prompt_config_replaces_and_appends_without_persisting() {
let mut cfg = Config::default();
cfg.prompt.system_prompt = Some("base".to_string());
cfg.prompt
.append_system_prompt
.push("extra instructions".to_string());
assert_eq!(
cfg.prompt.render_system_prompt("default"),
"base\n\nextra instructions"
);
let blob = toml::to_string(&cfg).expect("serialize");
assert!(!blob.contains("extra instructions"));
let loaded: Config = toml::from_str(&blob).expect("deserialize");
assert!(!loaded.prompt.is_customized());
}
}