use crate::constants::{DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE, LEGACY_DEFAULT_MAX_TOKENS};
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 web: WebConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub non_interactive: NonInteractiveConfig,
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_defer_tools: Option<bool>,
#[serde(default)]
pub providers: HashMap<String, UserProviderConfig>,
#[serde(default)]
pub reasoning_per_model: HashMap<String, ReasoningLevel>,
#[serde(default)]
pub ollama_num_ctx_per_model: HashMap<String, u32>,
#[serde(default)]
pub model_aliases: HashMap<String, String>,
#[serde(default)]
pub safety: SafetyConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub compaction: CompactionConfig,
#[serde(default)]
pub computer_use: ComputerUseConfig,
#[serde(default)]
pub exec: ExecConfig,
#[serde(default)]
pub plan: PlanConfig,
#[serde(default)]
pub agents: AgentsConfig,
#[serde(skip)]
pub prompt: PromptConfig,
#[serde(skip)]
pub active_profile: Option<String>,
}
impl Config {
pub fn mcp_deferral_enabled(&self) -> bool {
self.mcp_defer_tools.unwrap_or(true)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExecConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pty: Option<bool>,
}
impl ExecConfig {
pub fn pty_enabled(&self) -> bool {
self.pty.unwrap_or(true)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default)]
pub theme: ThemeChoice,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeChoice {
#[default]
Dark,
Light,
}
impl ThemeChoice {
pub fn as_str(self) -> &'static str {
match self {
ThemeChoice::Dark => "dark",
ThemeChoice::Light => "light",
}
}
}
#[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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NetworkPolicy {
#[default]
Allow,
Deny,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FilesystemPolicy {
#[default]
Unrestricted,
Project,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SafetyConfig {
pub mode: SafetyMode,
pub checkpoint_on_mutation: bool,
#[serde(default)]
pub network: NetworkPolicy,
#[serde(default)]
pub filesystem: FilesystemPolicy,
#[serde(default)]
pub overrides: Vec<PolicyOverride>,
#[serde(default)]
pub external_writes: crate::runtime::FloorLevel,
#[serde(default)]
pub system_installs: crate::runtime::FloorLevel,
#[serde(default)]
pub auto_classifier_model: Option<String>,
#[serde(default)]
pub allow_untrusted_headless_tools: bool,
}
impl Default for SafetyConfig {
fn default() -> Self {
Self {
mode: SafetyMode::Ask,
checkpoint_on_mutation: true,
network: NetworkPolicy::default(),
filesystem: FilesystemPolicy::default(),
overrides: Vec::new(),
external_writes: crate::runtime::FloorLevel::default(),
system_installs: crate::runtime::FloorLevel::default(),
auto_classifier_model: None,
allow_untrusted_headless_tools: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DaemonConfig {
pub max_concurrent_tasks: usize,
pub task_timeout_minutes: Option<u64>,
pub retention_days: i64,
pub outcomes_retention_days: i64,
pub scratchpad_retention_days: i64,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
max_concurrent_tasks: 1,
task_timeout_minutes: None,
retention_days: 30,
outcomes_retention_days: 180,
scratchpad_retention_days: crate::session::scratchpad::RETENTION_DAYS as i64,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPostApprove {
Start,
Wait,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanPermLevel {
Allow,
Auto,
Ask,
Deny,
}
impl PlanPermLevel {
pub fn as_str(self) -> &'static str {
match self {
PlanPermLevel::Allow => "allow",
PlanPermLevel::Auto => "auto",
PlanPermLevel::Ask => "ask",
PlanPermLevel::Deny => "deny",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct PlanPermissions {
pub builds: PlanPermLevel,
pub web: PlanPermLevel,
pub memory: PlanPermLevel,
pub tasks: PlanPermLevel,
}
impl Default for PlanPermissions {
fn default() -> Self {
Self {
builds: PlanPermLevel::Allow,
web: PlanPermLevel::Allow,
memory: PlanPermLevel::Allow,
tasks: PlanPermLevel::Deny,
}
}
}
impl PlanPermissions {
pub fn preset_name(&self) -> Option<&'static str> {
if *self == Self::default() {
Some("default")
} else if *self == Self::strict() {
Some("strict")
} else if *self == Self::open() {
Some("open")
} else {
None
}
}
pub fn strict() -> Self {
Self {
builds: PlanPermLevel::Deny,
web: PlanPermLevel::Deny,
memory: PlanPermLevel::Deny,
tasks: PlanPermLevel::Deny,
}
}
pub fn open() -> Self {
Self {
builds: PlanPermLevel::Allow,
web: PlanPermLevel::Allow,
memory: PlanPermLevel::Allow,
tasks: PlanPermLevel::Allow,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PlanConfig {
pub auto_approve: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_approve: Option<PlanPostApprove>,
pub permissions: PlanPermissions,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<crate::models::ReasoningLevel>,
}
#[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, Serialize, Deserialize)]
#[serde(default)]
pub struct CompactionConfig {
pub max_truncation_recoveries: u8,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ComputerUseConfig {
pub auto_screenshot: bool,
}
impl Default for ComputerUseConfig {
fn default() -> Self {
Self {
auto_screenshot: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentsConfig {
pub timeout_secs: u64,
pub types: HashMap<String, AgentTypeConfig>,
}
impl Default for AgentsConfig {
fn default() -> Self {
Self {
timeout_secs: 1200,
types: HashMap::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentTypeConfig {
pub tools: Option<Vec<String>>,
pub safety: Option<String>,
pub preamble: Option<String>,
pub model: Option<String>,
}
#[derive(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 env_headers: HashMap<String, String>,
#[serde(default)]
pub compat: Option<String>,
#[serde(default)]
pub default_model: Option<String>,
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct McpServerConfig {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub env_headers: HashMap<String, String>,
#[serde(default)]
pub allow_private_network: bool,
#[serde(default)]
pub enabled_tools: Vec<String>,
#[serde(default)]
pub disabled_tools: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defer: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportKind {
Stdio,
Http,
}
impl McpServerConfig {
pub fn transport_kind(&self) -> Result<TransportKind> {
match (&self.url, self.command.is_empty()) {
(Some(_), false) => Err(anyhow::anyhow!(
"MCP server config sets both `command` and `url`; they are mutually exclusive"
)),
(None, true) => Err(anyhow::anyhow!(
"MCP server config sets neither `command` nor `url`"
)),
(None, false) => Ok(TransportKind::Stdio),
(Some(url), true) => {
let parsed = reqwest::Url::parse(url)
.map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
let host = parsed.host_str().unwrap_or("");
match parsed.scheme() {
"https" => Ok(TransportKind::Http),
"http" if crate::utils::classify_host(host).is_loopback() => {
Ok(TransportKind::Http)
},
"http" => Err(anyhow::anyhow!(
"MCP server url '{url}' uses plaintext http to a non-loopback host; \
use https (auth headers would travel in cleartext)"
)),
other => Err(anyhow::anyhow!(
"MCP server url '{url}' has unsupported scheme '{other}' \
(expected https, or http to loopback)"
)),
}
},
}
}
pub fn tool_allowed(&self, tool_name: &str) -> bool {
if self.disabled_tools.iter().any(|t| t == tool_name) {
return false;
}
self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
}
}
fn debug_masked_map(
map: &HashMap<String, String>,
) -> std::collections::BTreeMap<&str, &'static str> {
map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
}
impl std::fmt::Debug for McpServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpServerConfig")
.field("command", &self.command)
.field(
"args",
&self
.args
.iter()
.map(|a| crate::utils::redact_secrets(a))
.collect::<Vec<_>>(),
)
.field("env", &debug_masked_map(&self.env))
.field("url", &self.url)
.field("headers", &debug_masked_map(&self.headers))
.field("env_headers", &self.env_headers)
.field("allow_private_network", &self.allow_private_network)
.field("enabled_tools", &self.enabled_tools)
.field("disabled_tools", &self.disabled_tools)
.finish()
}
}
impl std::fmt::Debug for UserProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserProviderConfig")
.field("base_url", &self.base_url)
.field("api_key_env", &self.api_key_env)
.field("extra_headers", &debug_masked_map(&self.extra_headers))
.field("env_headers", &self.env_headers)
.field("compat", &self.compat)
.field("default_model", &self.default_model)
.finish()
}
}
#[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: 0,
reasoning: ReasoningLevel::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OllamaConfig {
pub host: String,
pub port: u16,
pub num_gpu: Option<i32>,
pub num_thread: Option<i32>,
pub num_ctx: Option<i32>,
pub numa: Option<bool>,
pub allow_ram_offload: bool,
pub max_auto_num_ctx: Option<usize>,
pub auto_start: bool,
}
impl Default for OllamaConfig {
fn default() -> Self {
Self {
host: String::from("localhost"),
port: DEFAULT_OLLAMA_PORT,
num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, auto_start: true, }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FetchBackend {
#[default]
Native,
Ollama,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchBackend {
#[default]
Auto,
Ollama,
Searxng,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WebConfig {
pub fetch_backend: FetchBackend,
pub search_backend: SearchBackend,
pub searxng_url: String,
}
impl Default for WebConfig {
fn default() -> Self {
Self {
fetch_backend: FetchBackend::Native,
search_backend: SearchBackend::Auto,
searxng_url: String::from("http://localhost:8080"),
}
}
}
#[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: 0,
no_execute: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ConfigLayer {
Defaults = 0,
User = 1,
Profile = 2,
Project = 3,
Session = 4,
}
impl ConfigLayer {
fn name(self) -> &'static str {
match self {
ConfigLayer::Defaults => "defaults",
ConfigLayer::User => "user config",
ConfigLayer::Profile => "config profile",
ConfigLayer::Project => "project config",
ConfigLayer::Session => "session flags",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct LayerSource {
pub layer: ConfigLayer,
pub origin: String,
pub table: toml::Table,
}
#[derive(Debug, Clone, Default)]
pub struct SessionFlags {
pub overrides: Vec<String>,
pub deny_network: bool,
pub confine_fs: bool,
pub max_tokens: Option<usize>,
pub allow_untrusted_tools: bool,
pub profile: Option<String>,
}
impl SessionFlags {
pub(crate) fn to_table(&self) -> Result<toml::Table> {
let mut table = toml::Table::new();
apply_cli_overrides(&mut table, &self.overrides)?;
if self.deny_network {
deep_set_segments(
&mut table,
&["safety", "network"],
toml::Value::String("deny".into()),
)?;
}
if self.confine_fs {
deep_set_segments(
&mut table,
&["safety", "filesystem"],
toml::Value::String("project".into()),
)?;
}
if let Some(n) = self.max_tokens {
deep_set_segments(
&mut table,
&["default_model", "max_tokens"],
toml::Value::Integer(n as i64),
)?;
}
if self.allow_untrusted_tools {
deep_set_segments(
&mut table,
&["safety", "allow_untrusted_headless_tools"],
toml::Value::Boolean(true),
)?;
}
Ok(table)
}
}
fn take_profiles(table: &mut toml::Table) -> toml::Table {
match table.remove("profiles") {
Some(toml::Value::Table(profiles)) => profiles,
_ => toml::Table::new(),
}
}
fn resolve_profile_layer(
profiles: &toml::Table,
name: &str,
config_path: &std::path::Path,
) -> Result<LayerSource> {
match profiles.get(name) {
Some(toml::Value::Table(overlay)) => Ok(LayerSource {
layer: ConfigLayer::Profile,
origin: format!("profile:{} ({})", name, config_path.display()),
table: overlay.clone(),
}),
Some(_) => anyhow::bail!(
"config profile '{}' is not a table; define it as [profiles.{}] in {}",
name,
name,
config_path.display()
),
None => {
let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
available.sort_unstable();
if available.is_empty() {
anyhow::bail!(
"no config profiles defined; add [profiles.{}] to {}",
name,
config_path.display()
);
}
anyhow::bail!(
"unknown config profile '{}'; available: {}",
name,
available.join(", ")
)
},
}
}
pub fn load_config() -> Result<Config> {
let config_path = get_config_path()?;
let mut table = read_config_table(&config_path)?;
migrate_legacy_max_tokens(&mut table);
migrate_legacy_model_profiles(&mut table);
let _ = take_profiles(&mut table);
Ok(finalize_config(table)?.0)
}
pub struct LayeredLoad {
pub config: Config,
pub warnings: Vec<String>,
pub notices: Vec<String>,
}
pub fn load_layered_config(
cwd: Option<&std::path::Path>,
flags: &SessionFlags,
) -> Result<LayeredLoad> {
let config_path = get_config_path()?;
let mut user_table = read_config_table(&config_path)?;
migrate_legacy_max_tokens(&mut user_table);
migrate_legacy_model_profiles(&mut user_table);
let profiles = take_profiles(&mut user_table);
let mut layers = vec![LayerSource {
layer: ConfigLayer::User,
origin: config_path.display().to_string(),
table: user_table.clone(),
}];
let mut sanitizer_warnings = Vec::new();
let mut notices = Vec::new();
if let Some(name) = flags.profile.as_deref() {
let layer = resolve_profile_layer(&profiles, name, &config_path)?;
notices.push(format!(
"using config profile '{}' (from {})",
name,
config_path.display()
));
layers.push(layer);
}
if let Some(cwd) = cwd {
let base_safety = finalize_config(user_table)?.0.safety;
let (layer, warnings, notice) =
super::project_config::load_project_layer(cwd, &base_safety);
sanitizer_warnings.extend(warnings);
notices.extend(notice);
if let Some(layer) = layer {
layers.push(layer);
}
}
layers.push(LayerSource {
layer: ConfigLayer::Session,
origin: "command line".to_string(),
table: flags.to_table()?,
});
let (mut config, unknown_key_warnings) = merge_layers(layers)?;
config.active_profile = flags.profile.clone();
sanitizer_warnings.extend(unknown_key_warnings);
Ok(LayeredLoad {
config,
warnings: sanitizer_warnings,
notices,
})
}
pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
fn load(cwd: &std::path::Path) -> Result<Config> {
let config_path = get_config_path()?;
let mut user_table = read_config_table(&config_path)?;
migrate_legacy_max_tokens(&mut user_table);
migrate_legacy_model_profiles(&mut user_table);
let _ = take_profiles(&mut user_table);
let base_safety = finalize_config(user_table.clone())?.0.safety;
let mut layers = vec![LayerSource {
layer: ConfigLayer::User,
origin: config_path.display().to_string(),
table: user_table,
}];
let (layer, _warnings, _notice) =
super::project_config::load_project_layer(cwd, &base_safety);
if let Some(layer) = layer {
layers.push(layer);
}
Ok(merge_layers(layers)?.0)
}
load(cwd).unwrap_or_default()
}
pub fn load_config_or_warn() -> Config {
load_config().unwrap_or_else(|e| {
eprintln!(
"mermaid: {}",
crate::utils::redact_secrets(&format!("{e:#}"))
);
Config::default()
})
}
pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
if !path.exists() {
return Ok(toml::Table::new());
}
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
toml::from_str::<toml::Table>(&raw).with_context(|| {
format!(
"Failed to parse {}. Run 'mermaid init' to regenerate.",
path.display()
)
})
}
pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
let mut warnings = Vec::new();
let mut merged = toml::Table::new();
for layer in layers {
collect_layer_warnings(&layer, &mut warnings);
deep_merge(&mut merged, layer.table);
}
let (config, _) = finalize_config(merged)?;
Ok((config, warnings))
}
fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
let mut ignored = Vec::new();
let result: Result<Config, _> =
serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
ignored.push(path.to_string())
});
if result.is_ok() {
for path in ignored {
warnings.push(format!(
"unknown config key '{path}' in {} ({}) — check for a typo",
layer.layer.name(),
layer.origin
));
}
}
}
fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
for (key, value) in overlay {
match (base.get_mut(&key), value) {
(Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
deep_merge(base_table, overlay_table);
},
(_, value) => {
base.insert(key, value);
},
}
}
}
fn migrate_legacy_max_tokens(table: &mut toml::Table) {
if let Some(dm) = table
.get_mut("default_model")
.and_then(|v| v.as_table_mut())
&& dm.get("max_tokens").and_then(|v| v.as_integer())
== Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
{
dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
}
}
fn migrate_legacy_model_profiles(table: &mut toml::Table) {
if table.contains_key("model_aliases") {
table.remove("model_profiles");
return;
}
if let Some(profiles) = table.remove("model_profiles") {
table.insert("model_aliases".to_string(), profiles);
}
}
fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
let mut ignored = Vec::new();
let config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
ignored.push(path.to_string());
})
.context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
Ok((config, ignored))
}
fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
for raw in overrides {
let (key, val) = raw
.split_once('=')
.with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
let key = key.trim();
if key.is_empty() {
anyhow::bail!("invalid -c override '{raw}' (empty key)");
}
deep_set(table, key, parse_override_value(val.trim()))?;
}
Ok(())
}
fn parse_override_value(s: &str) -> toml::Value {
toml::from_str::<toml::Table>(&format!("x = {s}"))
.ok()
.and_then(|t| t.get("x").cloned())
.unwrap_or_else(|| toml::Value::String(s.to_string()))
}
fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
let parts: Vec<&str> = key.split('.').collect();
deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
}
fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
let Some((leaf, parents)) = path.split_last() else {
anyhow::bail!("empty config key path");
};
let mut cur = table;
for part in parents {
let next = cur
.entry((*part).to_string())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
cur = next
.as_table_mut()
.with_context(|| format!("'{part}' is not a table"))?;
}
cur.insert((*leaf).to_string(), value);
Ok(())
}
pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
let Some((leaf, parents)) = path.split_last() else {
return false;
};
let mut cur = table;
for part in parents {
match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
Some(next) => cur = next,
None => return false,
}
}
cur.remove(*leaf).is_some()
}
pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
match load_layered_config(cwd, flags) {
Ok(load) => {
for notice in &load.notices {
eprintln!("mermaid: {notice}");
}
for warning in &load.warnings {
eprintln!("mermaid: warning: {warning}");
}
load.config
},
Err(e) => {
eprintln!(
"mermaid: {}",
crate::utils::redact_secrets(&format!("{e:#}"))
);
flags
.to_table()
.ok()
.and_then(|table| finalize_config(table).ok())
.map(|(config, _)| config)
.unwrap_or_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)
}
}
fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
let path = if let Some(p) = path {
p
} else {
get_config_dir()?.join("config.toml")
};
write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
}
fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
#[cfg(unix)]
crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
.with_context(|| format!("Failed to write config to {}", path.display()))?;
#[cfg(not(unix))]
crate::runtime::write_atomic(path, bytes)
.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(())
}
static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
update_user_config_table_at(&get_config_path()?, mutate)
}
fn update_user_config_table_at(
path: &std::path::Path,
mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
) -> Result<()> {
let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut table = read_config_table(path)?;
migrate_legacy_max_tokens(&mut table);
migrate_legacy_model_profiles(&mut table);
mutate(&mut table)?;
write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
}
pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
update_user_config_table(|table| deep_set_segments(table, path, value))
}
pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
}
pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
let mut removed = false;
update_user_config_table(|table| {
removed = deep_remove_segments(table, path);
Ok(())
})?;
Ok(removed)
}
pub fn persist_last_model(model: &str) -> Result<()> {
update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
}
pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
update_user_config_key(
&["ui", "theme"],
toml::Value::String(theme.as_str().to_string()),
)
}
pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
update_user_config_key(
&["default_model", "reasoning"],
toml::Value::try_from(level)?,
)
}
pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
update_user_config_key(
&["reasoning_per_model", model_id],
toml::Value::try_from(level)?,
)
}
pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
match num_ctx {
Some(n) => update_user_config_key(
&["ollama_num_ctx_per_model", model_id],
toml::Value::Integer(i64::from(n)),
),
None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
}
}
pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
update_user_config_key(
&["ollama", "allow_ram_offload"],
toml::Value::Boolean(enabled),
)
}
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_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_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_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
let alias = requested.strip_prefix("alias:").unwrap_or(requested);
if let Some(model) = config.model_aliases.get(alias) {
anyhow::ensure!(
!model.trim().is_empty(),
"model alias `{}` is configured with an empty model id",
alias
);
return Ok(Some(model.clone()));
}
if requested.starts_with("alias:") {
anyhow::bail!(
"model alias `{}` is not configured; add it under [model_aliases]",
alias
);
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_default_max_tokens_migrates_to_auto() {
let mut table: toml::Table =
toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
migrate_legacy_max_tokens(&mut table);
migrate_legacy_model_profiles(&mut table);
let (config, _) = finalize_config(table).unwrap();
assert_eq!(config.default_model.max_tokens, 0);
let mut table: toml::Table =
toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
migrate_legacy_max_tokens(&mut table);
migrate_legacy_model_profiles(&mut table);
let (config, _) = finalize_config(table).unwrap();
assert_eq!(config.default_model.max_tokens, 8192);
let mut table = toml::Table::new();
migrate_legacy_max_tokens(&mut table);
migrate_legacy_model_profiles(&mut table);
let (config, _) = finalize_config(table).unwrap();
assert_eq!(config.default_model.max_tokens, 0);
}
#[test]
fn legacy_model_profiles_table_migrates_to_model_aliases() {
let mut table: toml::Table =
toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
migrate_legacy_model_profiles(&mut table);
let (config, ignored) = finalize_config(table).unwrap();
assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
let mut table: toml::Table =
toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
.unwrap();
migrate_legacy_model_profiles(&mut table);
let (config, ignored) = finalize_config(table).unwrap();
assert_eq!(config.model_aliases["fast"], "new");
assert!(ignored.is_empty());
let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
update_user_config_table_at(&path, |_| Ok(())).unwrap();
let blob = std::fs::read_to_string(&path).unwrap();
assert!(blob.contains("[model_aliases]"), "{blob}");
assert!(!blob.contains("model_profiles"), "{blob}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ui_theme_deserializes_defaults_and_rejects_typos() {
let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
assert_eq!(config.ui.theme, ThemeChoice::Light);
let config: Config = toml::from_str("").unwrap();
assert_eq!(config.ui.theme, ThemeChoice::Dark);
assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
}
#[test]
fn finalize_config_flags_unknown_keys() {
let table: toml::Table =
toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
.unwrap();
let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
assert_eq!(config.default_model.max_tokens, 512);
assert!(
ignored.iter().any(|p| p == "unknown_top"),
"got {ignored:?}"
);
assert!(
ignored.iter().any(|p| p.contains("bogus")),
"got {ignored:?}"
);
}
#[test]
fn cli_overrides_beat_file_and_create_nested_tables() {
let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
let (config, ignored) = finalize_config(table).unwrap();
assert_eq!(config.default_model.max_tokens, 8192);
assert!(ignored.is_empty());
let mut empty = toml::Table::new();
apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
assert_eq!(
finalize_config(empty).unwrap().0.default_model.max_tokens,
256
);
}
#[test]
fn parse_override_value_keeps_toml_types_with_string_fallback() {
assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
assert_eq!(
parse_override_value("ollama/qwen"),
toml::Value::String("ollama/qwen".to_string())
);
}
#[test]
fn cli_override_invalid_format_errors() {
let mut table = toml::Table::new();
assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
}
#[test]
fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
let mut base: toml::Table = toml::from_str(
"top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
)
.unwrap();
let overlay: toml::Table =
toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
.unwrap();
deep_merge(&mut base, overlay);
assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
assert_eq!(base["top"].as_integer(), Some(1));
}
#[test]
fn deep_merge_overlay_wins_on_kind_conflict() {
let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
deep_merge(&mut base, overlay);
assert_eq!(base["a"].as_integer(), Some(5));
assert_eq!(base["b"]["y"].as_integer(), Some(3));
}
#[test]
fn merge_layers_precedence_and_layer_attributed_warnings() {
let user: toml::Table = toml::from_str(
"last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
)
.unwrap();
let session: toml::Table =
toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
let (config, warnings) = merge_layers(vec![
LayerSource {
layer: ConfigLayer::User,
origin: "/tmp/user.toml".to_string(),
table: user,
},
LayerSource {
layer: ConfigLayer::Session,
origin: "command line".to_string(),
table: session,
},
])
.expect("merges");
assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
assert_eq!(config.default_model.max_tokens, 100);
assert!(
warnings
.iter()
.any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
"got {warnings:?}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("session_typo") && w.contains("session flags")),
"got {warnings:?}"
);
}
#[test]
fn take_profiles_excises_and_tolerates_absence() {
let mut table: toml::Table =
toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
let profiles = take_profiles(&mut table);
assert!(table.is_empty(), "profiles must be excised: {table:?}");
assert!(profiles.contains_key("fast"));
let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
assert!(take_profiles(&mut table).is_empty());
assert_eq!(table.len(), 1);
let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
assert!(take_profiles(&mut table).is_empty());
assert!(table.is_empty());
}
#[test]
fn resolve_profile_layer_errors_name_available_profiles() {
let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
let path = std::path::Path::new("/tmp/config.toml");
let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
assert!(err.to_string().contains("available: fast, work"), "{err}");
let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
assert!(
err.to_string().contains("no config profiles defined"),
"{err}"
);
let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
assert!(err.to_string().contains("not a table"), "{err}");
let profiles: toml::Table =
toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
assert_eq!(layer.layer, ConfigLayer::Profile);
assert!(layer.origin.contains("profile:work"));
}
#[test]
fn profile_layer_beats_user_loses_to_project_and_session() {
let user: toml::Table = toml::from_str(
"last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
)
.unwrap();
let profile: toml::Table = toml::from_str(
"last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
)
.unwrap();
let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
let session: toml::Table =
toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
let (config, warnings) = merge_layers(vec![
LayerSource {
layer: ConfigLayer::User,
origin: "/tmp/user.toml".to_string(),
table: user,
},
LayerSource {
layer: ConfigLayer::Profile,
origin: "profile:work (/tmp/user.toml)".to_string(),
table: profile,
},
LayerSource {
layer: ConfigLayer::Project,
origin: "/repo/.mermaid/config.toml".to_string(),
table: project,
},
LayerSource {
layer: ConfigLayer::Session,
origin: "command line".to_string(),
table: session,
},
])
.expect("merges");
assert_eq!(config.default_model.temperature, 0.5);
assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
assert_eq!(config.default_model.max_tokens, 100);
assert!(
warnings.iter().any(|w| w.contains("profile_typo")
&& w.contains("config profile (profile:work (/tmp/user.toml))")),
"got {warnings:?}"
);
}
#[test]
fn persists_never_touch_profile_tables() {
let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("config.toml");
std::fs::write(
&path,
"[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
)
.expect("seed");
update_user_config_table_at(&path, |table| {
deep_set_segments(
table,
&["safety", "mode"],
toml::Value::String("auto".to_string()),
)
})
.expect("persist");
let table: toml::Table =
toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
assert_eq!(
table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
Some(0.1)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_flags_table_maps_each_flag() {
let flags = SessionFlags {
overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
deny_network: true,
confine_fs: true,
max_tokens: Some(512),
allow_untrusted_tools: true,
profile: None,
};
let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
assert_eq!(config.safety.network, NetworkPolicy::Deny);
assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
assert_eq!(config.default_model.max_tokens, 512);
assert!(config.safety.allow_untrusted_headless_tools);
assert_eq!(config.web.searxng_url, "http://x:1");
}
#[test]
fn session_dedicated_flags_beat_dash_c() {
let flags = SessionFlags {
overrides: vec!["safety.network=allow".to_string()],
deny_network: true,
..Default::default()
};
let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
assert_eq!(config.safety.network, NetworkPolicy::Deny);
}
#[test]
fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
let mut warnings = Vec::new();
collect_layer_warnings(
&LayerSource {
layer: ConfigLayer::User,
origin: "x".to_string(),
table: bad.clone(),
},
&mut warnings,
);
assert!(warnings.is_empty());
assert!(
merge_layers(vec![LayerSource {
layer: ConfigLayer::User,
origin: "x".to_string(),
table: bad.clone(),
}])
.is_err()
);
let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
let (config, _) = merge_layers(vec![
LayerSource {
layer: ConfigLayer::User,
origin: "x".to_string(),
table: bad,
},
LayerSource {
layer: ConfigLayer::Session,
origin: "command line".to_string(),
table: fix,
},
])
.expect("later layer repairs the earlier one");
assert_eq!(config.safety.mode, SafetyMode::Ask);
}
#[test]
fn project_layer_beats_user_and_loses_to_session() {
let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
let project: toml::Table = toml::from_str(
"last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
)
.unwrap();
let session: toml::Table =
toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
let (config, _) = merge_layers(vec![
LayerSource {
layer: ConfigLayer::User,
origin: "user".to_string(),
table: user,
},
LayerSource {
layer: ConfigLayer::Project,
origin: "project".to_string(),
table: project,
},
LayerSource {
layer: ConfigLayer::Session,
origin: "command line".to_string(),
table: session,
},
])
.expect("merges");
assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
}
#[test]
fn session_flags_survive_corrupt_user_layer_fallback() {
let flags = SessionFlags {
deny_network: true,
..Default::default()
};
let config = flags
.to_table()
.ok()
.and_then(|table| finalize_config(table).ok())
.map(|(config, _)| config)
.unwrap_or_default();
assert_eq!(config.safety.network, NetworkPolicy::Deny);
}
#[test]
fn deep_set_segments_addresses_keys_containing_dots() {
let mut table = toml::Table::new();
deep_set_segments(
&mut table,
&["reasoning_per_model", "gemini/gemini-2.5-pro"],
toml::Value::String("high".to_string()),
)
.unwrap();
let (config, ignored) = finalize_config(table).unwrap();
assert!(ignored.is_empty(), "got {ignored:?}");
assert_eq!(
config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
Some(&ReasoningLevel::High)
);
}
#[test]
fn deep_remove_segments_removes_leaf_only() {
let mut table: toml::Table =
toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
.unwrap();
assert!(deep_remove_segments(
&mut table,
&["ollama_num_ctx_per_model", "ollama/a"]
));
assert_eq!(
table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
Some(2)
);
assert!(!deep_remove_segments(
&mut table,
&["ollama_num_ctx_per_model", "ollama/a"]
));
assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
}
#[test]
fn update_user_config_table_preserves_unknown_keys() {
let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("config.toml");
std::fs::write(
&path,
"future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
)
.expect("seed");
update_user_config_table_at(&path, |table| {
deep_set_segments(
table,
&["last_used_model"],
toml::Value::String("ollama/new".to_string()),
)
})
.expect("persist");
let blob = std::fs::read_to_string(&path).expect("read back");
let table: toml::Table = toml::from_str(&blob).expect("parse back");
assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
assert_eq!(table["future_key"].as_str(), Some("kept"));
assert!(!blob.contains("safety"), "defaults must not be frozen in");
assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn mcp_tool_allowed_honors_enabled_and_disabled() {
let cfg = McpServerConfig::default();
assert!(cfg.tool_allowed("anything"));
let cfg = McpServerConfig {
enabled_tools: vec!["read".into(), "search".into()],
..Default::default()
};
assert!(cfg.tool_allowed("read"));
assert!(!cfg.tool_allowed("write"));
let cfg = McpServerConfig {
enabled_tools: vec!["read".into(), "write".into()],
disabled_tools: vec!["write".into()],
..Default::default()
};
assert!(cfg.tool_allowed("read"));
assert!(!cfg.tool_allowed("write"));
}
#[test]
fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
let cfg = McpServerConfig {
command: "npx".to_string(),
..Default::default()
};
assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
let cfg = McpServerConfig {
url: Some("https://example.com/mcp".to_string()),
..Default::default()
};
assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
let cfg = McpServerConfig {
command: "npx".to_string(),
url: Some("https://example.com/mcp".to_string()),
..Default::default()
};
assert!(
cfg.transport_kind()
.unwrap_err()
.to_string()
.contains("mutually exclusive")
);
let cfg = McpServerConfig::default();
assert!(
cfg.transport_kind()
.unwrap_err()
.to_string()
.contains("neither")
);
}
#[test]
fn mcp_transport_kind_gates_url_scheme() {
let with_url = |url: &str| McpServerConfig {
url: Some(url.to_string()),
..Default::default()
};
assert!(
with_url("https://mcp.example.com/x")
.transport_kind()
.is_ok()
);
assert!(
with_url("http://localhost:8080/mcp")
.transport_kind()
.is_ok()
);
assert!(
with_url("http://127.0.0.1:8080/mcp")
.transport_kind()
.is_ok()
);
assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
assert!(with_url("not a url").transport_kind().is_err());
}
#[test]
fn mcp_server_config_debug_masks_header_values() {
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
let mut env_headers = HashMap::new();
env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
let cfg = McpServerConfig {
url: Some("https://example.com/mcp".to_string()),
headers,
env_headers,
..Default::default()
};
let rendered = format!("{cfg:?}");
assert!(!rendered.contains("sk-secret"), "{rendered}");
assert!(rendered.contains("Authorization"), "{rendered}");
assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
}
#[test]
fn mcp_url_config_round_trips_through_toml_without_command() {
let cfg = McpServerConfig {
url: Some("https://example.com/mcp".to_string()),
..Default::default()
};
let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
assert!(
!blob.contains("command"),
"empty command must be omitted: {blob}"
);
let back: McpServerConfig = toml::from_str(&blob).unwrap();
assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
assert!(back.command.is_empty());
let cfg = McpServerConfig {
command: "npx".to_string(),
..Default::default()
};
let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
assert!(!blob.contains("url"), "{blob}");
}
#[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 agents_config_defaults_and_parses_custom_types() {
let config: Config = toml::from_str("").expect("empty config parses");
assert_eq!(config.agents.timeout_secs, 1200);
assert!(config.agents.types.is_empty());
let config: Config = toml::from_str(
r#"
[agents]
timeout_secs = 300
[agents.types.scout]
tools = ["read_file", "execute_command"]
safety = "read_only"
preamble = "You are a scout."
model = "ollama/qwen3:8b"
"#,
)
.expect("agents section parses");
assert_eq!(config.agents.timeout_secs, 300);
let scout = &config.agents.types["scout"];
assert_eq!(
scout.tools.as_deref(),
Some(&["read_file".to_string(), "execute_command".to_string()][..])
);
assert_eq!(scout.safety.as_deref(), Some("read_only"));
assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
}
#[test]
fn configured_model_alias_resolves_explicit_prefix() {
let mut config = Config::default();
config
.model_aliases
.insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
assert_eq!(
resolve_model_alias("fast", &config).unwrap(),
Some("ollama/qwen3-coder:14b".to_string())
);
assert_eq!(
resolve_model_alias("alias:fast", &config).unwrap(),
Some("ollama/qwen3-coder:14b".to_string())
);
}
#[test]
fn alias_prefix_requires_configuration() {
let config = Config::default();
assert!(resolve_model_alias("alias:vision", &config).is_err());
assert_eq!(resolve_model_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 save_and_reload_preserves_ollama_context_overrides() {
let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("config.toml");
let mut cfg = Config::default();
cfg.ollama_num_ctx_per_model
.insert("ollama/ornith:9b".to_string(), 131_072);
cfg.ollama.allow_ram_offload = true;
cfg.ollama.max_auto_num_ctx = Some(65_536);
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.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
Some(&131_072)
);
assert!(loaded.ollama.allow_ram_offload);
assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn config_deserializes_without_ollama_context_keys() {
let toml_blob = r#"
[ollama]
host = "localhost"
port = 11434
"#;
let cfg: Config = toml::from_str(toml_blob).expect("parse");
assert!(cfg.ollama_num_ctx_per_model.is_empty());
assert!(!cfg.ollama.allow_ram_offload);
assert_eq!(cfg.ollama.max_auto_num_ctx, None);
assert!(cfg.ollama.auto_start);
}
#[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());
}
#[cfg(unix)]
#[test]
fn save_config_writes_owner_only_perms() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join("mermaid_test_config_perms");
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("config.toml");
std::fs::write(&path, "stale").expect("seed");
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
save_config(&Config::default(), Some(path.clone())).expect("save");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "config must be written owner-only");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn config_defaults_computer_use_auto_screenshot_on() {
let cfg: Config = toml::from_str("").expect("empty config");
assert!(cfg.computer_use.auto_screenshot);
}
#[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());
}
#[test]
fn plan_config_defaults_parse_and_do_not_freeze() {
let c: Config = toml::from_str("").expect("empty config parses");
assert!(!c.plan.auto_approve);
assert!(c.plan.post_approve.is_none());
let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
.expect("plan section parses");
assert!(c.plan.auto_approve);
assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
assert_eq!(
toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
.expect("wait parses")
.plan
.post_approve,
Some(PlanPostApprove::Wait)
);
let blob = toml::to_string(&Config::default()).expect("serialize");
assert!(!blob.contains("post_approve"));
}
}