1use crate::constants::{DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE, LEGACY_DEFAULT_MAX_TOKENS};
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 web: WebConfig,
28
29 #[serde(default)]
31 pub ui: UiConfig,
32
33 #[serde(default)]
35 pub non_interactive: NonInteractiveConfig,
36
37 #[serde(default)]
39 pub mcp_servers: HashMap<String, McpServerConfig>,
40
41 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub mcp_defer_tools: Option<bool>,
51
52 #[serde(default)]
66 pub providers: HashMap<String, UserProviderConfig>,
67
68 #[serde(default)]
80 pub reasoning_per_model: HashMap<String, ReasoningLevel>,
81
82 #[serde(default)]
91 pub ollama_num_ctx_per_model: HashMap<String, u32>,
92
93 #[serde(default)]
106 pub model_aliases: HashMap<String, String>,
107
108 #[serde(default)]
112 pub safety: SafetyConfig,
113
114 #[serde(default)]
116 pub memory: MemoryConfig,
117
118 #[serde(default)]
120 pub daemon: DaemonConfig,
121
122 #[serde(default)]
124 pub compaction: CompactionConfig,
125
126 #[serde(default)]
128 pub computer_use: ComputerUseConfig,
129
130 #[serde(default)]
132 pub exec: ExecConfig,
133
134 #[serde(default)]
136 pub plan: PlanConfig,
137
138 #[serde(default)]
141 pub agents: AgentsConfig,
142
143 #[serde(skip)]
147 pub prompt: PromptConfig,
148
149 #[serde(skip)]
153 pub active_profile: Option<String>,
154}
155
156impl Config {
157 pub fn mcp_deferral_enabled(&self) -> bool {
159 self.mcp_defer_tools.unwrap_or(true)
160 }
161}
162
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct ExecConfig {
166 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub pty: Option<bool>,
175}
176
177impl ExecConfig {
178 pub fn pty_enabled(&self) -> bool {
180 self.pty.unwrap_or(true)
181 }
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct UiConfig {
187 #[serde(default)]
189 pub theme: ThemeChoice,
190}
191
192#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum ThemeChoice {
198 #[default]
199 Dark,
200 Light,
201}
202
203impl ThemeChoice {
204 pub fn as_str(self) -> &'static str {
206 match self {
207 ThemeChoice::Dark => "dark",
208 ThemeChoice::Light => "light",
209 }
210 }
211}
212
213#[derive(Debug, Clone, Default)]
214pub struct PromptConfig {
215 pub system_prompt: Option<String>,
216 pub append_system_prompt: Vec<String>,
217}
218
219impl PromptConfig {
220 pub fn render_system_prompt(&self, default_prompt: &str) -> String {
221 self.append_extras(self.base_prompt(default_prompt))
222 }
223
224 pub fn base_prompt<'a>(&'a self, default_prompt: &'a str) -> &'a str {
232 self.system_prompt.as_deref().unwrap_or(default_prompt)
233 }
234
235 pub fn append_extras(&self, base: &str) -> String {
237 let mut rendered = base.trim_end().to_string();
238
239 for extra in &self.append_system_prompt {
240 let extra = extra.trim();
241 if extra.is_empty() {
242 continue;
243 }
244 if !rendered.is_empty() {
245 rendered.push_str("\n\n");
246 }
247 rendered.push_str(extra);
248 }
249
250 rendered
251 }
252
253 pub fn is_customized(&self) -> bool {
254 self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
255 }
256}
257
258#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum NetworkPolicy {
264 #[default]
265 Allow,
266 Deny,
267}
268
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "snake_case")]
276pub enum FilesystemPolicy {
277 #[default]
278 Unrestricted,
279 Project,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(default)]
284pub struct SafetyConfig {
285 pub mode: SafetyMode,
286 pub checkpoint_on_mutation: bool,
287 #[serde(default)]
290 pub network: NetworkPolicy,
291 #[serde(default)]
295 pub filesystem: FilesystemPolicy,
296 #[serde(default)]
297 pub overrides: Vec<PolicyOverride>,
298 #[serde(default)]
305 pub external_writes: crate::runtime::FloorLevel,
306 #[serde(default)]
313 pub system_installs: crate::runtime::FloorLevel,
314 #[serde(default)]
318 pub auto_classifier_model: Option<String>,
319 #[serde(default)]
325 pub allow_untrusted_headless_tools: bool,
326 #[serde(default)]
330 pub allow_readonly_web: bool,
331}
332
333impl Default for SafetyConfig {
334 fn default() -> Self {
335 Self {
336 mode: SafetyMode::Ask,
340 checkpoint_on_mutation: true,
341 network: NetworkPolicy::default(),
342 filesystem: FilesystemPolicy::default(),
343 overrides: Vec::new(),
344 external_writes: crate::runtime::FloorLevel::default(),
345 system_installs: crate::runtime::FloorLevel::default(),
346 auto_classifier_model: None,
347 allow_untrusted_headless_tools: false,
348 allow_readonly_web: false,
349 }
350 }
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355#[serde(default)]
356pub struct DaemonConfig {
357 pub max_concurrent_tasks: usize,
362 pub task_timeout_minutes: Option<u64>,
367 pub retention_days: i64,
371 pub outcomes_retention_days: i64,
376 pub scratchpad_retention_days: i64,
381}
382
383impl Default for DaemonConfig {
384 fn default() -> Self {
385 Self {
386 max_concurrent_tasks: 1,
387 task_timeout_minutes: None,
388 retention_days: 30,
389 outcomes_retention_days: 180,
390 scratchpad_retention_days: crate::session::scratchpad::RETENTION_DAYS as i64,
391 }
392 }
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "snake_case")]
398pub enum PlanPostApprove {
399 Start,
401 Wait,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum PlanPermLevel {
412 Allow,
413 Auto,
414 Ask,
415 Deny,
416}
417
418impl PlanPermLevel {
419 pub fn as_str(self) -> &'static str {
420 match self {
421 PlanPermLevel::Allow => "allow",
422 PlanPermLevel::Auto => "auto",
423 PlanPermLevel::Ask => "ask",
424 PlanPermLevel::Deny => "deny",
425 }
426 }
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434#[serde(default)]
435pub struct PlanPermissions {
436 pub builds: PlanPermLevel,
438 pub web: PlanPermLevel,
440 pub memory: PlanPermLevel,
442 pub tasks: PlanPermLevel,
447}
448
449impl Default for PlanPermissions {
450 fn default() -> Self {
451 Self {
452 builds: PlanPermLevel::Allow,
453 web: PlanPermLevel::Ask,
457 memory: PlanPermLevel::Allow,
458 tasks: PlanPermLevel::Deny,
459 }
460 }
461}
462
463impl PlanPermissions {
464 pub fn preset_name(&self) -> Option<&'static str> {
467 if *self == Self::default() {
468 Some("default")
469 } else if *self == Self::strict() {
470 Some("strict")
471 } else if *self == Self::open() {
472 Some("open")
473 } else {
474 None
475 }
476 }
477
478 pub fn strict() -> Self {
480 Self {
481 builds: PlanPermLevel::Deny,
482 web: PlanPermLevel::Deny,
483 memory: PlanPermLevel::Deny,
484 tasks: PlanPermLevel::Deny,
485 }
486 }
487
488 pub fn open() -> Self {
490 Self {
491 builds: PlanPermLevel::Allow,
492 web: PlanPermLevel::Allow,
493 memory: PlanPermLevel::Allow,
494 tasks: PlanPermLevel::Allow,
495 }
496 }
497}
498
499#[derive(Debug, Clone, Default, Serialize, Deserialize)]
501#[serde(default)]
502pub struct PlanConfig {
503 pub auto_approve: bool,
507 #[serde(skip_serializing_if = "Option::is_none")]
513 pub post_approve: Option<PlanPostApprove>,
514 pub permissions: PlanPermissions,
519 #[serde(skip_serializing_if = "Option::is_none")]
524 pub model: Option<String>,
525 #[serde(skip_serializing_if = "Option::is_none")]
527 pub reasoning: Option<crate::models::ReasoningLevel>,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532#[serde(default)]
533pub struct MemoryConfig {
534 pub enabled: bool,
537 pub index_cap_bytes: usize,
539}
540
541impl Default for MemoryConfig {
542 fn default() -> Self {
543 Self {
544 enabled: true,
545 index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
546 }
547 }
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
557#[serde(default)]
558pub struct CompactionConfig {
559 pub max_truncation_recoveries: u8,
571
572 pub auto_enabled: bool,
576
577 pub auto_threshold_percent: u8,
581
582 pub tail_turns: usize,
586
587 pub tail_token_budget: usize,
590
591 pub tool_output_max_chars: usize,
595
596 pub summary_max_tokens: usize,
601
602 pub summarizer_input_token_budget: usize,
605
606 pub min_response_reserve_tokens: usize,
610 pub max_response_reserve_tokens: usize,
611}
612
613impl Default for CompactionConfig {
614 fn default() -> Self {
615 let policy = crate::domain::CompactionPolicy::default();
616 Self {
617 max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
618 auto_enabled: policy.auto_enabled,
619 auto_threshold_percent: policy.auto_threshold_percent,
620 tail_turns: policy.tail_turns,
621 tail_token_budget: policy.tail_token_budget,
622 tool_output_max_chars: policy.tool_output_max_chars,
623 summary_max_tokens: policy.summary_max_tokens,
624 summarizer_input_token_budget: policy.summarizer_input_token_budget,
625 min_response_reserve_tokens: policy.min_response_reserve_tokens,
626 max_response_reserve_tokens: policy.max_response_reserve_tokens,
627 }
628 }
629}
630
631impl CompactionConfig {
632 pub fn policy(&self) -> crate::domain::CompactionPolicy {
641 let defaults = crate::domain::CompactionPolicy::default();
642 let min_reserve = self.min_response_reserve_tokens;
643 let max_reserve = self.max_response_reserve_tokens;
644 crate::domain::CompactionPolicy {
645 auto_enabled: self.auto_enabled,
646 auto_threshold_percent: self.auto_threshold_percent.clamp(1, 100),
647 tail_turns: self.tail_turns.max(1),
648 tail_token_budget: nonzero_or(self.tail_token_budget, defaults.tail_token_budget),
651 tool_output_max_chars: nonzero_or(
652 self.tool_output_max_chars,
653 defaults.tool_output_max_chars,
654 ),
655 summary_max_tokens: nonzero_or(self.summary_max_tokens, defaults.summary_max_tokens),
656 summarizer_input_token_budget: nonzero_or(
657 self.summarizer_input_token_budget,
658 defaults.summarizer_input_token_budget,
659 ),
660 min_response_reserve_tokens: min_reserve.min(max_reserve),
664 max_response_reserve_tokens: min_reserve.max(max_reserve),
665 }
666 }
667}
668
669fn nonzero_or(value: usize, fallback: usize) -> usize {
671 if value == 0 { fallback } else { value }
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize)]
676#[serde(default)]
677pub struct ComputerUseConfig {
678 pub auto_screenshot: bool,
684}
685
686impl Default for ComputerUseConfig {
687 fn default() -> Self {
688 Self {
689 auto_screenshot: true,
690 }
691 }
692}
693
694#[derive(Debug, Clone, Serialize, Deserialize)]
696#[serde(default)]
697pub struct AgentsConfig {
698 pub timeout_secs: u64,
701 pub types: HashMap<String, AgentTypeConfig>,
712}
713
714impl Default for AgentsConfig {
715 fn default() -> Self {
716 Self {
717 timeout_secs: 1200,
718 types: HashMap::new(),
719 }
720 }
721}
722
723#[derive(Debug, Clone, Default, Serialize, Deserialize)]
726#[serde(default)]
727pub struct AgentTypeConfig {
728 pub tools: Option<Vec<String>>,
733 pub safety: Option<String>,
737 pub preamble: Option<String>,
740 pub model: Option<String>,
743 pub isolation: Option<String>,
751}
752
753#[derive(Clone, Default, Serialize, Deserialize)]
757pub struct UserProviderConfig {
758 #[serde(default)]
761 pub base_url: Option<String>,
762 #[serde(default)]
766 pub api_key_env: Option<String>,
767 #[serde(default)]
769 pub extra_headers: HashMap<String, String>,
770 #[serde(default)]
775 pub env_headers: HashMap<String, String>,
776 #[serde(default)]
782 pub compat: Option<String>,
783 #[serde(default)]
789 pub default_model: Option<String>,
790}
791
792#[derive(Clone, Default, Serialize, Deserialize)]
794pub struct McpServerConfig {
795 #[serde(default, skip_serializing_if = "String::is_empty")]
798 pub command: String,
799 #[serde(default)]
801 pub args: Vec<String>,
802 #[serde(default)]
804 pub env: HashMap<String, String>,
805 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub url: Option<String>,
810 #[serde(default)]
813 pub headers: HashMap<String, String>,
814 #[serde(default)]
819 pub env_headers: HashMap<String, String>,
820 #[serde(default)]
824 pub allow_private_network: bool,
825 #[serde(default)]
827 pub enabled_tools: Vec<String>,
828 #[serde(default)]
830 pub disabled_tools: Vec<String>,
831 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub defer: Option<bool>,
837}
838
839#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum TransportKind {
843 Stdio,
844 Http,
845}
846
847impl McpServerConfig {
848 pub fn transport_kind(&self) -> Result<TransportKind> {
853 match (&self.url, self.command.is_empty()) {
854 (Some(_), false) => Err(anyhow::anyhow!(
855 "MCP server config sets both `command` and `url`; they are mutually exclusive"
856 )),
857 (None, true) => Err(anyhow::anyhow!(
858 "MCP server config sets neither `command` nor `url`"
859 )),
860 (None, false) => Ok(TransportKind::Stdio),
861 (Some(url), true) => {
862 let parsed = reqwest::Url::parse(url)
863 .map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
864 let host = parsed.host_str().unwrap_or("");
865 match parsed.scheme() {
866 "https" => Ok(TransportKind::Http),
867 "http" if crate::utils::classify_host(host).is_loopback() => {
868 Ok(TransportKind::Http)
869 },
870 "http" => Err(anyhow::anyhow!(
871 "MCP server url '{url}' uses plaintext http to a non-loopback host; \
872 use https (auth headers would travel in cleartext)"
873 )),
874 other => Err(anyhow::anyhow!(
875 "MCP server url '{url}' has unsupported scheme '{other}' \
876 (expected https, or http to loopback)"
877 )),
878 }
879 },
880 }
881 }
882
883 pub fn tool_allowed(&self, tool_name: &str) -> bool {
887 if self.disabled_tools.iter().any(|t| t == tool_name) {
888 return false;
889 }
890 self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
891 }
892}
893
894fn debug_masked_map(
898 map: &HashMap<String, String>,
899) -> std::collections::BTreeMap<&str, &'static str> {
900 map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
901}
902
903impl std::fmt::Debug for McpServerConfig {
908 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
909 f.debug_struct("McpServerConfig")
910 .field("command", &self.command)
911 .field(
913 "args",
914 &self
915 .args
916 .iter()
917 .map(|a| crate::utils::redact_secrets(a))
918 .collect::<Vec<_>>(),
919 )
920 .field("env", &debug_masked_map(&self.env))
921 .field("url", &self.url)
922 .field("headers", &debug_masked_map(&self.headers))
924 .field("env_headers", &self.env_headers)
926 .field("allow_private_network", &self.allow_private_network)
927 .field("enabled_tools", &self.enabled_tools)
929 .field("disabled_tools", &self.disabled_tools)
930 .finish()
931 }
932}
933
934impl std::fmt::Debug for UserProviderConfig {
935 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
936 f.debug_struct("UserProviderConfig")
937 .field("base_url", &self.base_url)
938 .field("api_key_env", &self.api_key_env)
939 .field("extra_headers", &debug_masked_map(&self.extra_headers))
940 .field("env_headers", &self.env_headers)
942 .field("compat", &self.compat)
943 .field("default_model", &self.default_model)
944 .finish()
945 }
946}
947
948#[derive(Debug, Clone, Serialize, Deserialize)]
950#[serde(default)]
951pub struct ModelSettings {
952 pub provider: String,
954 pub name: String,
956 pub temperature: f32,
958 pub max_tokens: usize,
960 pub reasoning: ReasoningLevel,
964}
965
966impl Default for ModelSettings {
967 fn default() -> Self {
968 Self {
969 provider: String::new(),
970 name: String::new(),
971 temperature: DEFAULT_TEMPERATURE,
972 max_tokens: 0,
976 reasoning: ReasoningLevel::default(),
977 }
978 }
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize)]
983#[serde(default)]
984pub struct OllamaConfig {
985 pub host: String,
987 pub port: u16,
989 pub num_gpu: Option<i32>,
992 pub num_thread: Option<i32>,
995 pub num_ctx: Option<i32>,
998 pub numa: Option<bool>,
1000 pub allow_ram_offload: bool,
1006 pub max_auto_num_ctx: Option<usize>,
1010 pub auto_start: bool,
1015}
1016
1017impl Default for OllamaConfig {
1018 fn default() -> Self {
1019 Self {
1020 host: String::from("localhost"),
1021 port: DEFAULT_OLLAMA_PORT,
1022 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, auto_start: true, }
1030 }
1031}
1032
1033#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1035#[serde(rename_all = "lowercase")]
1036pub enum FetchBackend {
1037 #[default]
1040 Native,
1041 Ollama,
1043}
1044
1045#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1047#[serde(rename_all = "lowercase")]
1048pub enum SearchBackend {
1049 #[default]
1053 Auto,
1054 Ollama,
1056 Searxng,
1058}
1059
1060#[derive(Debug, Clone, Serialize, Deserialize)]
1069#[serde(default)]
1070pub struct WebConfig {
1071 pub fetch_backend: FetchBackend,
1074 pub search_backend: SearchBackend,
1078 pub searxng_url: String,
1083}
1084
1085impl Default for WebConfig {
1086 fn default() -> Self {
1087 Self {
1088 fetch_backend: FetchBackend::Native,
1089 search_backend: SearchBackend::Auto,
1090 searxng_url: String::from("http://localhost:8080"),
1091 }
1092 }
1093}
1094
1095#[derive(Debug, Clone, Serialize, Deserialize)]
1097#[serde(default)]
1098pub struct NonInteractiveConfig {
1099 pub output_format: String,
1101 pub max_tokens: usize,
1103 pub no_execute: bool,
1105}
1106
1107impl Default for NonInteractiveConfig {
1108 fn default() -> Self {
1109 Self {
1110 output_format: String::from("text"),
1111 max_tokens: 0,
1113 no_execute: false,
1114 }
1115 }
1116}
1117
1118#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1122pub enum ConfigLayer {
1123 Defaults = 0,
1126 User = 1,
1129 Profile = 2,
1133 Project = 3,
1136 Session = 4,
1140}
1141
1142impl ConfigLayer {
1143 fn name(self) -> &'static str {
1145 match self {
1146 ConfigLayer::Defaults => "defaults",
1147 ConfigLayer::User => "user config",
1148 ConfigLayer::Profile => "config profile",
1149 ConfigLayer::Project => "project config",
1150 ConfigLayer::Session => "session flags",
1151 }
1152 }
1153}
1154
1155#[derive(Debug, Clone)]
1157pub(crate) struct LayerSource {
1158 pub layer: ConfigLayer,
1160 pub origin: String,
1162 pub table: toml::Table,
1165}
1166
1167#[derive(Debug, Clone, Default)]
1170pub struct SessionFlags {
1171 pub overrides: Vec<String>,
1174 pub deny_network: bool,
1176 pub confine_fs: bool,
1178 pub max_tokens: Option<usize>,
1180 pub allow_untrusted_tools: bool,
1182 pub profile: Option<String>,
1186}
1187
1188impl SessionFlags {
1189 pub(crate) fn to_table(&self) -> Result<toml::Table> {
1193 let mut table = toml::Table::new();
1194 apply_cli_overrides(&mut table, &self.overrides)?;
1195 if self.deny_network {
1196 deep_set_segments(
1197 &mut table,
1198 &["safety", "network"],
1199 toml::Value::String("deny".into()),
1200 )?;
1201 }
1202 if self.confine_fs {
1203 deep_set_segments(
1204 &mut table,
1205 &["safety", "filesystem"],
1206 toml::Value::String("project".into()),
1207 )?;
1208 }
1209 if let Some(n) = self.max_tokens {
1210 deep_set_segments(
1211 &mut table,
1212 &["default_model", "max_tokens"],
1213 toml::Value::Integer(n as i64),
1214 )?;
1215 }
1216 if self.allow_untrusted_tools {
1217 deep_set_segments(
1218 &mut table,
1219 &["safety", "allow_untrusted_headless_tools"],
1220 toml::Value::Boolean(true),
1221 )?;
1222 }
1223 Ok(table)
1224 }
1225}
1226
1227fn take_profiles(table: &mut toml::Table) -> toml::Table {
1234 match table.remove("profiles") {
1235 Some(toml::Value::Table(profiles)) => profiles,
1236 _ => toml::Table::new(),
1239 }
1240}
1241
1242fn resolve_profile_layer(
1246 profiles: &toml::Table,
1247 name: &str,
1248 config_path: &std::path::Path,
1249) -> Result<LayerSource> {
1250 match profiles.get(name) {
1251 Some(toml::Value::Table(overlay)) => Ok(LayerSource {
1252 layer: ConfigLayer::Profile,
1253 origin: format!("profile:{} ({})", name, config_path.display()),
1254 table: overlay.clone(),
1255 }),
1256 Some(_) => anyhow::bail!(
1257 "config profile '{}' is not a table; define it as [profiles.{}] in {}",
1258 name,
1259 name,
1260 config_path.display()
1261 ),
1262 None => {
1263 let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
1264 available.sort_unstable();
1265 if available.is_empty() {
1266 anyhow::bail!(
1267 "no config profiles defined; add [profiles.{}] to {}",
1268 name,
1269 config_path.display()
1270 );
1271 }
1272 anyhow::bail!(
1273 "unknown config profile '{}'; available: {}",
1274 name,
1275 available.join(", ")
1276 )
1277 },
1278 }
1279}
1280
1281pub fn load_config() -> Result<Config> {
1286 let config_path = get_config_path()?;
1287 let mut table = read_config_table(&config_path)?;
1288 migrate_legacy_max_tokens(&mut table);
1289 migrate_legacy_model_profiles(&mut table);
1290 let _ = take_profiles(&mut table);
1291 Ok(finalize_config(table)?.0)
1292}
1293
1294pub struct LayeredLoad {
1297 pub config: Config,
1299 pub warnings: Vec<String>,
1301 pub notices: Vec<String>,
1303}
1304
1305pub fn load_layered_config(
1310 cwd: Option<&std::path::Path>,
1311 flags: &SessionFlags,
1312) -> Result<LayeredLoad> {
1313 let config_path = get_config_path()?;
1314 let mut user_table = read_config_table(&config_path)?;
1315 migrate_legacy_max_tokens(&mut user_table);
1316 migrate_legacy_model_profiles(&mut user_table);
1317 let profiles = take_profiles(&mut user_table);
1320 let mut layers = vec![LayerSource {
1321 layer: ConfigLayer::User,
1322 origin: config_path.display().to_string(),
1323 table: user_table.clone(),
1324 }];
1325 let mut sanitizer_warnings = Vec::new();
1326 let mut notices = Vec::new();
1327 if let Some(name) = flags.profile.as_deref() {
1328 let layer = resolve_profile_layer(&profiles, name, &config_path)?;
1329 notices.push(format!(
1330 "using config profile '{}' (from {})",
1331 name,
1332 config_path.display()
1333 ));
1334 layers.push(layer);
1335 }
1336 if let Some(cwd) = cwd {
1337 let base_safety = finalize_config(user_table)?.0.safety;
1340 let (layer, warnings, notice) =
1341 super::project_config::load_project_layer(cwd, &base_safety);
1342 sanitizer_warnings.extend(warnings);
1343 notices.extend(notice);
1344 if let Some(layer) = layer {
1345 layers.push(layer);
1346 }
1347 }
1348 layers.push(LayerSource {
1349 layer: ConfigLayer::Session,
1350 origin: "command line".to_string(),
1351 table: flags.to_table()?,
1352 });
1353 let (mut config, unknown_key_warnings) = merge_layers(layers)?;
1354 config.active_profile = flags.profile.clone();
1355 sanitizer_warnings.extend(unknown_key_warnings);
1358 Ok(LayeredLoad {
1359 config,
1360 warnings: sanitizer_warnings,
1361 notices,
1362 })
1363}
1364
1365pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
1370 fn load(cwd: &std::path::Path) -> Result<Config> {
1371 let config_path = get_config_path()?;
1372 let mut user_table = read_config_table(&config_path)?;
1373 migrate_legacy_max_tokens(&mut user_table);
1374 migrate_legacy_model_profiles(&mut user_table);
1375 let _ = take_profiles(&mut user_table);
1376 let base_safety = finalize_config(user_table.clone())?.0.safety;
1377 let mut layers = vec![LayerSource {
1378 layer: ConfigLayer::User,
1379 origin: config_path.display().to_string(),
1380 table: user_table,
1381 }];
1382 let (layer, _warnings, _notice) =
1383 super::project_config::load_project_layer(cwd, &base_safety);
1384 if let Some(layer) = layer {
1385 layers.push(layer);
1386 }
1387 Ok(merge_layers(layers)?.0)
1388 }
1389 load(cwd).unwrap_or_default()
1390}
1391
1392pub fn load_config_or_warn() -> Config {
1396 load_config().unwrap_or_else(|e| {
1397 eprintln!(
1398 "mermaid: {}",
1399 crate::utils::redact_secrets(&format!("{e:#}"))
1400 );
1401 Config::default()
1402 })
1403}
1404
1405pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
1407 if !path.exists() {
1408 return Ok(toml::Table::new());
1409 }
1410 let raw = std::fs::read_to_string(path)
1411 .with_context(|| format!("Failed to read {}", path.display()))?;
1412 toml::from_str::<toml::Table>(&raw).with_context(|| {
1413 format!(
1414 "Failed to parse {}. Run 'mermaid init' to regenerate.",
1415 path.display()
1416 )
1417 })
1418}
1419
1420pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
1424 let mut warnings = Vec::new();
1425 let mut merged = toml::Table::new();
1426 for layer in layers {
1427 collect_layer_warnings(&layer, &mut warnings);
1428 deep_merge(&mut merged, layer.table);
1429 }
1430 let (config, _) = finalize_config(merged)?;
1431 Ok((config, warnings))
1432}
1433
1434fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
1440 let mut ignored = Vec::new();
1441 let result: Result<Config, _> =
1442 serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
1443 ignored.push(path.to_string())
1444 });
1445 if result.is_ok() {
1446 for path in ignored {
1447 warnings.push(format!(
1448 "unknown config key '{path}' in {} ({}) — check for a typo",
1449 layer.layer.name(),
1450 layer.origin
1451 ));
1452 }
1453 }
1454}
1455
1456fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
1461 for (key, value) in overlay {
1462 match (base.get_mut(&key), value) {
1463 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
1464 deep_merge(base_table, overlay_table);
1465 },
1466 (_, value) => {
1467 base.insert(key, value);
1468 },
1469 }
1470 }
1471}
1472
1473fn migrate_legacy_max_tokens(table: &mut toml::Table) {
1482 if let Some(dm) = table
1483 .get_mut("default_model")
1484 .and_then(|v| v.as_table_mut())
1485 && dm.get("max_tokens").and_then(|v| v.as_integer())
1486 == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
1487 {
1488 dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
1489 }
1490}
1491
1492fn migrate_legacy_model_profiles(table: &mut toml::Table) {
1498 if table.contains_key("model_aliases") {
1499 table.remove("model_profiles");
1500 return;
1501 }
1502 if let Some(profiles) = table.remove("model_profiles") {
1503 table.insert("model_aliases".to_string(), profiles);
1504 }
1505}
1506
1507fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
1511 let mut ignored = Vec::new();
1512 let mut config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
1513 ignored.push(path.to_string());
1514 })
1515 .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
1516 if config.safety.mode.is_planning() {
1523 config.safety.mode = SafetyConfig::default().mode;
1524 ignored.push(
1525 "safety.mode (plan is entered with /plan or Shift+Tab, not configured)".to_string(),
1526 );
1527 }
1528 Ok((config, ignored))
1529}
1530
1531fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
1535 for raw in overrides {
1536 let (key, val) = raw
1537 .split_once('=')
1538 .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
1539 let key = key.trim();
1540 if key.is_empty() {
1541 anyhow::bail!("invalid -c override '{raw}' (empty key)");
1542 }
1543 deep_set(table, key, parse_override_value(val.trim()))?;
1544 }
1545 Ok(())
1546}
1547
1548fn parse_override_value(s: &str) -> toml::Value {
1551 toml::from_str::<toml::Table>(&format!("x = {s}"))
1552 .ok()
1553 .and_then(|t| t.get("x").cloned())
1554 .unwrap_or_else(|| toml::Value::String(s.to_string()))
1555}
1556
1557fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
1563 let parts: Vec<&str> = key.split('.').collect();
1564 deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
1565}
1566
1567fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
1571 let Some((leaf, parents)) = path.split_last() else {
1572 anyhow::bail!("empty config key path");
1573 };
1574 let mut cur = table;
1575 for part in parents {
1576 let next = cur
1577 .entry((*part).to_string())
1578 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1579 cur = next
1580 .as_table_mut()
1581 .with_context(|| format!("'{part}' is not a table"))?;
1582 }
1583 cur.insert((*leaf).to_string(), value);
1584 Ok(())
1585}
1586
1587pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
1591 let Some((leaf, parents)) = path.split_last() else {
1592 return false;
1593 };
1594 let mut cur = table;
1595 for part in parents {
1596 match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
1597 Some(next) => cur = next,
1598 None => return false,
1599 }
1600 }
1601 cur.remove(*leaf).is_some()
1602}
1603
1604pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
1610 match load_layered_config(cwd, flags) {
1611 Ok(load) => {
1612 for notice in &load.notices {
1613 eprintln!("mermaid: {notice}");
1614 }
1615 for warning in &load.warnings {
1616 eprintln!("mermaid: warning: {warning}");
1617 }
1618 load.config
1619 },
1620 Err(e) => {
1621 eprintln!(
1625 "mermaid: {}",
1626 crate::utils::redact_secrets(&format!("{e:#}"))
1627 );
1628 flags
1629 .to_table()
1630 .ok()
1631 .and_then(|table| finalize_config(table).ok())
1632 .map(|(config, _)| config)
1633 .unwrap_or_default()
1634 },
1635 }
1636}
1637
1638pub fn get_config_path() -> Result<PathBuf> {
1640 Ok(get_config_dir()?.join("config.toml"))
1641}
1642
1643pub fn get_config_dir() -> Result<PathBuf> {
1645 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1646 let config_dir = proj_dirs.config_dir();
1647 std::fs::create_dir_all(config_dir)?;
1648 Ok(config_dir.to_path_buf())
1649 } else {
1650 let home = std::env::var("HOME")
1652 .or_else(|_| std::env::var("USERPROFILE"))
1653 .context("Could not determine home directory")?;
1654 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
1655 std::fs::create_dir_all(&config_dir)?;
1656 Ok(config_dir)
1657 }
1658}
1659
1660fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
1667 let path = if let Some(p) = path {
1668 p
1669 } else {
1670 get_config_dir()?.join("config.toml")
1671 };
1672 write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
1673}
1674
1675fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1686 #[cfg(unix)]
1687 crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
1688 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1689 #[cfg(not(unix))]
1690 crate::runtime::write_atomic(path, bytes)
1691 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1692 Ok(())
1693}
1694
1695pub fn init_config() -> Result<()> {
1697 let config_file = get_config_path()?;
1698
1699 if config_file.exists() {
1700 println!("Configuration already exists at: {}", config_file.display());
1701 } else {
1702 let default_config = Config::default();
1703 save_config(&default_config, Some(config_file.clone()))?;
1704 println!("Created configuration at: {}", config_file.display());
1705 }
1706
1707 Ok(())
1708}
1709
1710static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1716
1717fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
1725 update_user_config_table_at(&get_config_path()?, mutate)
1726}
1727
1728fn update_user_config_table_at(
1730 path: &std::path::Path,
1731 mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
1732) -> Result<()> {
1733 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1734 let mut table = read_config_table(path)?;
1735 migrate_legacy_max_tokens(&mut table);
1737 migrate_legacy_model_profiles(&mut table);
1738 mutate(&mut table)?;
1739 write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
1740}
1741
1742pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
1746 update_user_config_table(|table| deep_set_segments(table, path, value))
1747}
1748
1749pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
1754 update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
1755}
1756
1757pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
1760 let mut removed = false;
1761 update_user_config_table(|table| {
1762 removed = deep_remove_segments(table, path);
1763 Ok(())
1764 })?;
1765 Ok(removed)
1766}
1767
1768pub fn persist_last_model(model: &str) -> Result<()> {
1770 update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
1771}
1772
1773pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
1775 update_user_config_key(
1776 &["ui", "theme"],
1777 toml::Value::String(theme.as_str().to_string()),
1778 )
1779}
1780
1781pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
1784 update_user_config_key(
1785 &["default_model", "reasoning"],
1786 toml::Value::try_from(level)?,
1787 )
1788}
1789
1790pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
1796 update_user_config_key(
1797 &["reasoning_per_model", model_id],
1798 toml::Value::try_from(level)?,
1799 )
1800}
1801
1802pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
1805 match num_ctx {
1806 Some(n) => update_user_config_key(
1807 &["ollama_num_ctx_per_model", model_id],
1808 toml::Value::Integer(i64::from(n)),
1809 ),
1810 None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
1811 }
1812}
1813
1814pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
1816 update_user_config_key(
1817 &["ollama", "allow_ram_offload"],
1818 toml::Value::Boolean(enabled),
1819 )
1820}
1821
1822pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
1825 if let Some(model) = cli_model {
1826 if let Some(resolved) = resolve_model_alias(model, config)? {
1827 return Ok(resolved);
1828 }
1829 return Ok(model.to_string());
1830 }
1831 if let Some(last_model) = &config.last_used_model {
1832 if let Some(resolved) = resolve_model_alias(last_model, config)? {
1833 return Ok(resolved);
1834 }
1835 return Ok(last_model.clone());
1836 }
1837 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1838 return Ok(format!(
1839 "{}/{}",
1840 config.default_model.provider, config.default_model.name
1841 ));
1842 }
1843 let local = crate::ollama::local_models(config).await;
1849 if let Some(first) = local.as_ref().and_then(|models| models.first()) {
1850 return Ok(format!("ollama/{}", first));
1851 }
1852 if let Some(model_id) = configured_provider_default_model(config) {
1853 return Ok(model_id);
1854 }
1855 Err(no_model_configured_error(config, local.is_some()))
1856}
1857
1858fn configured_provider_default_model(config: &Config) -> Option<String> {
1863 for provider in crate::providers::configured_remote_providers(config) {
1864 let model = config
1865 .providers
1866 .get(&provider.name)
1867 .and_then(|entry| entry.default_model.as_deref())
1868 .map(str::trim)
1869 .filter(|model| !model.is_empty());
1870 let Some(model) = model else { continue };
1871 if model.starts_with(&format!("{}/", provider.name)) {
1875 return Some(model.to_string());
1876 }
1877 return Some(format!("{}/{}", provider.name, model));
1878 }
1879 None
1880}
1881
1882fn no_model_configured_error(config: &Config, ollama_installed: bool) -> anyhow::Error {
1888 let providers = crate::providers::configured_remote_providers(config);
1889 let mut lines = vec!["No model configured yet.".to_string(), String::new()];
1890
1891 if let Some(first) = providers.first() {
1892 let names: Vec<&str> = providers.iter().map(|p| p.name.as_str()).collect();
1893 lines.push(format!("Remote providers ready: {}", names.join(", ")));
1894 lines.push("Name a model to use one, e.g.:".to_string());
1895 lines.push(format!(" mermaid --model {}/<model>", first.name));
1896 lines.push(
1897 "Mermaid remembers the last model you used, so --model is a one-time step; \
1898 `mermaid list` shows what is available."
1899 .to_string(),
1900 );
1901 lines.push(String::new());
1902 lines.push("Or pin one in config.toml:".to_string());
1903 lines.push(format!(" [providers.{}]", first.name));
1904 lines.push(" default_model = \"<model>\"".to_string());
1905 } else {
1906 lines.push(
1907 "For a remote model, set a provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY,"
1908 .to_string(),
1909 );
1910 lines.push("GOOGLE_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, …) and name a".to_string());
1911 lines.push("model: mermaid --model anthropic/<model>".to_string());
1912 }
1913
1914 lines.push(String::new());
1915 if ollama_installed {
1916 lines.push("For a local model, pull one first: ollama pull qwen3:8b".to_string());
1917 } else {
1918 lines.push(
1919 "For local models, install Ollama (https://ollama.com/download), then: \
1920 ollama pull qwen3:8b"
1921 .to_string(),
1922 );
1923 }
1924 lines.push("`mermaid doctor` reports what is and isn't ready.".to_string());
1925
1926 anyhow::anyhow!(lines.join("\n"))
1927}
1928
1929fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
1930 let alias = requested.strip_prefix("alias:").unwrap_or(requested);
1931 if let Some(model) = config.model_aliases.get(alias) {
1932 anyhow::ensure!(
1933 !model.trim().is_empty(),
1934 "model alias `{}` is configured with an empty model id",
1935 alias
1936 );
1937 return Ok(Some(model.clone()));
1938 }
1939 if requested.starts_with("alias:") {
1940 anyhow::bail!(
1941 "model alias `{}` is not configured; add it under [model_aliases]",
1942 alias
1943 );
1944 }
1945 Ok(None)
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950 use super::*;
1951
1952 #[test]
1953 fn legacy_default_max_tokens_migrates_to_auto() {
1954 let mut table: toml::Table =
1956 toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
1957 migrate_legacy_max_tokens(&mut table);
1958 migrate_legacy_model_profiles(&mut table);
1959 let (config, _) = finalize_config(table).unwrap();
1960 assert_eq!(config.default_model.max_tokens, 0);
1961
1962 let mut table: toml::Table =
1964 toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
1965 migrate_legacy_max_tokens(&mut table);
1966 migrate_legacy_model_profiles(&mut table);
1967 let (config, _) = finalize_config(table).unwrap();
1968 assert_eq!(config.default_model.max_tokens, 8192);
1969
1970 let mut table = toml::Table::new();
1972 migrate_legacy_max_tokens(&mut table);
1973 migrate_legacy_model_profiles(&mut table);
1974 let (config, _) = finalize_config(table).unwrap();
1975 assert_eq!(config.default_model.max_tokens, 0);
1976 }
1977
1978 #[test]
1979 fn legacy_model_profiles_table_migrates_to_model_aliases() {
1980 let mut table: toml::Table =
1982 toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
1983 migrate_legacy_model_profiles(&mut table);
1984 let (config, ignored) = finalize_config(table).unwrap();
1985 assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
1986 assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
1987 let mut table: toml::Table =
1989 toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
1990 .unwrap();
1991 migrate_legacy_model_profiles(&mut table);
1992 let (config, ignored) = finalize_config(table).unwrap();
1993 assert_eq!(config.model_aliases["fast"], "new");
1994 assert!(ignored.is_empty());
1995 let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
1997 std::fs::create_dir_all(&dir).unwrap();
1998 let path = dir.join("config.toml");
1999 std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
2000 update_user_config_table_at(&path, |_| Ok(())).unwrap();
2001 let blob = std::fs::read_to_string(&path).unwrap();
2002 assert!(blob.contains("[model_aliases]"), "{blob}");
2003 assert!(!blob.contains("model_profiles"), "{blob}");
2004 let _ = std::fs::remove_dir_all(&dir);
2005 }
2006
2007 #[test]
2008 fn ui_theme_deserializes_defaults_and_rejects_typos() {
2009 let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
2010 assert_eq!(config.ui.theme, ThemeChoice::Light);
2011 let config: Config = toml::from_str("").unwrap();
2013 assert_eq!(config.ui.theme, ThemeChoice::Dark);
2014 assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
2015 assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
2017 }
2018
2019 #[test]
2020 fn finalize_config_flags_unknown_keys() {
2021 let table: toml::Table =
2022 toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
2023 .unwrap();
2024 let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
2025 assert_eq!(config.default_model.max_tokens, 512);
2026 assert!(
2027 ignored.iter().any(|p| p == "unknown_top"),
2028 "got {ignored:?}"
2029 );
2030 assert!(
2031 ignored.iter().any(|p| p.contains("bogus")),
2032 "got {ignored:?}"
2033 );
2034 }
2035
2036 #[test]
2037 fn cli_overrides_beat_file_and_create_nested_tables() {
2038 let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
2040 apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
2041 let (config, ignored) = finalize_config(table).unwrap();
2042 assert_eq!(config.default_model.max_tokens, 8192);
2043 assert!(ignored.is_empty());
2044 let mut empty = toml::Table::new();
2046 apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
2047 assert_eq!(
2048 finalize_config(empty).unwrap().0.default_model.max_tokens,
2049 256
2050 );
2051 }
2052
2053 #[test]
2054 fn parse_override_value_keeps_toml_types_with_string_fallback() {
2055 assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
2056 assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
2057 assert_eq!(
2058 parse_override_value("ollama/qwen"),
2059 toml::Value::String("ollama/qwen".to_string())
2060 );
2061 }
2062
2063 #[test]
2064 fn cli_override_invalid_format_errors() {
2065 let mut table = toml::Table::new();
2066 assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
2067 assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
2068 }
2069
2070 #[test]
2071 fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
2072 let mut base: toml::Table = toml::from_str(
2073 "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
2074 )
2075 .unwrap();
2076 let overlay: toml::Table =
2077 toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
2078 .unwrap();
2079 deep_merge(&mut base, overlay);
2080 assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
2082 assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
2084 assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
2086 assert_eq!(base["top"].as_integer(), Some(1));
2088 }
2089
2090 #[test]
2091 fn deep_merge_overlay_wins_on_kind_conflict() {
2092 let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
2094 let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
2095 deep_merge(&mut base, overlay);
2096 assert_eq!(base["a"].as_integer(), Some(5));
2097 assert_eq!(base["b"]["y"].as_integer(), Some(3));
2098 }
2099
2100 #[test]
2101 fn merge_layers_precedence_and_layer_attributed_warnings() {
2102 let user: toml::Table = toml::from_str(
2103 "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
2104 )
2105 .unwrap();
2106 let session: toml::Table =
2107 toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
2108 let (config, warnings) = merge_layers(vec![
2109 LayerSource {
2110 layer: ConfigLayer::User,
2111 origin: "/tmp/user.toml".to_string(),
2112 table: user,
2113 },
2114 LayerSource {
2115 layer: ConfigLayer::Session,
2116 origin: "command line".to_string(),
2117 table: session,
2118 },
2119 ])
2120 .expect("merges");
2121 assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
2123 assert_eq!(config.default_model.max_tokens, 100);
2124 assert!(
2126 warnings
2127 .iter()
2128 .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
2129 "got {warnings:?}"
2130 );
2131 assert!(
2132 warnings
2133 .iter()
2134 .any(|w| w.contains("session_typo") && w.contains("session flags")),
2135 "got {warnings:?}"
2136 );
2137 }
2138
2139 #[test]
2140 fn take_profiles_excises_and_tolerates_absence() {
2141 let mut table: toml::Table =
2142 toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
2143 let profiles = take_profiles(&mut table);
2144 assert!(table.is_empty(), "profiles must be excised: {table:?}");
2145 assert!(profiles.contains_key("fast"));
2146 let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
2148 assert!(take_profiles(&mut table).is_empty());
2149 assert_eq!(table.len(), 1);
2150 let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
2152 assert!(take_profiles(&mut table).is_empty());
2153 assert!(table.is_empty());
2154 }
2155
2156 #[test]
2157 fn resolve_profile_layer_errors_name_available_profiles() {
2158 let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
2159 let path = std::path::Path::new("/tmp/config.toml");
2160 let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
2161 assert!(err.to_string().contains("available: fast, work"), "{err}");
2162 let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
2164 assert!(
2165 err.to_string().contains("no config profiles defined"),
2166 "{err}"
2167 );
2168 let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
2170 let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
2171 assert!(err.to_string().contains("not a table"), "{err}");
2172 let profiles: toml::Table =
2174 toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
2175 let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
2176 assert_eq!(layer.layer, ConfigLayer::Profile);
2177 assert!(layer.origin.contains("profile:work"));
2178 }
2179
2180 #[test]
2181 fn profile_layer_beats_user_loses_to_project_and_session() {
2182 let user: toml::Table = toml::from_str(
2183 "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
2184 )
2185 .unwrap();
2186 let profile: toml::Table = toml::from_str(
2187 "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
2188 )
2189 .unwrap();
2190 let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
2191 let session: toml::Table =
2192 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2193 let (config, warnings) = merge_layers(vec![
2194 LayerSource {
2195 layer: ConfigLayer::User,
2196 origin: "/tmp/user.toml".to_string(),
2197 table: user,
2198 },
2199 LayerSource {
2200 layer: ConfigLayer::Profile,
2201 origin: "profile:work (/tmp/user.toml)".to_string(),
2202 table: profile,
2203 },
2204 LayerSource {
2205 layer: ConfigLayer::Project,
2206 origin: "/repo/.mermaid/config.toml".to_string(),
2207 table: project,
2208 },
2209 LayerSource {
2210 layer: ConfigLayer::Session,
2211 origin: "command line".to_string(),
2212 table: session,
2213 },
2214 ])
2215 .expect("merges");
2216 assert_eq!(config.default_model.temperature, 0.5);
2219 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2220 assert_eq!(config.default_model.max_tokens, 100);
2221 assert!(
2223 warnings.iter().any(|w| w.contains("profile_typo")
2224 && w.contains("config profile (profile:work (/tmp/user.toml))")),
2225 "got {warnings:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn persists_never_touch_profile_tables() {
2231 let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
2232 std::fs::create_dir_all(&dir).expect("create temp dir");
2233 let path = dir.join("config.toml");
2234 std::fs::write(
2235 &path,
2236 "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
2237 )
2238 .expect("seed");
2239
2240 update_user_config_table_at(&path, |table| {
2241 deep_set_segments(
2242 table,
2243 &["safety", "mode"],
2244 toml::Value::String("auto".to_string()),
2245 )
2246 })
2247 .expect("persist");
2248
2249 let table: toml::Table =
2250 toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
2251 assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
2252 assert_eq!(
2254 table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
2255 Some(0.1)
2256 );
2257 let _ = std::fs::remove_dir_all(&dir);
2258 }
2259
2260 #[test]
2261 fn session_flags_table_maps_each_flag() {
2262 let flags = SessionFlags {
2263 overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
2264 deny_network: true,
2265 confine_fs: true,
2266 max_tokens: Some(512),
2267 allow_untrusted_tools: true,
2268 profile: None,
2269 };
2270 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2271 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2272 assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
2273 assert_eq!(config.default_model.max_tokens, 512);
2274 assert!(config.safety.allow_untrusted_headless_tools);
2275 assert_eq!(config.web.searxng_url, "http://x:1");
2276 }
2277
2278 #[test]
2279 fn session_dedicated_flags_beat_dash_c() {
2280 let flags = SessionFlags {
2283 overrides: vec!["safety.network=allow".to_string()],
2284 deny_network: true,
2285 ..Default::default()
2286 };
2287 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2288 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2289 }
2290
2291 #[test]
2292 fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
2293 let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
2295 let mut warnings = Vec::new();
2296 collect_layer_warnings(
2297 &LayerSource {
2298 layer: ConfigLayer::User,
2299 origin: "x".to_string(),
2300 table: bad.clone(),
2301 },
2302 &mut warnings,
2303 );
2304 assert!(warnings.is_empty());
2305 assert!(
2307 merge_layers(vec![LayerSource {
2308 layer: ConfigLayer::User,
2309 origin: "x".to_string(),
2310 table: bad.clone(),
2311 }])
2312 .is_err()
2313 );
2314 let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
2316 let (config, _) = merge_layers(vec![
2317 LayerSource {
2318 layer: ConfigLayer::User,
2319 origin: "x".to_string(),
2320 table: bad,
2321 },
2322 LayerSource {
2323 layer: ConfigLayer::Session,
2324 origin: "command line".to_string(),
2325 table: fix,
2326 },
2327 ])
2328 .expect("later layer repairs the earlier one");
2329 assert_eq!(config.safety.mode, SafetyMode::Ask);
2330 }
2331
2332 #[test]
2333 fn project_layer_beats_user_and_loses_to_session() {
2334 let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
2335 let project: toml::Table = toml::from_str(
2336 "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
2337 )
2338 .unwrap();
2339 let session: toml::Table =
2340 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2341 let (config, _) = merge_layers(vec![
2342 LayerSource {
2343 layer: ConfigLayer::User,
2344 origin: "user".to_string(),
2345 table: user,
2346 },
2347 LayerSource {
2348 layer: ConfigLayer::Project,
2349 origin: "project".to_string(),
2350 table: project,
2351 },
2352 LayerSource {
2353 layer: ConfigLayer::Session,
2354 origin: "command line".to_string(),
2355 table: session,
2356 },
2357 ])
2358 .expect("merges");
2359 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2361 assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
2363 }
2364
2365 #[test]
2366 fn session_flags_survive_corrupt_user_layer_fallback() {
2367 let flags = SessionFlags {
2370 deny_network: true,
2371 ..Default::default()
2372 };
2373 let config = flags
2374 .to_table()
2375 .ok()
2376 .and_then(|table| finalize_config(table).ok())
2377 .map(|(config, _)| config)
2378 .unwrap_or_default();
2379 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2380 }
2381
2382 #[test]
2383 fn deep_set_segments_addresses_keys_containing_dots() {
2384 let mut table = toml::Table::new();
2387 deep_set_segments(
2388 &mut table,
2389 &["reasoning_per_model", "gemini/gemini-2.5-pro"],
2390 toml::Value::String("high".to_string()),
2391 )
2392 .unwrap();
2393 let (config, ignored) = finalize_config(table).unwrap();
2394 assert!(ignored.is_empty(), "got {ignored:?}");
2395 assert_eq!(
2396 config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
2397 Some(&ReasoningLevel::High)
2398 );
2399 }
2400
2401 #[test]
2402 fn deep_remove_segments_removes_leaf_only() {
2403 let mut table: toml::Table =
2404 toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
2405 .unwrap();
2406 assert!(deep_remove_segments(
2407 &mut table,
2408 &["ollama_num_ctx_per_model", "ollama/a"]
2409 ));
2410 assert_eq!(
2412 table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
2413 Some(2)
2414 );
2415 assert!(!deep_remove_segments(
2416 &mut table,
2417 &["ollama_num_ctx_per_model", "ollama/a"]
2418 ));
2419 assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
2420 }
2421
2422 #[test]
2423 fn update_user_config_table_preserves_unknown_keys() {
2424 let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
2425 std::fs::create_dir_all(&dir).expect("create temp dir");
2426 let path = dir.join("config.toml");
2427 std::fs::write(
2430 &path,
2431 "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
2432 )
2433 .expect("seed");
2434
2435 update_user_config_table_at(&path, |table| {
2436 deep_set_segments(
2437 table,
2438 &["last_used_model"],
2439 toml::Value::String("ollama/new".to_string()),
2440 )
2441 })
2442 .expect("persist");
2443
2444 let blob = std::fs::read_to_string(&path).expect("read back");
2445 let table: toml::Table = toml::from_str(&blob).expect("parse back");
2446 assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
2448 assert_eq!(table["future_key"].as_str(), Some("kept"));
2450 assert!(!blob.contains("safety"), "defaults must not be frozen in");
2452 assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
2453
2454 let _ = std::fs::remove_dir_all(&dir);
2455 }
2456
2457 #[test]
2458 fn mcp_tool_allowed_honors_enabled_and_disabled() {
2459 let cfg = McpServerConfig::default();
2461 assert!(cfg.tool_allowed("anything"));
2462 let cfg = McpServerConfig {
2464 enabled_tools: vec!["read".into(), "search".into()],
2465 ..Default::default()
2466 };
2467 assert!(cfg.tool_allowed("read"));
2468 assert!(!cfg.tool_allowed("write"));
2469 let cfg = McpServerConfig {
2471 enabled_tools: vec!["read".into(), "write".into()],
2472 disabled_tools: vec!["write".into()],
2473 ..Default::default()
2474 };
2475 assert!(cfg.tool_allowed("read"));
2476 assert!(!cfg.tool_allowed("write"));
2477 }
2478
2479 #[test]
2480 fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
2481 let cfg = McpServerConfig {
2483 command: "npx".to_string(),
2484 ..Default::default()
2485 };
2486 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
2487 let cfg = McpServerConfig {
2489 url: Some("https://example.com/mcp".to_string()),
2490 ..Default::default()
2491 };
2492 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
2493 let cfg = McpServerConfig {
2495 command: "npx".to_string(),
2496 url: Some("https://example.com/mcp".to_string()),
2497 ..Default::default()
2498 };
2499 assert!(
2500 cfg.transport_kind()
2501 .unwrap_err()
2502 .to_string()
2503 .contains("mutually exclusive")
2504 );
2505 let cfg = McpServerConfig::default();
2507 assert!(
2508 cfg.transport_kind()
2509 .unwrap_err()
2510 .to_string()
2511 .contains("neither")
2512 );
2513 }
2514
2515 #[test]
2516 fn mcp_transport_kind_gates_url_scheme() {
2517 let with_url = |url: &str| McpServerConfig {
2518 url: Some(url.to_string()),
2519 ..Default::default()
2520 };
2521 assert!(
2524 with_url("https://mcp.example.com/x")
2525 .transport_kind()
2526 .is_ok()
2527 );
2528 assert!(
2529 with_url("http://localhost:8080/mcp")
2530 .transport_kind()
2531 .is_ok()
2532 );
2533 assert!(
2534 with_url("http://127.0.0.1:8080/mcp")
2535 .transport_kind()
2536 .is_ok()
2537 );
2538 assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
2539 assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
2540 assert!(with_url("not a url").transport_kind().is_err());
2541 }
2542
2543 #[test]
2544 fn mcp_server_config_debug_masks_header_values() {
2545 let mut headers = HashMap::new();
2546 headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
2547 let mut env_headers = HashMap::new();
2548 env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
2549 let cfg = McpServerConfig {
2550 url: Some("https://example.com/mcp".to_string()),
2551 headers,
2552 env_headers,
2553 ..Default::default()
2554 };
2555 let rendered = format!("{cfg:?}");
2556 assert!(!rendered.contains("sk-secret"), "{rendered}");
2557 assert!(rendered.contains("Authorization"), "{rendered}");
2558 assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
2560 }
2561
2562 #[test]
2563 fn mcp_url_config_round_trips_through_toml_without_command() {
2564 let cfg = McpServerConfig {
2567 url: Some("https://example.com/mcp".to_string()),
2568 ..Default::default()
2569 };
2570 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2571 assert!(
2572 !blob.contains("command"),
2573 "empty command must be omitted: {blob}"
2574 );
2575 let back: McpServerConfig = toml::from_str(&blob).unwrap();
2576 assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
2577 assert!(back.command.is_empty());
2578 let cfg = McpServerConfig {
2580 command: "npx".to_string(),
2581 ..Default::default()
2582 };
2583 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2584 assert!(!blob.contains("url"), "{blob}");
2585 }
2586
2587 #[test]
2592 fn model_settings_deserializes_without_reasoning_field() {
2593 let toml_blob = r#"
2594 provider = "ollama"
2595 name = "qwen3-coder:30b"
2596 temperature = 0.7
2597 max_tokens = 4096
2598 "#;
2599 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
2600 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
2601 assert_eq!(settings.provider, "ollama");
2602 }
2603
2604 #[test]
2605 fn model_settings_round_trips_reasoning_high() {
2606 let original = ModelSettings {
2607 provider: "anthropic".to_string(),
2608 name: "claude-sonnet-4-6".to_string(),
2609 temperature: 0.5,
2610 max_tokens: 8192,
2611 reasoning: ReasoningLevel::High,
2612 };
2613 let toml_blob = toml::to_string(&original).expect("serialize");
2614 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
2615 assert_eq!(back.reasoning, ReasoningLevel::High);
2616 assert_eq!(back.name, "claude-sonnet-4-6");
2617 }
2618
2619 #[test]
2620 fn agents_config_defaults_and_parses_custom_types() {
2621 let config: Config = toml::from_str("").expect("empty config parses");
2623 assert_eq!(config.agents.timeout_secs, 1200);
2624 assert!(config.agents.types.is_empty());
2625
2626 let config: Config = toml::from_str(
2627 r#"
2628[agents]
2629timeout_secs = 300
2630
2631[agents.types.scout]
2632tools = ["read_file", "execute_command"]
2633safety = "read_only"
2634preamble = "You are a scout."
2635model = "ollama/qwen3:8b"
2636"#,
2637 )
2638 .expect("agents section parses");
2639 assert_eq!(config.agents.timeout_secs, 300);
2640 let scout = &config.agents.types["scout"];
2641 assert_eq!(
2642 scout.tools.as_deref(),
2643 Some(&["read_file".to_string(), "execute_command".to_string()][..])
2644 );
2645 assert_eq!(scout.safety.as_deref(), Some("read_only"));
2646 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
2647 }
2648
2649 #[test]
2650 fn configured_model_alias_resolves_explicit_prefix() {
2651 let mut config = Config::default();
2652 config
2653 .model_aliases
2654 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
2655 assert_eq!(
2656 resolve_model_alias("fast", &config).unwrap(),
2657 Some("ollama/qwen3-coder:14b".to_string())
2658 );
2659 assert_eq!(
2660 resolve_model_alias("alias:fast", &config).unwrap(),
2661 Some("ollama/qwen3-coder:14b".to_string())
2662 );
2663 }
2664
2665 #[test]
2666 fn alias_prefix_requires_configuration() {
2667 let config = Config::default();
2668 assert!(resolve_model_alias("alias:vision", &config).is_err());
2669 assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
2670 }
2671
2672 #[test]
2679 fn save_and_reload_preserves_reasoning_field() {
2680 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
2681 std::fs::create_dir_all(&dir).expect("create temp dir");
2682 let path = dir.join("config.toml");
2683
2684 let mut cfg = Config::default();
2685 cfg.default_model.provider = "ollama".to_string();
2686 cfg.default_model.name = "qwen3-coder:30b".to_string();
2687 cfg.default_model.reasoning = ReasoningLevel::Low;
2688
2689 save_config(&cfg, Some(path.clone())).expect("save");
2690
2691 let blob = std::fs::read_to_string(&path).expect("read");
2692 let loaded: Config = toml::from_str(&blob).expect("parse back");
2693 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
2694
2695 let _ = std::fs::remove_dir_all(&dir);
2696 }
2697
2698 #[test]
2703 fn save_and_reload_preserves_reasoning_per_model_table() {
2704 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
2705 std::fs::create_dir_all(&dir).expect("create temp dir");
2706 let path = dir.join("config.toml");
2707
2708 let mut cfg = Config::default();
2709 cfg.reasoning_per_model.insert(
2710 "anthropic/claude-sonnet-4-6".to_string(),
2711 ReasoningLevel::High,
2712 );
2713 cfg.reasoning_per_model
2714 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
2715
2716 save_config(&cfg, Some(path.clone())).expect("save");
2717
2718 let blob = std::fs::read_to_string(&path).expect("read");
2719 let loaded: Config = toml::from_str(&blob).expect("parse back");
2720 assert_eq!(
2721 loaded
2722 .reasoning_per_model
2723 .get("anthropic/claude-sonnet-4-6"),
2724 Some(&ReasoningLevel::High)
2725 );
2726 assert_eq!(
2727 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
2728 Some(&ReasoningLevel::Low)
2729 );
2730
2731 let _ = std::fs::remove_dir_all(&dir);
2732 }
2733
2734 #[test]
2737 fn save_and_reload_preserves_ollama_context_overrides() {
2738 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
2739 std::fs::create_dir_all(&dir).expect("create temp dir");
2740 let path = dir.join("config.toml");
2741
2742 let mut cfg = Config::default();
2743 cfg.ollama_num_ctx_per_model
2744 .insert("ollama/ornith:9b".to_string(), 131_072);
2745 cfg.ollama.allow_ram_offload = true;
2746 cfg.ollama.max_auto_num_ctx = Some(65_536);
2747
2748 save_config(&cfg, Some(path.clone())).expect("save");
2749 let blob = std::fs::read_to_string(&path).expect("read");
2750 let loaded: Config = toml::from_str(&blob).expect("parse back");
2751
2752 assert_eq!(
2753 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
2754 Some(&131_072)
2755 );
2756 assert!(loaded.ollama.allow_ram_offload);
2757 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
2758
2759 let _ = std::fs::remove_dir_all(&dir);
2760 }
2761
2762 #[test]
2765 fn config_deserializes_without_ollama_context_keys() {
2766 let toml_blob = r#"
2767[ollama]
2768host = "localhost"
2769port = 11434
2770"#;
2771 let cfg: Config = toml::from_str(toml_blob).expect("parse");
2772 assert!(cfg.ollama_num_ctx_per_model.is_empty());
2773 assert!(!cfg.ollama.allow_ram_offload);
2774 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
2775 assert!(cfg.ollama.auto_start);
2778 }
2779
2780 #[test]
2784 fn config_deserializes_without_reasoning_per_model() {
2785 let toml_blob = r#"
2786 last_used_model = "ollama/qwen3-coder:30b"
2787
2788 [default_model]
2789 provider = "ollama"
2790 name = "qwen3-coder:30b"
2791 temperature = 0.7
2792 max_tokens = 4096
2793 "#;
2794 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
2795 assert!(cfg.reasoning_per_model.is_empty());
2796 assert!(!cfg.prompt.is_customized());
2797 }
2798
2799 #[cfg(unix)]
2803 #[test]
2804 fn save_config_writes_owner_only_perms() {
2805 use std::os::unix::fs::PermissionsExt;
2806 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
2807 std::fs::create_dir_all(&dir).expect("create temp dir");
2808 let path = dir.join("config.toml");
2809 std::fs::write(&path, "stale").expect("seed");
2811 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
2812
2813 save_config(&Config::default(), Some(path.clone())).expect("save");
2814 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2815 assert_eq!(mode, 0o600, "config must be written owner-only");
2816
2817 let _ = std::fs::remove_dir_all(&dir);
2818 }
2819
2820 #[test]
2821 fn config_defaults_computer_use_auto_screenshot_on() {
2822 let cfg: Config = toml::from_str("").expect("empty config");
2824 assert!(cfg.computer_use.auto_screenshot);
2825 }
2826
2827 #[test]
2828 fn prompt_config_replaces_and_appends_without_persisting() {
2829 let mut cfg = Config::default();
2830 cfg.prompt.system_prompt = Some("base".to_string());
2831 cfg.prompt
2832 .append_system_prompt
2833 .push("extra instructions".to_string());
2834
2835 assert_eq!(
2836 cfg.prompt.render_system_prompt("default"),
2837 "base\n\nextra instructions"
2838 );
2839
2840 let blob = toml::to_string(&cfg).expect("serialize");
2841 assert!(!blob.contains("extra instructions"));
2842 let loaded: Config = toml::from_str(&blob).expect("deserialize");
2843 assert!(!loaded.prompt.is_customized());
2844 }
2845
2846 #[test]
2849 fn absent_compaction_section_matches_the_built_in_policy() {
2850 let c: Config = toml::from_str("").expect("empty config parses");
2851 assert_eq!(
2852 c.compaction.policy(),
2853 crate::domain::CompactionPolicy::default(),
2854 );
2855 }
2856
2857 #[test]
2858 fn compaction_settings_reach_the_policy() {
2859 let c: Config = toml::from_str(
2860 "[compaction]\n\
2861 auto_enabled = false\n\
2862 auto_threshold_percent = 60\n\
2863 tail_turns = 5\n\
2864 tail_token_budget = 12000\n\
2865 summary_max_tokens = 3000\n",
2866 )
2867 .expect("compaction section parses");
2868 let policy = c.compaction.policy();
2869 assert!(!policy.auto_enabled);
2870 assert_eq!(policy.auto_threshold_percent, 60);
2871 assert_eq!(policy.tail_turns, 5);
2872 assert_eq!(policy.tail_token_budget, 12_000);
2873 assert_eq!(policy.summary_max_tokens, 3_000);
2874 let defaults = crate::domain::CompactionPolicy::default();
2876 assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2877 }
2878
2879 #[test]
2882 fn nonsense_compaction_settings_are_clamped() {
2883 let c: Config = toml::from_str(
2884 "[compaction]\n\
2885 auto_threshold_percent = 250\n\
2886 tail_turns = 0\n\
2887 tail_token_budget = 0\n\
2888 summary_max_tokens = 0\n\
2889 summarizer_input_token_budget = 0\n\
2890 tool_output_max_chars = 0\n\
2891 min_response_reserve_tokens = 50000\n\
2892 max_response_reserve_tokens = 1000\n",
2893 )
2894 .expect("config parses");
2895 let policy = c.compaction.policy();
2896 let defaults = crate::domain::CompactionPolicy::default();
2897
2898 assert_eq!(policy.auto_threshold_percent, 100, "percent clamps to 100");
2899 assert_eq!(
2900 policy.tail_turns, 1,
2901 "a checkpoint needs a live turn after it"
2902 );
2903 assert_eq!(policy.tail_token_budget, defaults.tail_token_budget);
2905 assert_eq!(policy.summary_max_tokens, defaults.summary_max_tokens);
2906 assert_eq!(
2907 policy.summarizer_input_token_budget,
2908 defaults.summarizer_input_token_budget
2909 );
2910 assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2911
2912 assert_eq!(policy.min_response_reserve_tokens, 1_000);
2916 assert_eq!(policy.max_response_reserve_tokens, 50_000);
2917 assert!(policy.min_response_reserve_tokens <= policy.max_response_reserve_tokens);
2918 }
2919
2920 #[test]
2923 fn zero_compaction_threshold_clamps_up() {
2924 let c: Config =
2925 toml::from_str("[compaction]\nauto_threshold_percent = 0\n").expect("parses");
2926 assert_eq!(c.compaction.policy().auto_threshold_percent, 1);
2927 }
2928
2929 #[test]
2930 fn plan_config_defaults_parse_and_do_not_freeze() {
2931 let c: Config = toml::from_str("").expect("empty config parses");
2933 assert!(!c.plan.auto_approve);
2934 assert!(c.plan.post_approve.is_none());
2935 let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
2937 .expect("plan section parses");
2938 assert!(c.plan.auto_approve);
2939 assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
2940 assert_eq!(
2941 toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
2942 .expect("wait parses")
2943 .plan
2944 .post_approve,
2945 Some(PlanPostApprove::Wait)
2946 );
2947 let blob = toml::to_string(&Config::default()).expect("serialize");
2951 assert!(!blob.contains("post_approve"));
2952 }
2953
2954 fn config_with_provider_default(provider: &str, model: &str) -> Config {
2956 let mut config = Config::default();
2957 config.providers.insert(
2958 provider.to_string(),
2959 UserProviderConfig {
2960 default_model: Some(model.to_string()),
2961 ..Default::default()
2962 },
2963 );
2964 config
2965 }
2966
2967 #[test]
2970 fn provider_default_model_resolves_without_ollama() {
2971 let config = config_with_provider_default("anthropic", "claude-x");
2972 temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
2973 assert_eq!(
2974 configured_provider_default_model(&config).as_deref(),
2975 Some("anthropic/claude-x")
2976 );
2977 });
2978 }
2979
2980 #[test]
2983 fn provider_default_model_ignored_without_a_key() {
2984 let config = config_with_provider_default("anthropic", "claude-x");
2985 temp_env::with_vars([("ANTHROPIC_API_KEY", None::<&str>)], || {
2986 if crate::utils::provider_key_source("anthropic", "ANTHROPIC_API_KEY", None) == "none" {
2989 assert_eq!(configured_provider_default_model(&config), None);
2990 }
2991 });
2992 }
2993
2994 #[test]
2997 fn provider_default_model_is_prefixed_exactly_once() {
2998 temp_env::with_vars([("OPENROUTER_API_KEY", Some("sk-test"))], || {
2999 let vendor_model = config_with_provider_default("openrouter", "z-ai/glm-5.2");
3000 assert_eq!(
3001 configured_provider_default_model(&vendor_model).as_deref(),
3002 Some("openrouter/z-ai/glm-5.2")
3003 );
3004 let already_prefixed =
3005 config_with_provider_default("openrouter", "openrouter/z-ai/glm-5.2");
3006 assert_eq!(
3007 configured_provider_default_model(&already_prefixed).as_deref(),
3008 Some("openrouter/z-ai/glm-5.2")
3009 );
3010 });
3011 }
3012
3013 #[test]
3017 fn missing_model_error_does_not_demand_ollama_when_a_provider_is_ready() {
3018 let config = Config::default();
3019 temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
3020 let msg = no_model_configured_error(&config, false).to_string();
3021 assert!(msg.contains("anthropic"), "{msg}");
3022 assert!(msg.contains("mermaid --model anthropic/<model>"), "{msg}");
3023 assert!(msg.contains("[providers.anthropic]"), "{msg}");
3024 assert!(!msg.contains("Ollama is not installed"), "{msg}");
3027 });
3028 }
3029
3030 fn with_no_provider_keys<T>(f: impl FnOnce() -> T) -> T {
3033 let cleared: Vec<(&str, Option<&str>)> = [
3034 crate::providers::model::anthropic::DEFAULT_API_KEY_ENV,
3035 crate::providers::model::gemini::DEFAULT_API_KEY_ENV,
3036 crate::providers::model::gemini::LEGACY_API_KEY_ENV,
3037 crate::providers::model::meta::DEFAULT_API_KEY_ENV,
3038 ]
3039 .iter()
3040 .map(|env| (*env, None))
3041 .chain(
3042 crate::models::PROVIDER_REGISTRY
3043 .iter()
3044 .map(|profile| (profile.api_key_env, None)),
3045 )
3046 .collect();
3047 temp_env::with_vars(cleared, f)
3048 }
3049
3050 #[test]
3053 fn missing_model_error_offers_both_routes_when_nothing_is_configured() {
3054 with_no_provider_keys(|| {
3055 let msg = no_model_configured_error(&Config::default(), false).to_string();
3056 assert!(msg.contains("https://ollama.com/download"), "{msg}");
3057 if !msg.contains("Remote providers ready") {
3060 assert!(msg.contains("ANTHROPIC_API_KEY"), "{msg}");
3061 }
3062 });
3063 }
3064
3065 #[test]
3069 fn resolve_model_id_falls_back_to_a_configured_provider() {
3070 let mut config = config_with_provider_default("anthropic", "claude-x");
3071 config.ollama.host = "http://127.0.0.1".to_string();
3074 config.ollama.port = 1;
3075 config.ollama.auto_start = false;
3076 temp_env::with_vars([("ANTHROPIC_API_KEY", Some("sk-test"))], || {
3077 let runtime = tokio::runtime::Runtime::new().expect("runtime");
3078 let resolved = runtime
3079 .block_on(resolve_model_id(None, &config))
3080 .expect("a configured provider is enough to resolve a model");
3081 assert_eq!(resolved, "anthropic/claude-x");
3082 });
3083 }
3084
3085 #[test]
3087 fn missing_model_error_says_pull_when_ollama_is_installed() {
3088 let msg = no_model_configured_error(&Config::default(), true).to_string();
3089 assert!(msg.contains("ollama pull qwen3:8b"), "{msg}");
3090 assert!(!msg.contains("https://ollama.com/download"), "{msg}");
3091 }
3092}