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}
744
745#[derive(Clone, Default, Serialize, Deserialize)]
749pub struct UserProviderConfig {
750 #[serde(default)]
753 pub base_url: Option<String>,
754 #[serde(default)]
758 pub api_key_env: Option<String>,
759 #[serde(default)]
761 pub extra_headers: HashMap<String, String>,
762 #[serde(default)]
767 pub env_headers: HashMap<String, String>,
768 #[serde(default)]
774 pub compat: Option<String>,
775 #[serde(default)]
779 pub default_model: Option<String>,
780}
781
782#[derive(Clone, Default, Serialize, Deserialize)]
784pub struct McpServerConfig {
785 #[serde(default, skip_serializing_if = "String::is_empty")]
788 pub command: String,
789 #[serde(default)]
791 pub args: Vec<String>,
792 #[serde(default)]
794 pub env: HashMap<String, String>,
795 #[serde(default, skip_serializing_if = "Option::is_none")]
799 pub url: Option<String>,
800 #[serde(default)]
803 pub headers: HashMap<String, String>,
804 #[serde(default)]
809 pub env_headers: HashMap<String, String>,
810 #[serde(default)]
814 pub allow_private_network: bool,
815 #[serde(default)]
817 pub enabled_tools: Vec<String>,
818 #[serde(default)]
820 pub disabled_tools: Vec<String>,
821 #[serde(default, skip_serializing_if = "Option::is_none")]
826 pub defer: Option<bool>,
827}
828
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
832pub enum TransportKind {
833 Stdio,
834 Http,
835}
836
837impl McpServerConfig {
838 pub fn transport_kind(&self) -> Result<TransportKind> {
843 match (&self.url, self.command.is_empty()) {
844 (Some(_), false) => Err(anyhow::anyhow!(
845 "MCP server config sets both `command` and `url`; they are mutually exclusive"
846 )),
847 (None, true) => Err(anyhow::anyhow!(
848 "MCP server config sets neither `command` nor `url`"
849 )),
850 (None, false) => Ok(TransportKind::Stdio),
851 (Some(url), true) => {
852 let parsed = reqwest::Url::parse(url)
853 .map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
854 let host = parsed.host_str().unwrap_or("");
855 match parsed.scheme() {
856 "https" => Ok(TransportKind::Http),
857 "http" if crate::utils::classify_host(host).is_loopback() => {
858 Ok(TransportKind::Http)
859 },
860 "http" => Err(anyhow::anyhow!(
861 "MCP server url '{url}' uses plaintext http to a non-loopback host; \
862 use https (auth headers would travel in cleartext)"
863 )),
864 other => Err(anyhow::anyhow!(
865 "MCP server url '{url}' has unsupported scheme '{other}' \
866 (expected https, or http to loopback)"
867 )),
868 }
869 },
870 }
871 }
872
873 pub fn tool_allowed(&self, tool_name: &str) -> bool {
877 if self.disabled_tools.iter().any(|t| t == tool_name) {
878 return false;
879 }
880 self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
881 }
882}
883
884fn debug_masked_map(
888 map: &HashMap<String, String>,
889) -> std::collections::BTreeMap<&str, &'static str> {
890 map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
891}
892
893impl std::fmt::Debug for McpServerConfig {
898 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
899 f.debug_struct("McpServerConfig")
900 .field("command", &self.command)
901 .field(
903 "args",
904 &self
905 .args
906 .iter()
907 .map(|a| crate::utils::redact_secrets(a))
908 .collect::<Vec<_>>(),
909 )
910 .field("env", &debug_masked_map(&self.env))
911 .field("url", &self.url)
912 .field("headers", &debug_masked_map(&self.headers))
914 .field("env_headers", &self.env_headers)
916 .field("allow_private_network", &self.allow_private_network)
917 .field("enabled_tools", &self.enabled_tools)
919 .field("disabled_tools", &self.disabled_tools)
920 .finish()
921 }
922}
923
924impl std::fmt::Debug for UserProviderConfig {
925 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
926 f.debug_struct("UserProviderConfig")
927 .field("base_url", &self.base_url)
928 .field("api_key_env", &self.api_key_env)
929 .field("extra_headers", &debug_masked_map(&self.extra_headers))
930 .field("env_headers", &self.env_headers)
932 .field("compat", &self.compat)
933 .field("default_model", &self.default_model)
934 .finish()
935 }
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize)]
940#[serde(default)]
941pub struct ModelSettings {
942 pub provider: String,
944 pub name: String,
946 pub temperature: f32,
948 pub max_tokens: usize,
950 pub reasoning: ReasoningLevel,
954}
955
956impl Default for ModelSettings {
957 fn default() -> Self {
958 Self {
959 provider: String::new(),
960 name: String::new(),
961 temperature: DEFAULT_TEMPERATURE,
962 max_tokens: 0,
966 reasoning: ReasoningLevel::default(),
967 }
968 }
969}
970
971#[derive(Debug, Clone, Serialize, Deserialize)]
973#[serde(default)]
974pub struct OllamaConfig {
975 pub host: String,
977 pub port: u16,
979 pub num_gpu: Option<i32>,
982 pub num_thread: Option<i32>,
985 pub num_ctx: Option<i32>,
988 pub numa: Option<bool>,
990 pub allow_ram_offload: bool,
996 pub max_auto_num_ctx: Option<usize>,
1000 pub auto_start: bool,
1005}
1006
1007impl Default for OllamaConfig {
1008 fn default() -> Self {
1009 Self {
1010 host: String::from("localhost"),
1011 port: DEFAULT_OLLAMA_PORT,
1012 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, auto_start: true, }
1020 }
1021}
1022
1023#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1025#[serde(rename_all = "lowercase")]
1026pub enum FetchBackend {
1027 #[default]
1030 Native,
1031 Ollama,
1033}
1034
1035#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1037#[serde(rename_all = "lowercase")]
1038pub enum SearchBackend {
1039 #[default]
1043 Auto,
1044 Ollama,
1046 Searxng,
1048}
1049
1050#[derive(Debug, Clone, Serialize, Deserialize)]
1059#[serde(default)]
1060pub struct WebConfig {
1061 pub fetch_backend: FetchBackend,
1064 pub search_backend: SearchBackend,
1068 pub searxng_url: String,
1073}
1074
1075impl Default for WebConfig {
1076 fn default() -> Self {
1077 Self {
1078 fetch_backend: FetchBackend::Native,
1079 search_backend: SearchBackend::Auto,
1080 searxng_url: String::from("http://localhost:8080"),
1081 }
1082 }
1083}
1084
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1087#[serde(default)]
1088pub struct NonInteractiveConfig {
1089 pub output_format: String,
1091 pub max_tokens: usize,
1093 pub no_execute: bool,
1095}
1096
1097impl Default for NonInteractiveConfig {
1098 fn default() -> Self {
1099 Self {
1100 output_format: String::from("text"),
1101 max_tokens: 0,
1103 no_execute: false,
1104 }
1105 }
1106}
1107
1108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1112pub enum ConfigLayer {
1113 Defaults = 0,
1116 User = 1,
1119 Profile = 2,
1123 Project = 3,
1126 Session = 4,
1130}
1131
1132impl ConfigLayer {
1133 fn name(self) -> &'static str {
1135 match self {
1136 ConfigLayer::Defaults => "defaults",
1137 ConfigLayer::User => "user config",
1138 ConfigLayer::Profile => "config profile",
1139 ConfigLayer::Project => "project config",
1140 ConfigLayer::Session => "session flags",
1141 }
1142 }
1143}
1144
1145#[derive(Debug, Clone)]
1147pub(crate) struct LayerSource {
1148 pub layer: ConfigLayer,
1150 pub origin: String,
1152 pub table: toml::Table,
1155}
1156
1157#[derive(Debug, Clone, Default)]
1160pub struct SessionFlags {
1161 pub overrides: Vec<String>,
1164 pub deny_network: bool,
1166 pub confine_fs: bool,
1168 pub max_tokens: Option<usize>,
1170 pub allow_untrusted_tools: bool,
1172 pub profile: Option<String>,
1176}
1177
1178impl SessionFlags {
1179 pub(crate) fn to_table(&self) -> Result<toml::Table> {
1183 let mut table = toml::Table::new();
1184 apply_cli_overrides(&mut table, &self.overrides)?;
1185 if self.deny_network {
1186 deep_set_segments(
1187 &mut table,
1188 &["safety", "network"],
1189 toml::Value::String("deny".into()),
1190 )?;
1191 }
1192 if self.confine_fs {
1193 deep_set_segments(
1194 &mut table,
1195 &["safety", "filesystem"],
1196 toml::Value::String("project".into()),
1197 )?;
1198 }
1199 if let Some(n) = self.max_tokens {
1200 deep_set_segments(
1201 &mut table,
1202 &["default_model", "max_tokens"],
1203 toml::Value::Integer(n as i64),
1204 )?;
1205 }
1206 if self.allow_untrusted_tools {
1207 deep_set_segments(
1208 &mut table,
1209 &["safety", "allow_untrusted_headless_tools"],
1210 toml::Value::Boolean(true),
1211 )?;
1212 }
1213 Ok(table)
1214 }
1215}
1216
1217fn take_profiles(table: &mut toml::Table) -> toml::Table {
1224 match table.remove("profiles") {
1225 Some(toml::Value::Table(profiles)) => profiles,
1226 _ => toml::Table::new(),
1229 }
1230}
1231
1232fn resolve_profile_layer(
1236 profiles: &toml::Table,
1237 name: &str,
1238 config_path: &std::path::Path,
1239) -> Result<LayerSource> {
1240 match profiles.get(name) {
1241 Some(toml::Value::Table(overlay)) => Ok(LayerSource {
1242 layer: ConfigLayer::Profile,
1243 origin: format!("profile:{} ({})", name, config_path.display()),
1244 table: overlay.clone(),
1245 }),
1246 Some(_) => anyhow::bail!(
1247 "config profile '{}' is not a table; define it as [profiles.{}] in {}",
1248 name,
1249 name,
1250 config_path.display()
1251 ),
1252 None => {
1253 let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
1254 available.sort_unstable();
1255 if available.is_empty() {
1256 anyhow::bail!(
1257 "no config profiles defined; add [profiles.{}] to {}",
1258 name,
1259 config_path.display()
1260 );
1261 }
1262 anyhow::bail!(
1263 "unknown config profile '{}'; available: {}",
1264 name,
1265 available.join(", ")
1266 )
1267 },
1268 }
1269}
1270
1271pub fn load_config() -> Result<Config> {
1276 let config_path = get_config_path()?;
1277 let mut table = read_config_table(&config_path)?;
1278 migrate_legacy_max_tokens(&mut table);
1279 migrate_legacy_model_profiles(&mut table);
1280 let _ = take_profiles(&mut table);
1281 Ok(finalize_config(table)?.0)
1282}
1283
1284pub struct LayeredLoad {
1287 pub config: Config,
1289 pub warnings: Vec<String>,
1291 pub notices: Vec<String>,
1293}
1294
1295pub fn load_layered_config(
1300 cwd: Option<&std::path::Path>,
1301 flags: &SessionFlags,
1302) -> Result<LayeredLoad> {
1303 let config_path = get_config_path()?;
1304 let mut user_table = read_config_table(&config_path)?;
1305 migrate_legacy_max_tokens(&mut user_table);
1306 migrate_legacy_model_profiles(&mut user_table);
1307 let profiles = take_profiles(&mut user_table);
1310 let mut layers = vec![LayerSource {
1311 layer: ConfigLayer::User,
1312 origin: config_path.display().to_string(),
1313 table: user_table.clone(),
1314 }];
1315 let mut sanitizer_warnings = Vec::new();
1316 let mut notices = Vec::new();
1317 if let Some(name) = flags.profile.as_deref() {
1318 let layer = resolve_profile_layer(&profiles, name, &config_path)?;
1319 notices.push(format!(
1320 "using config profile '{}' (from {})",
1321 name,
1322 config_path.display()
1323 ));
1324 layers.push(layer);
1325 }
1326 if let Some(cwd) = cwd {
1327 let base_safety = finalize_config(user_table)?.0.safety;
1330 let (layer, warnings, notice) =
1331 super::project_config::load_project_layer(cwd, &base_safety);
1332 sanitizer_warnings.extend(warnings);
1333 notices.extend(notice);
1334 if let Some(layer) = layer {
1335 layers.push(layer);
1336 }
1337 }
1338 layers.push(LayerSource {
1339 layer: ConfigLayer::Session,
1340 origin: "command line".to_string(),
1341 table: flags.to_table()?,
1342 });
1343 let (mut config, unknown_key_warnings) = merge_layers(layers)?;
1344 config.active_profile = flags.profile.clone();
1345 sanitizer_warnings.extend(unknown_key_warnings);
1348 Ok(LayeredLoad {
1349 config,
1350 warnings: sanitizer_warnings,
1351 notices,
1352 })
1353}
1354
1355pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
1360 fn load(cwd: &std::path::Path) -> Result<Config> {
1361 let config_path = get_config_path()?;
1362 let mut user_table = read_config_table(&config_path)?;
1363 migrate_legacy_max_tokens(&mut user_table);
1364 migrate_legacy_model_profiles(&mut user_table);
1365 let _ = take_profiles(&mut user_table);
1366 let base_safety = finalize_config(user_table.clone())?.0.safety;
1367 let mut layers = vec![LayerSource {
1368 layer: ConfigLayer::User,
1369 origin: config_path.display().to_string(),
1370 table: user_table,
1371 }];
1372 let (layer, _warnings, _notice) =
1373 super::project_config::load_project_layer(cwd, &base_safety);
1374 if let Some(layer) = layer {
1375 layers.push(layer);
1376 }
1377 Ok(merge_layers(layers)?.0)
1378 }
1379 load(cwd).unwrap_or_default()
1380}
1381
1382pub fn load_config_or_warn() -> Config {
1386 load_config().unwrap_or_else(|e| {
1387 eprintln!(
1388 "mermaid: {}",
1389 crate::utils::redact_secrets(&format!("{e:#}"))
1390 );
1391 Config::default()
1392 })
1393}
1394
1395pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
1397 if !path.exists() {
1398 return Ok(toml::Table::new());
1399 }
1400 let raw = std::fs::read_to_string(path)
1401 .with_context(|| format!("Failed to read {}", path.display()))?;
1402 toml::from_str::<toml::Table>(&raw).with_context(|| {
1403 format!(
1404 "Failed to parse {}. Run 'mermaid init' to regenerate.",
1405 path.display()
1406 )
1407 })
1408}
1409
1410pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
1414 let mut warnings = Vec::new();
1415 let mut merged = toml::Table::new();
1416 for layer in layers {
1417 collect_layer_warnings(&layer, &mut warnings);
1418 deep_merge(&mut merged, layer.table);
1419 }
1420 let (config, _) = finalize_config(merged)?;
1421 Ok((config, warnings))
1422}
1423
1424fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
1430 let mut ignored = Vec::new();
1431 let result: Result<Config, _> =
1432 serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
1433 ignored.push(path.to_string())
1434 });
1435 if result.is_ok() {
1436 for path in ignored {
1437 warnings.push(format!(
1438 "unknown config key '{path}' in {} ({}) — check for a typo",
1439 layer.layer.name(),
1440 layer.origin
1441 ));
1442 }
1443 }
1444}
1445
1446fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
1451 for (key, value) in overlay {
1452 match (base.get_mut(&key), value) {
1453 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
1454 deep_merge(base_table, overlay_table);
1455 },
1456 (_, value) => {
1457 base.insert(key, value);
1458 },
1459 }
1460 }
1461}
1462
1463fn migrate_legacy_max_tokens(table: &mut toml::Table) {
1472 if let Some(dm) = table
1473 .get_mut("default_model")
1474 .and_then(|v| v.as_table_mut())
1475 && dm.get("max_tokens").and_then(|v| v.as_integer())
1476 == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
1477 {
1478 dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
1479 }
1480}
1481
1482fn migrate_legacy_model_profiles(table: &mut toml::Table) {
1488 if table.contains_key("model_aliases") {
1489 table.remove("model_profiles");
1490 return;
1491 }
1492 if let Some(profiles) = table.remove("model_profiles") {
1493 table.insert("model_aliases".to_string(), profiles);
1494 }
1495}
1496
1497fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
1501 let mut ignored = Vec::new();
1502 let mut config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
1503 ignored.push(path.to_string());
1504 })
1505 .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
1506 if config.safety.mode.is_planning() {
1513 config.safety.mode = SafetyConfig::default().mode;
1514 ignored.push(
1515 "safety.mode (plan is entered with /plan or Shift+Tab, not configured)".to_string(),
1516 );
1517 }
1518 Ok((config, ignored))
1519}
1520
1521fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
1525 for raw in overrides {
1526 let (key, val) = raw
1527 .split_once('=')
1528 .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
1529 let key = key.trim();
1530 if key.is_empty() {
1531 anyhow::bail!("invalid -c override '{raw}' (empty key)");
1532 }
1533 deep_set(table, key, parse_override_value(val.trim()))?;
1534 }
1535 Ok(())
1536}
1537
1538fn parse_override_value(s: &str) -> toml::Value {
1541 toml::from_str::<toml::Table>(&format!("x = {s}"))
1542 .ok()
1543 .and_then(|t| t.get("x").cloned())
1544 .unwrap_or_else(|| toml::Value::String(s.to_string()))
1545}
1546
1547fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
1553 let parts: Vec<&str> = key.split('.').collect();
1554 deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
1555}
1556
1557fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
1561 let Some((leaf, parents)) = path.split_last() else {
1562 anyhow::bail!("empty config key path");
1563 };
1564 let mut cur = table;
1565 for part in parents {
1566 let next = cur
1567 .entry((*part).to_string())
1568 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1569 cur = next
1570 .as_table_mut()
1571 .with_context(|| format!("'{part}' is not a table"))?;
1572 }
1573 cur.insert((*leaf).to_string(), value);
1574 Ok(())
1575}
1576
1577pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
1581 let Some((leaf, parents)) = path.split_last() else {
1582 return false;
1583 };
1584 let mut cur = table;
1585 for part in parents {
1586 match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
1587 Some(next) => cur = next,
1588 None => return false,
1589 }
1590 }
1591 cur.remove(*leaf).is_some()
1592}
1593
1594pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
1600 match load_layered_config(cwd, flags) {
1601 Ok(load) => {
1602 for notice in &load.notices {
1603 eprintln!("mermaid: {notice}");
1604 }
1605 for warning in &load.warnings {
1606 eprintln!("mermaid: warning: {warning}");
1607 }
1608 load.config
1609 },
1610 Err(e) => {
1611 eprintln!(
1615 "mermaid: {}",
1616 crate::utils::redact_secrets(&format!("{e:#}"))
1617 );
1618 flags
1619 .to_table()
1620 .ok()
1621 .and_then(|table| finalize_config(table).ok())
1622 .map(|(config, _)| config)
1623 .unwrap_or_default()
1624 },
1625 }
1626}
1627
1628pub fn get_config_path() -> Result<PathBuf> {
1630 Ok(get_config_dir()?.join("config.toml"))
1631}
1632
1633pub fn get_config_dir() -> Result<PathBuf> {
1635 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1636 let config_dir = proj_dirs.config_dir();
1637 std::fs::create_dir_all(config_dir)?;
1638 Ok(config_dir.to_path_buf())
1639 } else {
1640 let home = std::env::var("HOME")
1642 .or_else(|_| std::env::var("USERPROFILE"))
1643 .context("Could not determine home directory")?;
1644 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
1645 std::fs::create_dir_all(&config_dir)?;
1646 Ok(config_dir)
1647 }
1648}
1649
1650fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
1657 let path = if let Some(p) = path {
1658 p
1659 } else {
1660 get_config_dir()?.join("config.toml")
1661 };
1662 write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
1663}
1664
1665fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1676 #[cfg(unix)]
1677 crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
1678 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1679 #[cfg(not(unix))]
1680 crate::runtime::write_atomic(path, bytes)
1681 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1682 Ok(())
1683}
1684
1685pub fn init_config() -> Result<()> {
1687 let config_file = get_config_path()?;
1688
1689 if config_file.exists() {
1690 println!("Configuration already exists at: {}", config_file.display());
1691 } else {
1692 let default_config = Config::default();
1693 save_config(&default_config, Some(config_file.clone()))?;
1694 println!("Created configuration at: {}", config_file.display());
1695 }
1696
1697 Ok(())
1698}
1699
1700static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1706
1707fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
1715 update_user_config_table_at(&get_config_path()?, mutate)
1716}
1717
1718fn update_user_config_table_at(
1720 path: &std::path::Path,
1721 mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
1722) -> Result<()> {
1723 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1724 let mut table = read_config_table(path)?;
1725 migrate_legacy_max_tokens(&mut table);
1727 migrate_legacy_model_profiles(&mut table);
1728 mutate(&mut table)?;
1729 write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
1730}
1731
1732pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
1736 update_user_config_table(|table| deep_set_segments(table, path, value))
1737}
1738
1739pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
1744 update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
1745}
1746
1747pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
1750 let mut removed = false;
1751 update_user_config_table(|table| {
1752 removed = deep_remove_segments(table, path);
1753 Ok(())
1754 })?;
1755 Ok(removed)
1756}
1757
1758pub fn persist_last_model(model: &str) -> Result<()> {
1760 update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
1761}
1762
1763pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
1765 update_user_config_key(
1766 &["ui", "theme"],
1767 toml::Value::String(theme.as_str().to_string()),
1768 )
1769}
1770
1771pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
1774 update_user_config_key(
1775 &["default_model", "reasoning"],
1776 toml::Value::try_from(level)?,
1777 )
1778}
1779
1780pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
1786 update_user_config_key(
1787 &["reasoning_per_model", model_id],
1788 toml::Value::try_from(level)?,
1789 )
1790}
1791
1792pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
1795 match num_ctx {
1796 Some(n) => update_user_config_key(
1797 &["ollama_num_ctx_per_model", model_id],
1798 toml::Value::Integer(i64::from(n)),
1799 ),
1800 None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
1801 }
1802}
1803
1804pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
1806 update_user_config_key(
1807 &["ollama", "allow_ram_offload"],
1808 toml::Value::Boolean(enabled),
1809 )
1810}
1811
1812pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
1814 if let Some(model) = cli_model {
1815 if let Some(resolved) = resolve_model_alias(model, config)? {
1816 return Ok(resolved);
1817 }
1818 return Ok(model.to_string());
1819 }
1820 if let Some(last_model) = &config.last_used_model {
1821 if let Some(resolved) = resolve_model_alias(last_model, config)? {
1822 return Ok(resolved);
1823 }
1824 return Ok(last_model.clone());
1825 }
1826 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1827 return Ok(format!(
1828 "{}/{}",
1829 config.default_model.provider, config.default_model.name
1830 ));
1831 }
1832 let available = crate::ollama::require_any_model(config).await?;
1833 let first = available
1837 .first()
1838 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
1839 Ok(format!("ollama/{}", first))
1840}
1841
1842fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
1843 let alias = requested.strip_prefix("alias:").unwrap_or(requested);
1844 if let Some(model) = config.model_aliases.get(alias) {
1845 anyhow::ensure!(
1846 !model.trim().is_empty(),
1847 "model alias `{}` is configured with an empty model id",
1848 alias
1849 );
1850 return Ok(Some(model.clone()));
1851 }
1852 if requested.starts_with("alias:") {
1853 anyhow::bail!(
1854 "model alias `{}` is not configured; add it under [model_aliases]",
1855 alias
1856 );
1857 }
1858 Ok(None)
1859}
1860
1861#[cfg(test)]
1862mod tests {
1863 use super::*;
1864
1865 #[test]
1866 fn legacy_default_max_tokens_migrates_to_auto() {
1867 let mut table: toml::Table =
1869 toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
1870 migrate_legacy_max_tokens(&mut table);
1871 migrate_legacy_model_profiles(&mut table);
1872 let (config, _) = finalize_config(table).unwrap();
1873 assert_eq!(config.default_model.max_tokens, 0);
1874
1875 let mut table: toml::Table =
1877 toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
1878 migrate_legacy_max_tokens(&mut table);
1879 migrate_legacy_model_profiles(&mut table);
1880 let (config, _) = finalize_config(table).unwrap();
1881 assert_eq!(config.default_model.max_tokens, 8192);
1882
1883 let mut table = toml::Table::new();
1885 migrate_legacy_max_tokens(&mut table);
1886 migrate_legacy_model_profiles(&mut table);
1887 let (config, _) = finalize_config(table).unwrap();
1888 assert_eq!(config.default_model.max_tokens, 0);
1889 }
1890
1891 #[test]
1892 fn legacy_model_profiles_table_migrates_to_model_aliases() {
1893 let mut table: toml::Table =
1895 toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
1896 migrate_legacy_model_profiles(&mut table);
1897 let (config, ignored) = finalize_config(table).unwrap();
1898 assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
1899 assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
1900 let mut table: toml::Table =
1902 toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
1903 .unwrap();
1904 migrate_legacy_model_profiles(&mut table);
1905 let (config, ignored) = finalize_config(table).unwrap();
1906 assert_eq!(config.model_aliases["fast"], "new");
1907 assert!(ignored.is_empty());
1908 let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
1910 std::fs::create_dir_all(&dir).unwrap();
1911 let path = dir.join("config.toml");
1912 std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
1913 update_user_config_table_at(&path, |_| Ok(())).unwrap();
1914 let blob = std::fs::read_to_string(&path).unwrap();
1915 assert!(blob.contains("[model_aliases]"), "{blob}");
1916 assert!(!blob.contains("model_profiles"), "{blob}");
1917 let _ = std::fs::remove_dir_all(&dir);
1918 }
1919
1920 #[test]
1921 fn ui_theme_deserializes_defaults_and_rejects_typos() {
1922 let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
1923 assert_eq!(config.ui.theme, ThemeChoice::Light);
1924 let config: Config = toml::from_str("").unwrap();
1926 assert_eq!(config.ui.theme, ThemeChoice::Dark);
1927 assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
1928 assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
1930 }
1931
1932 #[test]
1933 fn finalize_config_flags_unknown_keys() {
1934 let table: toml::Table =
1935 toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
1936 .unwrap();
1937 let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
1938 assert_eq!(config.default_model.max_tokens, 512);
1939 assert!(
1940 ignored.iter().any(|p| p == "unknown_top"),
1941 "got {ignored:?}"
1942 );
1943 assert!(
1944 ignored.iter().any(|p| p.contains("bogus")),
1945 "got {ignored:?}"
1946 );
1947 }
1948
1949 #[test]
1950 fn cli_overrides_beat_file_and_create_nested_tables() {
1951 let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
1953 apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
1954 let (config, ignored) = finalize_config(table).unwrap();
1955 assert_eq!(config.default_model.max_tokens, 8192);
1956 assert!(ignored.is_empty());
1957 let mut empty = toml::Table::new();
1959 apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
1960 assert_eq!(
1961 finalize_config(empty).unwrap().0.default_model.max_tokens,
1962 256
1963 );
1964 }
1965
1966 #[test]
1967 fn parse_override_value_keeps_toml_types_with_string_fallback() {
1968 assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
1969 assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
1970 assert_eq!(
1971 parse_override_value("ollama/qwen"),
1972 toml::Value::String("ollama/qwen".to_string())
1973 );
1974 }
1975
1976 #[test]
1977 fn cli_override_invalid_format_errors() {
1978 let mut table = toml::Table::new();
1979 assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
1980 assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
1981 }
1982
1983 #[test]
1984 fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
1985 let mut base: toml::Table = toml::from_str(
1986 "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
1987 )
1988 .unwrap();
1989 let overlay: toml::Table =
1990 toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
1991 .unwrap();
1992 deep_merge(&mut base, overlay);
1993 assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
1995 assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
1997 assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
1999 assert_eq!(base["top"].as_integer(), Some(1));
2001 }
2002
2003 #[test]
2004 fn deep_merge_overlay_wins_on_kind_conflict() {
2005 let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
2007 let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
2008 deep_merge(&mut base, overlay);
2009 assert_eq!(base["a"].as_integer(), Some(5));
2010 assert_eq!(base["b"]["y"].as_integer(), Some(3));
2011 }
2012
2013 #[test]
2014 fn merge_layers_precedence_and_layer_attributed_warnings() {
2015 let user: toml::Table = toml::from_str(
2016 "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
2017 )
2018 .unwrap();
2019 let session: toml::Table =
2020 toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
2021 let (config, warnings) = merge_layers(vec![
2022 LayerSource {
2023 layer: ConfigLayer::User,
2024 origin: "/tmp/user.toml".to_string(),
2025 table: user,
2026 },
2027 LayerSource {
2028 layer: ConfigLayer::Session,
2029 origin: "command line".to_string(),
2030 table: session,
2031 },
2032 ])
2033 .expect("merges");
2034 assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
2036 assert_eq!(config.default_model.max_tokens, 100);
2037 assert!(
2039 warnings
2040 .iter()
2041 .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
2042 "got {warnings:?}"
2043 );
2044 assert!(
2045 warnings
2046 .iter()
2047 .any(|w| w.contains("session_typo") && w.contains("session flags")),
2048 "got {warnings:?}"
2049 );
2050 }
2051
2052 #[test]
2053 fn take_profiles_excises_and_tolerates_absence() {
2054 let mut table: toml::Table =
2055 toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
2056 let profiles = take_profiles(&mut table);
2057 assert!(table.is_empty(), "profiles must be excised: {table:?}");
2058 assert!(profiles.contains_key("fast"));
2059 let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
2061 assert!(take_profiles(&mut table).is_empty());
2062 assert_eq!(table.len(), 1);
2063 let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
2065 assert!(take_profiles(&mut table).is_empty());
2066 assert!(table.is_empty());
2067 }
2068
2069 #[test]
2070 fn resolve_profile_layer_errors_name_available_profiles() {
2071 let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
2072 let path = std::path::Path::new("/tmp/config.toml");
2073 let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
2074 assert!(err.to_string().contains("available: fast, work"), "{err}");
2075 let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
2077 assert!(
2078 err.to_string().contains("no config profiles defined"),
2079 "{err}"
2080 );
2081 let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
2083 let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
2084 assert!(err.to_string().contains("not a table"), "{err}");
2085 let profiles: toml::Table =
2087 toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
2088 let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
2089 assert_eq!(layer.layer, ConfigLayer::Profile);
2090 assert!(layer.origin.contains("profile:work"));
2091 }
2092
2093 #[test]
2094 fn profile_layer_beats_user_loses_to_project_and_session() {
2095 let user: toml::Table = toml::from_str(
2096 "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
2097 )
2098 .unwrap();
2099 let profile: toml::Table = toml::from_str(
2100 "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
2101 )
2102 .unwrap();
2103 let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
2104 let session: toml::Table =
2105 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2106 let (config, warnings) = merge_layers(vec![
2107 LayerSource {
2108 layer: ConfigLayer::User,
2109 origin: "/tmp/user.toml".to_string(),
2110 table: user,
2111 },
2112 LayerSource {
2113 layer: ConfigLayer::Profile,
2114 origin: "profile:work (/tmp/user.toml)".to_string(),
2115 table: profile,
2116 },
2117 LayerSource {
2118 layer: ConfigLayer::Project,
2119 origin: "/repo/.mermaid/config.toml".to_string(),
2120 table: project,
2121 },
2122 LayerSource {
2123 layer: ConfigLayer::Session,
2124 origin: "command line".to_string(),
2125 table: session,
2126 },
2127 ])
2128 .expect("merges");
2129 assert_eq!(config.default_model.temperature, 0.5);
2132 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2133 assert_eq!(config.default_model.max_tokens, 100);
2134 assert!(
2136 warnings.iter().any(|w| w.contains("profile_typo")
2137 && w.contains("config profile (profile:work (/tmp/user.toml))")),
2138 "got {warnings:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn persists_never_touch_profile_tables() {
2144 let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
2145 std::fs::create_dir_all(&dir).expect("create temp dir");
2146 let path = dir.join("config.toml");
2147 std::fs::write(
2148 &path,
2149 "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
2150 )
2151 .expect("seed");
2152
2153 update_user_config_table_at(&path, |table| {
2154 deep_set_segments(
2155 table,
2156 &["safety", "mode"],
2157 toml::Value::String("auto".to_string()),
2158 )
2159 })
2160 .expect("persist");
2161
2162 let table: toml::Table =
2163 toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
2164 assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
2165 assert_eq!(
2167 table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
2168 Some(0.1)
2169 );
2170 let _ = std::fs::remove_dir_all(&dir);
2171 }
2172
2173 #[test]
2174 fn session_flags_table_maps_each_flag() {
2175 let flags = SessionFlags {
2176 overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
2177 deny_network: true,
2178 confine_fs: true,
2179 max_tokens: Some(512),
2180 allow_untrusted_tools: true,
2181 profile: None,
2182 };
2183 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2184 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2185 assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
2186 assert_eq!(config.default_model.max_tokens, 512);
2187 assert!(config.safety.allow_untrusted_headless_tools);
2188 assert_eq!(config.web.searxng_url, "http://x:1");
2189 }
2190
2191 #[test]
2192 fn session_dedicated_flags_beat_dash_c() {
2193 let flags = SessionFlags {
2196 overrides: vec!["safety.network=allow".to_string()],
2197 deny_network: true,
2198 ..Default::default()
2199 };
2200 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2201 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2202 }
2203
2204 #[test]
2205 fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
2206 let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
2208 let mut warnings = Vec::new();
2209 collect_layer_warnings(
2210 &LayerSource {
2211 layer: ConfigLayer::User,
2212 origin: "x".to_string(),
2213 table: bad.clone(),
2214 },
2215 &mut warnings,
2216 );
2217 assert!(warnings.is_empty());
2218 assert!(
2220 merge_layers(vec![LayerSource {
2221 layer: ConfigLayer::User,
2222 origin: "x".to_string(),
2223 table: bad.clone(),
2224 }])
2225 .is_err()
2226 );
2227 let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
2229 let (config, _) = merge_layers(vec![
2230 LayerSource {
2231 layer: ConfigLayer::User,
2232 origin: "x".to_string(),
2233 table: bad,
2234 },
2235 LayerSource {
2236 layer: ConfigLayer::Session,
2237 origin: "command line".to_string(),
2238 table: fix,
2239 },
2240 ])
2241 .expect("later layer repairs the earlier one");
2242 assert_eq!(config.safety.mode, SafetyMode::Ask);
2243 }
2244
2245 #[test]
2246 fn project_layer_beats_user_and_loses_to_session() {
2247 let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
2248 let project: toml::Table = toml::from_str(
2249 "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
2250 )
2251 .unwrap();
2252 let session: toml::Table =
2253 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2254 let (config, _) = merge_layers(vec![
2255 LayerSource {
2256 layer: ConfigLayer::User,
2257 origin: "user".to_string(),
2258 table: user,
2259 },
2260 LayerSource {
2261 layer: ConfigLayer::Project,
2262 origin: "project".to_string(),
2263 table: project,
2264 },
2265 LayerSource {
2266 layer: ConfigLayer::Session,
2267 origin: "command line".to_string(),
2268 table: session,
2269 },
2270 ])
2271 .expect("merges");
2272 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2274 assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
2276 }
2277
2278 #[test]
2279 fn session_flags_survive_corrupt_user_layer_fallback() {
2280 let flags = SessionFlags {
2283 deny_network: true,
2284 ..Default::default()
2285 };
2286 let config = flags
2287 .to_table()
2288 .ok()
2289 .and_then(|table| finalize_config(table).ok())
2290 .map(|(config, _)| config)
2291 .unwrap_or_default();
2292 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2293 }
2294
2295 #[test]
2296 fn deep_set_segments_addresses_keys_containing_dots() {
2297 let mut table = toml::Table::new();
2300 deep_set_segments(
2301 &mut table,
2302 &["reasoning_per_model", "gemini/gemini-2.5-pro"],
2303 toml::Value::String("high".to_string()),
2304 )
2305 .unwrap();
2306 let (config, ignored) = finalize_config(table).unwrap();
2307 assert!(ignored.is_empty(), "got {ignored:?}");
2308 assert_eq!(
2309 config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
2310 Some(&ReasoningLevel::High)
2311 );
2312 }
2313
2314 #[test]
2315 fn deep_remove_segments_removes_leaf_only() {
2316 let mut table: toml::Table =
2317 toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
2318 .unwrap();
2319 assert!(deep_remove_segments(
2320 &mut table,
2321 &["ollama_num_ctx_per_model", "ollama/a"]
2322 ));
2323 assert_eq!(
2325 table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
2326 Some(2)
2327 );
2328 assert!(!deep_remove_segments(
2329 &mut table,
2330 &["ollama_num_ctx_per_model", "ollama/a"]
2331 ));
2332 assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
2333 }
2334
2335 #[test]
2336 fn update_user_config_table_preserves_unknown_keys() {
2337 let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
2338 std::fs::create_dir_all(&dir).expect("create temp dir");
2339 let path = dir.join("config.toml");
2340 std::fs::write(
2343 &path,
2344 "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
2345 )
2346 .expect("seed");
2347
2348 update_user_config_table_at(&path, |table| {
2349 deep_set_segments(
2350 table,
2351 &["last_used_model"],
2352 toml::Value::String("ollama/new".to_string()),
2353 )
2354 })
2355 .expect("persist");
2356
2357 let blob = std::fs::read_to_string(&path).expect("read back");
2358 let table: toml::Table = toml::from_str(&blob).expect("parse back");
2359 assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
2361 assert_eq!(table["future_key"].as_str(), Some("kept"));
2363 assert!(!blob.contains("safety"), "defaults must not be frozen in");
2365 assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
2366
2367 let _ = std::fs::remove_dir_all(&dir);
2368 }
2369
2370 #[test]
2371 fn mcp_tool_allowed_honors_enabled_and_disabled() {
2372 let cfg = McpServerConfig::default();
2374 assert!(cfg.tool_allowed("anything"));
2375 let cfg = McpServerConfig {
2377 enabled_tools: vec!["read".into(), "search".into()],
2378 ..Default::default()
2379 };
2380 assert!(cfg.tool_allowed("read"));
2381 assert!(!cfg.tool_allowed("write"));
2382 let cfg = McpServerConfig {
2384 enabled_tools: vec!["read".into(), "write".into()],
2385 disabled_tools: vec!["write".into()],
2386 ..Default::default()
2387 };
2388 assert!(cfg.tool_allowed("read"));
2389 assert!(!cfg.tool_allowed("write"));
2390 }
2391
2392 #[test]
2393 fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
2394 let cfg = McpServerConfig {
2396 command: "npx".to_string(),
2397 ..Default::default()
2398 };
2399 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
2400 let cfg = McpServerConfig {
2402 url: Some("https://example.com/mcp".to_string()),
2403 ..Default::default()
2404 };
2405 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
2406 let cfg = McpServerConfig {
2408 command: "npx".to_string(),
2409 url: Some("https://example.com/mcp".to_string()),
2410 ..Default::default()
2411 };
2412 assert!(
2413 cfg.transport_kind()
2414 .unwrap_err()
2415 .to_string()
2416 .contains("mutually exclusive")
2417 );
2418 let cfg = McpServerConfig::default();
2420 assert!(
2421 cfg.transport_kind()
2422 .unwrap_err()
2423 .to_string()
2424 .contains("neither")
2425 );
2426 }
2427
2428 #[test]
2429 fn mcp_transport_kind_gates_url_scheme() {
2430 let with_url = |url: &str| McpServerConfig {
2431 url: Some(url.to_string()),
2432 ..Default::default()
2433 };
2434 assert!(
2437 with_url("https://mcp.example.com/x")
2438 .transport_kind()
2439 .is_ok()
2440 );
2441 assert!(
2442 with_url("http://localhost:8080/mcp")
2443 .transport_kind()
2444 .is_ok()
2445 );
2446 assert!(
2447 with_url("http://127.0.0.1:8080/mcp")
2448 .transport_kind()
2449 .is_ok()
2450 );
2451 assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
2452 assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
2453 assert!(with_url("not a url").transport_kind().is_err());
2454 }
2455
2456 #[test]
2457 fn mcp_server_config_debug_masks_header_values() {
2458 let mut headers = HashMap::new();
2459 headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
2460 let mut env_headers = HashMap::new();
2461 env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
2462 let cfg = McpServerConfig {
2463 url: Some("https://example.com/mcp".to_string()),
2464 headers,
2465 env_headers,
2466 ..Default::default()
2467 };
2468 let rendered = format!("{cfg:?}");
2469 assert!(!rendered.contains("sk-secret"), "{rendered}");
2470 assert!(rendered.contains("Authorization"), "{rendered}");
2471 assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
2473 }
2474
2475 #[test]
2476 fn mcp_url_config_round_trips_through_toml_without_command() {
2477 let cfg = McpServerConfig {
2480 url: Some("https://example.com/mcp".to_string()),
2481 ..Default::default()
2482 };
2483 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2484 assert!(
2485 !blob.contains("command"),
2486 "empty command must be omitted: {blob}"
2487 );
2488 let back: McpServerConfig = toml::from_str(&blob).unwrap();
2489 assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
2490 assert!(back.command.is_empty());
2491 let cfg = McpServerConfig {
2493 command: "npx".to_string(),
2494 ..Default::default()
2495 };
2496 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2497 assert!(!blob.contains("url"), "{blob}");
2498 }
2499
2500 #[test]
2505 fn model_settings_deserializes_without_reasoning_field() {
2506 let toml_blob = r#"
2507 provider = "ollama"
2508 name = "qwen3-coder:30b"
2509 temperature = 0.7
2510 max_tokens = 4096
2511 "#;
2512 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
2513 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
2514 assert_eq!(settings.provider, "ollama");
2515 }
2516
2517 #[test]
2518 fn model_settings_round_trips_reasoning_high() {
2519 let original = ModelSettings {
2520 provider: "anthropic".to_string(),
2521 name: "claude-sonnet-4-6".to_string(),
2522 temperature: 0.5,
2523 max_tokens: 8192,
2524 reasoning: ReasoningLevel::High,
2525 };
2526 let toml_blob = toml::to_string(&original).expect("serialize");
2527 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
2528 assert_eq!(back.reasoning, ReasoningLevel::High);
2529 assert_eq!(back.name, "claude-sonnet-4-6");
2530 }
2531
2532 #[test]
2533 fn agents_config_defaults_and_parses_custom_types() {
2534 let config: Config = toml::from_str("").expect("empty config parses");
2536 assert_eq!(config.agents.timeout_secs, 1200);
2537 assert!(config.agents.types.is_empty());
2538
2539 let config: Config = toml::from_str(
2540 r#"
2541[agents]
2542timeout_secs = 300
2543
2544[agents.types.scout]
2545tools = ["read_file", "execute_command"]
2546safety = "read_only"
2547preamble = "You are a scout."
2548model = "ollama/qwen3:8b"
2549"#,
2550 )
2551 .expect("agents section parses");
2552 assert_eq!(config.agents.timeout_secs, 300);
2553 let scout = &config.agents.types["scout"];
2554 assert_eq!(
2555 scout.tools.as_deref(),
2556 Some(&["read_file".to_string(), "execute_command".to_string()][..])
2557 );
2558 assert_eq!(scout.safety.as_deref(), Some("read_only"));
2559 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
2560 }
2561
2562 #[test]
2563 fn configured_model_alias_resolves_explicit_prefix() {
2564 let mut config = Config::default();
2565 config
2566 .model_aliases
2567 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
2568 assert_eq!(
2569 resolve_model_alias("fast", &config).unwrap(),
2570 Some("ollama/qwen3-coder:14b".to_string())
2571 );
2572 assert_eq!(
2573 resolve_model_alias("alias:fast", &config).unwrap(),
2574 Some("ollama/qwen3-coder:14b".to_string())
2575 );
2576 }
2577
2578 #[test]
2579 fn alias_prefix_requires_configuration() {
2580 let config = Config::default();
2581 assert!(resolve_model_alias("alias:vision", &config).is_err());
2582 assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
2583 }
2584
2585 #[test]
2592 fn save_and_reload_preserves_reasoning_field() {
2593 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
2594 std::fs::create_dir_all(&dir).expect("create temp dir");
2595 let path = dir.join("config.toml");
2596
2597 let mut cfg = Config::default();
2598 cfg.default_model.provider = "ollama".to_string();
2599 cfg.default_model.name = "qwen3-coder:30b".to_string();
2600 cfg.default_model.reasoning = ReasoningLevel::Low;
2601
2602 save_config(&cfg, Some(path.clone())).expect("save");
2603
2604 let blob = std::fs::read_to_string(&path).expect("read");
2605 let loaded: Config = toml::from_str(&blob).expect("parse back");
2606 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
2607
2608 let _ = std::fs::remove_dir_all(&dir);
2609 }
2610
2611 #[test]
2616 fn save_and_reload_preserves_reasoning_per_model_table() {
2617 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
2618 std::fs::create_dir_all(&dir).expect("create temp dir");
2619 let path = dir.join("config.toml");
2620
2621 let mut cfg = Config::default();
2622 cfg.reasoning_per_model.insert(
2623 "anthropic/claude-sonnet-4-6".to_string(),
2624 ReasoningLevel::High,
2625 );
2626 cfg.reasoning_per_model
2627 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
2628
2629 save_config(&cfg, Some(path.clone())).expect("save");
2630
2631 let blob = std::fs::read_to_string(&path).expect("read");
2632 let loaded: Config = toml::from_str(&blob).expect("parse back");
2633 assert_eq!(
2634 loaded
2635 .reasoning_per_model
2636 .get("anthropic/claude-sonnet-4-6"),
2637 Some(&ReasoningLevel::High)
2638 );
2639 assert_eq!(
2640 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
2641 Some(&ReasoningLevel::Low)
2642 );
2643
2644 let _ = std::fs::remove_dir_all(&dir);
2645 }
2646
2647 #[test]
2650 fn save_and_reload_preserves_ollama_context_overrides() {
2651 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
2652 std::fs::create_dir_all(&dir).expect("create temp dir");
2653 let path = dir.join("config.toml");
2654
2655 let mut cfg = Config::default();
2656 cfg.ollama_num_ctx_per_model
2657 .insert("ollama/ornith:9b".to_string(), 131_072);
2658 cfg.ollama.allow_ram_offload = true;
2659 cfg.ollama.max_auto_num_ctx = Some(65_536);
2660
2661 save_config(&cfg, Some(path.clone())).expect("save");
2662 let blob = std::fs::read_to_string(&path).expect("read");
2663 let loaded: Config = toml::from_str(&blob).expect("parse back");
2664
2665 assert_eq!(
2666 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
2667 Some(&131_072)
2668 );
2669 assert!(loaded.ollama.allow_ram_offload);
2670 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
2671
2672 let _ = std::fs::remove_dir_all(&dir);
2673 }
2674
2675 #[test]
2678 fn config_deserializes_without_ollama_context_keys() {
2679 let toml_blob = r#"
2680[ollama]
2681host = "localhost"
2682port = 11434
2683"#;
2684 let cfg: Config = toml::from_str(toml_blob).expect("parse");
2685 assert!(cfg.ollama_num_ctx_per_model.is_empty());
2686 assert!(!cfg.ollama.allow_ram_offload);
2687 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
2688 assert!(cfg.ollama.auto_start);
2691 }
2692
2693 #[test]
2697 fn config_deserializes_without_reasoning_per_model() {
2698 let toml_blob = r#"
2699 last_used_model = "ollama/qwen3-coder:30b"
2700
2701 [default_model]
2702 provider = "ollama"
2703 name = "qwen3-coder:30b"
2704 temperature = 0.7
2705 max_tokens = 4096
2706 "#;
2707 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
2708 assert!(cfg.reasoning_per_model.is_empty());
2709 assert!(!cfg.prompt.is_customized());
2710 }
2711
2712 #[cfg(unix)]
2716 #[test]
2717 fn save_config_writes_owner_only_perms() {
2718 use std::os::unix::fs::PermissionsExt;
2719 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
2720 std::fs::create_dir_all(&dir).expect("create temp dir");
2721 let path = dir.join("config.toml");
2722 std::fs::write(&path, "stale").expect("seed");
2724 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
2725
2726 save_config(&Config::default(), Some(path.clone())).expect("save");
2727 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2728 assert_eq!(mode, 0o600, "config must be written owner-only");
2729
2730 let _ = std::fs::remove_dir_all(&dir);
2731 }
2732
2733 #[test]
2734 fn config_defaults_computer_use_auto_screenshot_on() {
2735 let cfg: Config = toml::from_str("").expect("empty config");
2737 assert!(cfg.computer_use.auto_screenshot);
2738 }
2739
2740 #[test]
2741 fn prompt_config_replaces_and_appends_without_persisting() {
2742 let mut cfg = Config::default();
2743 cfg.prompt.system_prompt = Some("base".to_string());
2744 cfg.prompt
2745 .append_system_prompt
2746 .push("extra instructions".to_string());
2747
2748 assert_eq!(
2749 cfg.prompt.render_system_prompt("default"),
2750 "base\n\nextra instructions"
2751 );
2752
2753 let blob = toml::to_string(&cfg).expect("serialize");
2754 assert!(!blob.contains("extra instructions"));
2755 let loaded: Config = toml::from_str(&blob).expect("deserialize");
2756 assert!(!loaded.prompt.is_customized());
2757 }
2758
2759 #[test]
2762 fn absent_compaction_section_matches_the_built_in_policy() {
2763 let c: Config = toml::from_str("").expect("empty config parses");
2764 assert_eq!(
2765 c.compaction.policy(),
2766 crate::domain::CompactionPolicy::default(),
2767 );
2768 }
2769
2770 #[test]
2771 fn compaction_settings_reach_the_policy() {
2772 let c: Config = toml::from_str(
2773 "[compaction]\n\
2774 auto_enabled = false\n\
2775 auto_threshold_percent = 60\n\
2776 tail_turns = 5\n\
2777 tail_token_budget = 12000\n\
2778 summary_max_tokens = 3000\n",
2779 )
2780 .expect("compaction section parses");
2781 let policy = c.compaction.policy();
2782 assert!(!policy.auto_enabled);
2783 assert_eq!(policy.auto_threshold_percent, 60);
2784 assert_eq!(policy.tail_turns, 5);
2785 assert_eq!(policy.tail_token_budget, 12_000);
2786 assert_eq!(policy.summary_max_tokens, 3_000);
2787 let defaults = crate::domain::CompactionPolicy::default();
2789 assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2790 }
2791
2792 #[test]
2795 fn nonsense_compaction_settings_are_clamped() {
2796 let c: Config = toml::from_str(
2797 "[compaction]\n\
2798 auto_threshold_percent = 250\n\
2799 tail_turns = 0\n\
2800 tail_token_budget = 0\n\
2801 summary_max_tokens = 0\n\
2802 summarizer_input_token_budget = 0\n\
2803 tool_output_max_chars = 0\n\
2804 min_response_reserve_tokens = 50000\n\
2805 max_response_reserve_tokens = 1000\n",
2806 )
2807 .expect("config parses");
2808 let policy = c.compaction.policy();
2809 let defaults = crate::domain::CompactionPolicy::default();
2810
2811 assert_eq!(policy.auto_threshold_percent, 100, "percent clamps to 100");
2812 assert_eq!(
2813 policy.tail_turns, 1,
2814 "a checkpoint needs a live turn after it"
2815 );
2816 assert_eq!(policy.tail_token_budget, defaults.tail_token_budget);
2818 assert_eq!(policy.summary_max_tokens, defaults.summary_max_tokens);
2819 assert_eq!(
2820 policy.summarizer_input_token_budget,
2821 defaults.summarizer_input_token_budget
2822 );
2823 assert_eq!(policy.tool_output_max_chars, defaults.tool_output_max_chars);
2824
2825 assert_eq!(policy.min_response_reserve_tokens, 1_000);
2829 assert_eq!(policy.max_response_reserve_tokens, 50_000);
2830 assert!(policy.min_response_reserve_tokens <= policy.max_response_reserve_tokens);
2831 }
2832
2833 #[test]
2836 fn zero_compaction_threshold_clamps_up() {
2837 let c: Config =
2838 toml::from_str("[compaction]\nauto_threshold_percent = 0\n").expect("parses");
2839 assert_eq!(c.compaction.policy().auto_threshold_percent, 1);
2840 }
2841
2842 #[test]
2843 fn plan_config_defaults_parse_and_do_not_freeze() {
2844 let c: Config = toml::from_str("").expect("empty config parses");
2846 assert!(!c.plan.auto_approve);
2847 assert!(c.plan.post_approve.is_none());
2848 let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
2850 .expect("plan section parses");
2851 assert!(c.plan.auto_approve);
2852 assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
2853 assert_eq!(
2854 toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
2855 .expect("wait parses")
2856 .plan
2857 .post_approve,
2858 Some(PlanPostApprove::Wait)
2859 );
2860 let blob = toml::to_string(&Config::default()).expect("serialize");
2864 assert!(!blob.contains("post_approve"));
2865 }
2866}