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)]
552#[serde(default)]
553pub struct CompactionConfig {
554 pub max_truncation_recoveries: u8,
566}
567
568impl Default for CompactionConfig {
569 fn default() -> Self {
570 Self {
571 max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
572 }
573 }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(default)]
579pub struct ComputerUseConfig {
580 pub auto_screenshot: bool,
586}
587
588impl Default for ComputerUseConfig {
589 fn default() -> Self {
590 Self {
591 auto_screenshot: true,
592 }
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
598#[serde(default)]
599pub struct AgentsConfig {
600 pub timeout_secs: u64,
603 pub types: HashMap<String, AgentTypeConfig>,
614}
615
616impl Default for AgentsConfig {
617 fn default() -> Self {
618 Self {
619 timeout_secs: 1200,
620 types: HashMap::new(),
621 }
622 }
623}
624
625#[derive(Debug, Clone, Default, Serialize, Deserialize)]
628#[serde(default)]
629pub struct AgentTypeConfig {
630 pub tools: Option<Vec<String>>,
635 pub safety: Option<String>,
639 pub preamble: Option<String>,
642 pub model: Option<String>,
645}
646
647#[derive(Clone, Default, Serialize, Deserialize)]
651pub struct UserProviderConfig {
652 #[serde(default)]
655 pub base_url: Option<String>,
656 #[serde(default)]
660 pub api_key_env: Option<String>,
661 #[serde(default)]
663 pub extra_headers: HashMap<String, String>,
664 #[serde(default)]
669 pub env_headers: HashMap<String, String>,
670 #[serde(default)]
676 pub compat: Option<String>,
677 #[serde(default)]
681 pub default_model: Option<String>,
682}
683
684#[derive(Clone, Default, Serialize, Deserialize)]
686pub struct McpServerConfig {
687 #[serde(default, skip_serializing_if = "String::is_empty")]
690 pub command: String,
691 #[serde(default)]
693 pub args: Vec<String>,
694 #[serde(default)]
696 pub env: HashMap<String, String>,
697 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub url: Option<String>,
702 #[serde(default)]
705 pub headers: HashMap<String, String>,
706 #[serde(default)]
711 pub env_headers: HashMap<String, String>,
712 #[serde(default)]
716 pub allow_private_network: bool,
717 #[serde(default)]
719 pub enabled_tools: Vec<String>,
720 #[serde(default)]
722 pub disabled_tools: Vec<String>,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub defer: Option<bool>,
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub enum TransportKind {
735 Stdio,
736 Http,
737}
738
739impl McpServerConfig {
740 pub fn transport_kind(&self) -> Result<TransportKind> {
745 match (&self.url, self.command.is_empty()) {
746 (Some(_), false) => Err(anyhow::anyhow!(
747 "MCP server config sets both `command` and `url`; they are mutually exclusive"
748 )),
749 (None, true) => Err(anyhow::anyhow!(
750 "MCP server config sets neither `command` nor `url`"
751 )),
752 (None, false) => Ok(TransportKind::Stdio),
753 (Some(url), true) => {
754 let parsed = reqwest::Url::parse(url)
755 .map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
756 let host = parsed.host_str().unwrap_or("");
757 match parsed.scheme() {
758 "https" => Ok(TransportKind::Http),
759 "http" if crate::utils::classify_host(host).is_loopback() => {
760 Ok(TransportKind::Http)
761 },
762 "http" => Err(anyhow::anyhow!(
763 "MCP server url '{url}' uses plaintext http to a non-loopback host; \
764 use https (auth headers would travel in cleartext)"
765 )),
766 other => Err(anyhow::anyhow!(
767 "MCP server url '{url}' has unsupported scheme '{other}' \
768 (expected https, or http to loopback)"
769 )),
770 }
771 },
772 }
773 }
774
775 pub fn tool_allowed(&self, tool_name: &str) -> bool {
779 if self.disabled_tools.iter().any(|t| t == tool_name) {
780 return false;
781 }
782 self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
783 }
784}
785
786fn debug_masked_map(
790 map: &HashMap<String, String>,
791) -> std::collections::BTreeMap<&str, &'static str> {
792 map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
793}
794
795impl std::fmt::Debug for McpServerConfig {
800 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801 f.debug_struct("McpServerConfig")
802 .field("command", &self.command)
803 .field(
805 "args",
806 &self
807 .args
808 .iter()
809 .map(|a| crate::utils::redact_secrets(a))
810 .collect::<Vec<_>>(),
811 )
812 .field("env", &debug_masked_map(&self.env))
813 .field("url", &self.url)
814 .field("headers", &debug_masked_map(&self.headers))
816 .field("env_headers", &self.env_headers)
818 .field("allow_private_network", &self.allow_private_network)
819 .field("enabled_tools", &self.enabled_tools)
821 .field("disabled_tools", &self.disabled_tools)
822 .finish()
823 }
824}
825
826impl std::fmt::Debug for UserProviderConfig {
827 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
828 f.debug_struct("UserProviderConfig")
829 .field("base_url", &self.base_url)
830 .field("api_key_env", &self.api_key_env)
831 .field("extra_headers", &debug_masked_map(&self.extra_headers))
832 .field("env_headers", &self.env_headers)
834 .field("compat", &self.compat)
835 .field("default_model", &self.default_model)
836 .finish()
837 }
838}
839
840#[derive(Debug, Clone, Serialize, Deserialize)]
842#[serde(default)]
843pub struct ModelSettings {
844 pub provider: String,
846 pub name: String,
848 pub temperature: f32,
850 pub max_tokens: usize,
852 pub reasoning: ReasoningLevel,
856}
857
858impl Default for ModelSettings {
859 fn default() -> Self {
860 Self {
861 provider: String::new(),
862 name: String::new(),
863 temperature: DEFAULT_TEMPERATURE,
864 max_tokens: 0,
868 reasoning: ReasoningLevel::default(),
869 }
870 }
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize)]
875#[serde(default)]
876pub struct OllamaConfig {
877 pub host: String,
879 pub port: u16,
881 pub num_gpu: Option<i32>,
884 pub num_thread: Option<i32>,
887 pub num_ctx: Option<i32>,
890 pub numa: Option<bool>,
892 pub allow_ram_offload: bool,
898 pub max_auto_num_ctx: Option<usize>,
902 pub auto_start: bool,
907}
908
909impl Default for OllamaConfig {
910 fn default() -> Self {
911 Self {
912 host: String::from("localhost"),
913 port: DEFAULT_OLLAMA_PORT,
914 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, auto_start: true, }
922 }
923}
924
925#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
927#[serde(rename_all = "lowercase")]
928pub enum FetchBackend {
929 #[default]
932 Native,
933 Ollama,
935}
936
937#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
939#[serde(rename_all = "lowercase")]
940pub enum SearchBackend {
941 #[default]
945 Auto,
946 Ollama,
948 Searxng,
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize)]
961#[serde(default)]
962pub struct WebConfig {
963 pub fetch_backend: FetchBackend,
966 pub search_backend: SearchBackend,
970 pub searxng_url: String,
975}
976
977impl Default for WebConfig {
978 fn default() -> Self {
979 Self {
980 fetch_backend: FetchBackend::Native,
981 search_backend: SearchBackend::Auto,
982 searxng_url: String::from("http://localhost:8080"),
983 }
984 }
985}
986
987#[derive(Debug, Clone, Serialize, Deserialize)]
989#[serde(default)]
990pub struct NonInteractiveConfig {
991 pub output_format: String,
993 pub max_tokens: usize,
995 pub no_execute: bool,
997}
998
999impl Default for NonInteractiveConfig {
1000 fn default() -> Self {
1001 Self {
1002 output_format: String::from("text"),
1003 max_tokens: 0,
1005 no_execute: false,
1006 }
1007 }
1008}
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1014pub enum ConfigLayer {
1015 Defaults = 0,
1018 User = 1,
1021 Profile = 2,
1025 Project = 3,
1028 Session = 4,
1032}
1033
1034impl ConfigLayer {
1035 fn name(self) -> &'static str {
1037 match self {
1038 ConfigLayer::Defaults => "defaults",
1039 ConfigLayer::User => "user config",
1040 ConfigLayer::Profile => "config profile",
1041 ConfigLayer::Project => "project config",
1042 ConfigLayer::Session => "session flags",
1043 }
1044 }
1045}
1046
1047#[derive(Debug, Clone)]
1049pub(crate) struct LayerSource {
1050 pub layer: ConfigLayer,
1052 pub origin: String,
1054 pub table: toml::Table,
1057}
1058
1059#[derive(Debug, Clone, Default)]
1062pub struct SessionFlags {
1063 pub overrides: Vec<String>,
1066 pub deny_network: bool,
1068 pub confine_fs: bool,
1070 pub max_tokens: Option<usize>,
1072 pub allow_untrusted_tools: bool,
1074 pub profile: Option<String>,
1078}
1079
1080impl SessionFlags {
1081 pub(crate) fn to_table(&self) -> Result<toml::Table> {
1085 let mut table = toml::Table::new();
1086 apply_cli_overrides(&mut table, &self.overrides)?;
1087 if self.deny_network {
1088 deep_set_segments(
1089 &mut table,
1090 &["safety", "network"],
1091 toml::Value::String("deny".into()),
1092 )?;
1093 }
1094 if self.confine_fs {
1095 deep_set_segments(
1096 &mut table,
1097 &["safety", "filesystem"],
1098 toml::Value::String("project".into()),
1099 )?;
1100 }
1101 if let Some(n) = self.max_tokens {
1102 deep_set_segments(
1103 &mut table,
1104 &["default_model", "max_tokens"],
1105 toml::Value::Integer(n as i64),
1106 )?;
1107 }
1108 if self.allow_untrusted_tools {
1109 deep_set_segments(
1110 &mut table,
1111 &["safety", "allow_untrusted_headless_tools"],
1112 toml::Value::Boolean(true),
1113 )?;
1114 }
1115 Ok(table)
1116 }
1117}
1118
1119fn take_profiles(table: &mut toml::Table) -> toml::Table {
1126 match table.remove("profiles") {
1127 Some(toml::Value::Table(profiles)) => profiles,
1128 _ => toml::Table::new(),
1131 }
1132}
1133
1134fn resolve_profile_layer(
1138 profiles: &toml::Table,
1139 name: &str,
1140 config_path: &std::path::Path,
1141) -> Result<LayerSource> {
1142 match profiles.get(name) {
1143 Some(toml::Value::Table(overlay)) => Ok(LayerSource {
1144 layer: ConfigLayer::Profile,
1145 origin: format!("profile:{} ({})", name, config_path.display()),
1146 table: overlay.clone(),
1147 }),
1148 Some(_) => anyhow::bail!(
1149 "config profile '{}' is not a table; define it as [profiles.{}] in {}",
1150 name,
1151 name,
1152 config_path.display()
1153 ),
1154 None => {
1155 let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
1156 available.sort_unstable();
1157 if available.is_empty() {
1158 anyhow::bail!(
1159 "no config profiles defined; add [profiles.{}] to {}",
1160 name,
1161 config_path.display()
1162 );
1163 }
1164 anyhow::bail!(
1165 "unknown config profile '{}'; available: {}",
1166 name,
1167 available.join(", ")
1168 )
1169 },
1170 }
1171}
1172
1173pub fn load_config() -> Result<Config> {
1178 let config_path = get_config_path()?;
1179 let mut table = read_config_table(&config_path)?;
1180 migrate_legacy_max_tokens(&mut table);
1181 migrate_legacy_model_profiles(&mut table);
1182 let _ = take_profiles(&mut table);
1183 Ok(finalize_config(table)?.0)
1184}
1185
1186pub struct LayeredLoad {
1189 pub config: Config,
1191 pub warnings: Vec<String>,
1193 pub notices: Vec<String>,
1195}
1196
1197pub fn load_layered_config(
1202 cwd: Option<&std::path::Path>,
1203 flags: &SessionFlags,
1204) -> Result<LayeredLoad> {
1205 let config_path = get_config_path()?;
1206 let mut user_table = read_config_table(&config_path)?;
1207 migrate_legacy_max_tokens(&mut user_table);
1208 migrate_legacy_model_profiles(&mut user_table);
1209 let profiles = take_profiles(&mut user_table);
1212 let mut layers = vec![LayerSource {
1213 layer: ConfigLayer::User,
1214 origin: config_path.display().to_string(),
1215 table: user_table.clone(),
1216 }];
1217 let mut sanitizer_warnings = Vec::new();
1218 let mut notices = Vec::new();
1219 if let Some(name) = flags.profile.as_deref() {
1220 let layer = resolve_profile_layer(&profiles, name, &config_path)?;
1221 notices.push(format!(
1222 "using config profile '{}' (from {})",
1223 name,
1224 config_path.display()
1225 ));
1226 layers.push(layer);
1227 }
1228 if let Some(cwd) = cwd {
1229 let base_safety = finalize_config(user_table)?.0.safety;
1232 let (layer, warnings, notice) =
1233 super::project_config::load_project_layer(cwd, &base_safety);
1234 sanitizer_warnings.extend(warnings);
1235 notices.extend(notice);
1236 if let Some(layer) = layer {
1237 layers.push(layer);
1238 }
1239 }
1240 layers.push(LayerSource {
1241 layer: ConfigLayer::Session,
1242 origin: "command line".to_string(),
1243 table: flags.to_table()?,
1244 });
1245 let (mut config, unknown_key_warnings) = merge_layers(layers)?;
1246 config.active_profile = flags.profile.clone();
1247 sanitizer_warnings.extend(unknown_key_warnings);
1250 Ok(LayeredLoad {
1251 config,
1252 warnings: sanitizer_warnings,
1253 notices,
1254 })
1255}
1256
1257pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
1262 fn load(cwd: &std::path::Path) -> Result<Config> {
1263 let config_path = get_config_path()?;
1264 let mut user_table = read_config_table(&config_path)?;
1265 migrate_legacy_max_tokens(&mut user_table);
1266 migrate_legacy_model_profiles(&mut user_table);
1267 let _ = take_profiles(&mut user_table);
1268 let base_safety = finalize_config(user_table.clone())?.0.safety;
1269 let mut layers = vec![LayerSource {
1270 layer: ConfigLayer::User,
1271 origin: config_path.display().to_string(),
1272 table: user_table,
1273 }];
1274 let (layer, _warnings, _notice) =
1275 super::project_config::load_project_layer(cwd, &base_safety);
1276 if let Some(layer) = layer {
1277 layers.push(layer);
1278 }
1279 Ok(merge_layers(layers)?.0)
1280 }
1281 load(cwd).unwrap_or_default()
1282}
1283
1284pub fn load_config_or_warn() -> Config {
1288 load_config().unwrap_or_else(|e| {
1289 eprintln!(
1290 "mermaid: {}",
1291 crate::utils::redact_secrets(&format!("{e:#}"))
1292 );
1293 Config::default()
1294 })
1295}
1296
1297pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
1299 if !path.exists() {
1300 return Ok(toml::Table::new());
1301 }
1302 let raw = std::fs::read_to_string(path)
1303 .with_context(|| format!("Failed to read {}", path.display()))?;
1304 toml::from_str::<toml::Table>(&raw).with_context(|| {
1305 format!(
1306 "Failed to parse {}. Run 'mermaid init' to regenerate.",
1307 path.display()
1308 )
1309 })
1310}
1311
1312pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
1316 let mut warnings = Vec::new();
1317 let mut merged = toml::Table::new();
1318 for layer in layers {
1319 collect_layer_warnings(&layer, &mut warnings);
1320 deep_merge(&mut merged, layer.table);
1321 }
1322 let (config, _) = finalize_config(merged)?;
1323 Ok((config, warnings))
1324}
1325
1326fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
1332 let mut ignored = Vec::new();
1333 let result: Result<Config, _> =
1334 serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
1335 ignored.push(path.to_string())
1336 });
1337 if result.is_ok() {
1338 for path in ignored {
1339 warnings.push(format!(
1340 "unknown config key '{path}' in {} ({}) — check for a typo",
1341 layer.layer.name(),
1342 layer.origin
1343 ));
1344 }
1345 }
1346}
1347
1348fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
1353 for (key, value) in overlay {
1354 match (base.get_mut(&key), value) {
1355 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
1356 deep_merge(base_table, overlay_table);
1357 },
1358 (_, value) => {
1359 base.insert(key, value);
1360 },
1361 }
1362 }
1363}
1364
1365fn migrate_legacy_max_tokens(table: &mut toml::Table) {
1374 if let Some(dm) = table
1375 .get_mut("default_model")
1376 .and_then(|v| v.as_table_mut())
1377 && dm.get("max_tokens").and_then(|v| v.as_integer())
1378 == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
1379 {
1380 dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
1381 }
1382}
1383
1384fn migrate_legacy_model_profiles(table: &mut toml::Table) {
1390 if table.contains_key("model_aliases") {
1391 table.remove("model_profiles");
1392 return;
1393 }
1394 if let Some(profiles) = table.remove("model_profiles") {
1395 table.insert("model_aliases".to_string(), profiles);
1396 }
1397}
1398
1399fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
1403 let mut ignored = Vec::new();
1404 let mut config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
1405 ignored.push(path.to_string());
1406 })
1407 .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
1408 if config.safety.mode.is_planning() {
1414 config.safety.mode = SafetyConfig::default().mode;
1415 ignored.push("safety.mode (plan is entered with /plan, not configured)".to_string());
1416 }
1417 Ok((config, ignored))
1418}
1419
1420fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
1424 for raw in overrides {
1425 let (key, val) = raw
1426 .split_once('=')
1427 .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
1428 let key = key.trim();
1429 if key.is_empty() {
1430 anyhow::bail!("invalid -c override '{raw}' (empty key)");
1431 }
1432 deep_set(table, key, parse_override_value(val.trim()))?;
1433 }
1434 Ok(())
1435}
1436
1437fn parse_override_value(s: &str) -> toml::Value {
1440 toml::from_str::<toml::Table>(&format!("x = {s}"))
1441 .ok()
1442 .and_then(|t| t.get("x").cloned())
1443 .unwrap_or_else(|| toml::Value::String(s.to_string()))
1444}
1445
1446fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
1452 let parts: Vec<&str> = key.split('.').collect();
1453 deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
1454}
1455
1456fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
1460 let Some((leaf, parents)) = path.split_last() else {
1461 anyhow::bail!("empty config key path");
1462 };
1463 let mut cur = table;
1464 for part in parents {
1465 let next = cur
1466 .entry((*part).to_string())
1467 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1468 cur = next
1469 .as_table_mut()
1470 .with_context(|| format!("'{part}' is not a table"))?;
1471 }
1472 cur.insert((*leaf).to_string(), value);
1473 Ok(())
1474}
1475
1476pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
1480 let Some((leaf, parents)) = path.split_last() else {
1481 return false;
1482 };
1483 let mut cur = table;
1484 for part in parents {
1485 match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
1486 Some(next) => cur = next,
1487 None => return false,
1488 }
1489 }
1490 cur.remove(*leaf).is_some()
1491}
1492
1493pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
1499 match load_layered_config(cwd, flags) {
1500 Ok(load) => {
1501 for notice in &load.notices {
1502 eprintln!("mermaid: {notice}");
1503 }
1504 for warning in &load.warnings {
1505 eprintln!("mermaid: warning: {warning}");
1506 }
1507 load.config
1508 },
1509 Err(e) => {
1510 eprintln!(
1514 "mermaid: {}",
1515 crate::utils::redact_secrets(&format!("{e:#}"))
1516 );
1517 flags
1518 .to_table()
1519 .ok()
1520 .and_then(|table| finalize_config(table).ok())
1521 .map(|(config, _)| config)
1522 .unwrap_or_default()
1523 },
1524 }
1525}
1526
1527pub fn get_config_path() -> Result<PathBuf> {
1529 Ok(get_config_dir()?.join("config.toml"))
1530}
1531
1532pub fn get_config_dir() -> Result<PathBuf> {
1534 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1535 let config_dir = proj_dirs.config_dir();
1536 std::fs::create_dir_all(config_dir)?;
1537 Ok(config_dir.to_path_buf())
1538 } else {
1539 let home = std::env::var("HOME")
1541 .or_else(|_| std::env::var("USERPROFILE"))
1542 .context("Could not determine home directory")?;
1543 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
1544 std::fs::create_dir_all(&config_dir)?;
1545 Ok(config_dir)
1546 }
1547}
1548
1549fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
1556 let path = if let Some(p) = path {
1557 p
1558 } else {
1559 get_config_dir()?.join("config.toml")
1560 };
1561 write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
1562}
1563
1564fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1575 #[cfg(unix)]
1576 crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
1577 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1578 #[cfg(not(unix))]
1579 crate::runtime::write_atomic(path, bytes)
1580 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1581 Ok(())
1582}
1583
1584pub fn init_config() -> Result<()> {
1586 let config_file = get_config_path()?;
1587
1588 if config_file.exists() {
1589 println!("Configuration already exists at: {}", config_file.display());
1590 } else {
1591 let default_config = Config::default();
1592 save_config(&default_config, Some(config_file.clone()))?;
1593 println!("Created configuration at: {}", config_file.display());
1594 }
1595
1596 Ok(())
1597}
1598
1599static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1605
1606fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
1614 update_user_config_table_at(&get_config_path()?, mutate)
1615}
1616
1617fn update_user_config_table_at(
1619 path: &std::path::Path,
1620 mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
1621) -> Result<()> {
1622 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1623 let mut table = read_config_table(path)?;
1624 migrate_legacy_max_tokens(&mut table);
1626 migrate_legacy_model_profiles(&mut table);
1627 mutate(&mut table)?;
1628 write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
1629}
1630
1631pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
1635 update_user_config_table(|table| deep_set_segments(table, path, value))
1636}
1637
1638pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
1643 update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
1644}
1645
1646pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
1649 let mut removed = false;
1650 update_user_config_table(|table| {
1651 removed = deep_remove_segments(table, path);
1652 Ok(())
1653 })?;
1654 Ok(removed)
1655}
1656
1657pub fn persist_last_model(model: &str) -> Result<()> {
1659 update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
1660}
1661
1662pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
1664 update_user_config_key(
1665 &["ui", "theme"],
1666 toml::Value::String(theme.as_str().to_string()),
1667 )
1668}
1669
1670pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
1673 update_user_config_key(
1674 &["default_model", "reasoning"],
1675 toml::Value::try_from(level)?,
1676 )
1677}
1678
1679pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
1685 update_user_config_key(
1686 &["reasoning_per_model", model_id],
1687 toml::Value::try_from(level)?,
1688 )
1689}
1690
1691pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
1694 match num_ctx {
1695 Some(n) => update_user_config_key(
1696 &["ollama_num_ctx_per_model", model_id],
1697 toml::Value::Integer(i64::from(n)),
1698 ),
1699 None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
1700 }
1701}
1702
1703pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
1705 update_user_config_key(
1706 &["ollama", "allow_ram_offload"],
1707 toml::Value::Boolean(enabled),
1708 )
1709}
1710
1711pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
1713 if let Some(model) = cli_model {
1714 if let Some(resolved) = resolve_model_alias(model, config)? {
1715 return Ok(resolved);
1716 }
1717 return Ok(model.to_string());
1718 }
1719 if let Some(last_model) = &config.last_used_model {
1720 if let Some(resolved) = resolve_model_alias(last_model, config)? {
1721 return Ok(resolved);
1722 }
1723 return Ok(last_model.clone());
1724 }
1725 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1726 return Ok(format!(
1727 "{}/{}",
1728 config.default_model.provider, config.default_model.name
1729 ));
1730 }
1731 let available = crate::ollama::require_any_model(config).await?;
1732 let first = available
1736 .first()
1737 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
1738 Ok(format!("ollama/{}", first))
1739}
1740
1741fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
1742 let alias = requested.strip_prefix("alias:").unwrap_or(requested);
1743 if let Some(model) = config.model_aliases.get(alias) {
1744 anyhow::ensure!(
1745 !model.trim().is_empty(),
1746 "model alias `{}` is configured with an empty model id",
1747 alias
1748 );
1749 return Ok(Some(model.clone()));
1750 }
1751 if requested.starts_with("alias:") {
1752 anyhow::bail!(
1753 "model alias `{}` is not configured; add it under [model_aliases]",
1754 alias
1755 );
1756 }
1757 Ok(None)
1758}
1759
1760#[cfg(test)]
1761mod tests {
1762 use super::*;
1763
1764 #[test]
1765 fn legacy_default_max_tokens_migrates_to_auto() {
1766 let mut table: toml::Table =
1768 toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
1769 migrate_legacy_max_tokens(&mut table);
1770 migrate_legacy_model_profiles(&mut table);
1771 let (config, _) = finalize_config(table).unwrap();
1772 assert_eq!(config.default_model.max_tokens, 0);
1773
1774 let mut table: toml::Table =
1776 toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
1777 migrate_legacy_max_tokens(&mut table);
1778 migrate_legacy_model_profiles(&mut table);
1779 let (config, _) = finalize_config(table).unwrap();
1780 assert_eq!(config.default_model.max_tokens, 8192);
1781
1782 let mut table = toml::Table::new();
1784 migrate_legacy_max_tokens(&mut table);
1785 migrate_legacy_model_profiles(&mut table);
1786 let (config, _) = finalize_config(table).unwrap();
1787 assert_eq!(config.default_model.max_tokens, 0);
1788 }
1789
1790 #[test]
1791 fn legacy_model_profiles_table_migrates_to_model_aliases() {
1792 let mut table: toml::Table =
1794 toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
1795 migrate_legacy_model_profiles(&mut table);
1796 let (config, ignored) = finalize_config(table).unwrap();
1797 assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
1798 assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
1799 let mut table: toml::Table =
1801 toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
1802 .unwrap();
1803 migrate_legacy_model_profiles(&mut table);
1804 let (config, ignored) = finalize_config(table).unwrap();
1805 assert_eq!(config.model_aliases["fast"], "new");
1806 assert!(ignored.is_empty());
1807 let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
1809 std::fs::create_dir_all(&dir).unwrap();
1810 let path = dir.join("config.toml");
1811 std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
1812 update_user_config_table_at(&path, |_| Ok(())).unwrap();
1813 let blob = std::fs::read_to_string(&path).unwrap();
1814 assert!(blob.contains("[model_aliases]"), "{blob}");
1815 assert!(!blob.contains("model_profiles"), "{blob}");
1816 let _ = std::fs::remove_dir_all(&dir);
1817 }
1818
1819 #[test]
1820 fn ui_theme_deserializes_defaults_and_rejects_typos() {
1821 let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
1822 assert_eq!(config.ui.theme, ThemeChoice::Light);
1823 let config: Config = toml::from_str("").unwrap();
1825 assert_eq!(config.ui.theme, ThemeChoice::Dark);
1826 assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
1827 assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
1829 }
1830
1831 #[test]
1832 fn finalize_config_flags_unknown_keys() {
1833 let table: toml::Table =
1834 toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
1835 .unwrap();
1836 let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
1837 assert_eq!(config.default_model.max_tokens, 512);
1838 assert!(
1839 ignored.iter().any(|p| p == "unknown_top"),
1840 "got {ignored:?}"
1841 );
1842 assert!(
1843 ignored.iter().any(|p| p.contains("bogus")),
1844 "got {ignored:?}"
1845 );
1846 }
1847
1848 #[test]
1849 fn cli_overrides_beat_file_and_create_nested_tables() {
1850 let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
1852 apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
1853 let (config, ignored) = finalize_config(table).unwrap();
1854 assert_eq!(config.default_model.max_tokens, 8192);
1855 assert!(ignored.is_empty());
1856 let mut empty = toml::Table::new();
1858 apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
1859 assert_eq!(
1860 finalize_config(empty).unwrap().0.default_model.max_tokens,
1861 256
1862 );
1863 }
1864
1865 #[test]
1866 fn parse_override_value_keeps_toml_types_with_string_fallback() {
1867 assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
1868 assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
1869 assert_eq!(
1870 parse_override_value("ollama/qwen"),
1871 toml::Value::String("ollama/qwen".to_string())
1872 );
1873 }
1874
1875 #[test]
1876 fn cli_override_invalid_format_errors() {
1877 let mut table = toml::Table::new();
1878 assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
1879 assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
1880 }
1881
1882 #[test]
1883 fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
1884 let mut base: toml::Table = toml::from_str(
1885 "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
1886 )
1887 .unwrap();
1888 let overlay: toml::Table =
1889 toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
1890 .unwrap();
1891 deep_merge(&mut base, overlay);
1892 assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
1894 assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
1896 assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
1898 assert_eq!(base["top"].as_integer(), Some(1));
1900 }
1901
1902 #[test]
1903 fn deep_merge_overlay_wins_on_kind_conflict() {
1904 let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
1906 let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
1907 deep_merge(&mut base, overlay);
1908 assert_eq!(base["a"].as_integer(), Some(5));
1909 assert_eq!(base["b"]["y"].as_integer(), Some(3));
1910 }
1911
1912 #[test]
1913 fn merge_layers_precedence_and_layer_attributed_warnings() {
1914 let user: toml::Table = toml::from_str(
1915 "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
1916 )
1917 .unwrap();
1918 let session: toml::Table =
1919 toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
1920 let (config, warnings) = merge_layers(vec![
1921 LayerSource {
1922 layer: ConfigLayer::User,
1923 origin: "/tmp/user.toml".to_string(),
1924 table: user,
1925 },
1926 LayerSource {
1927 layer: ConfigLayer::Session,
1928 origin: "command line".to_string(),
1929 table: session,
1930 },
1931 ])
1932 .expect("merges");
1933 assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
1935 assert_eq!(config.default_model.max_tokens, 100);
1936 assert!(
1938 warnings
1939 .iter()
1940 .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
1941 "got {warnings:?}"
1942 );
1943 assert!(
1944 warnings
1945 .iter()
1946 .any(|w| w.contains("session_typo") && w.contains("session flags")),
1947 "got {warnings:?}"
1948 );
1949 }
1950
1951 #[test]
1952 fn take_profiles_excises_and_tolerates_absence() {
1953 let mut table: toml::Table =
1954 toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
1955 let profiles = take_profiles(&mut table);
1956 assert!(table.is_empty(), "profiles must be excised: {table:?}");
1957 assert!(profiles.contains_key("fast"));
1958 let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
1960 assert!(take_profiles(&mut table).is_empty());
1961 assert_eq!(table.len(), 1);
1962 let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
1964 assert!(take_profiles(&mut table).is_empty());
1965 assert!(table.is_empty());
1966 }
1967
1968 #[test]
1969 fn resolve_profile_layer_errors_name_available_profiles() {
1970 let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
1971 let path = std::path::Path::new("/tmp/config.toml");
1972 let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
1973 assert!(err.to_string().contains("available: fast, work"), "{err}");
1974 let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
1976 assert!(
1977 err.to_string().contains("no config profiles defined"),
1978 "{err}"
1979 );
1980 let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
1982 let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
1983 assert!(err.to_string().contains("not a table"), "{err}");
1984 let profiles: toml::Table =
1986 toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
1987 let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
1988 assert_eq!(layer.layer, ConfigLayer::Profile);
1989 assert!(layer.origin.contains("profile:work"));
1990 }
1991
1992 #[test]
1993 fn profile_layer_beats_user_loses_to_project_and_session() {
1994 let user: toml::Table = toml::from_str(
1995 "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
1996 )
1997 .unwrap();
1998 let profile: toml::Table = toml::from_str(
1999 "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
2000 )
2001 .unwrap();
2002 let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
2003 let session: toml::Table =
2004 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2005 let (config, warnings) = merge_layers(vec![
2006 LayerSource {
2007 layer: ConfigLayer::User,
2008 origin: "/tmp/user.toml".to_string(),
2009 table: user,
2010 },
2011 LayerSource {
2012 layer: ConfigLayer::Profile,
2013 origin: "profile:work (/tmp/user.toml)".to_string(),
2014 table: profile,
2015 },
2016 LayerSource {
2017 layer: ConfigLayer::Project,
2018 origin: "/repo/.mermaid/config.toml".to_string(),
2019 table: project,
2020 },
2021 LayerSource {
2022 layer: ConfigLayer::Session,
2023 origin: "command line".to_string(),
2024 table: session,
2025 },
2026 ])
2027 .expect("merges");
2028 assert_eq!(config.default_model.temperature, 0.5);
2031 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2032 assert_eq!(config.default_model.max_tokens, 100);
2033 assert!(
2035 warnings.iter().any(|w| w.contains("profile_typo")
2036 && w.contains("config profile (profile:work (/tmp/user.toml))")),
2037 "got {warnings:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn persists_never_touch_profile_tables() {
2043 let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
2044 std::fs::create_dir_all(&dir).expect("create temp dir");
2045 let path = dir.join("config.toml");
2046 std::fs::write(
2047 &path,
2048 "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
2049 )
2050 .expect("seed");
2051
2052 update_user_config_table_at(&path, |table| {
2053 deep_set_segments(
2054 table,
2055 &["safety", "mode"],
2056 toml::Value::String("auto".to_string()),
2057 )
2058 })
2059 .expect("persist");
2060
2061 let table: toml::Table =
2062 toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
2063 assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
2064 assert_eq!(
2066 table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
2067 Some(0.1)
2068 );
2069 let _ = std::fs::remove_dir_all(&dir);
2070 }
2071
2072 #[test]
2073 fn session_flags_table_maps_each_flag() {
2074 let flags = SessionFlags {
2075 overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
2076 deny_network: true,
2077 confine_fs: true,
2078 max_tokens: Some(512),
2079 allow_untrusted_tools: true,
2080 profile: None,
2081 };
2082 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2083 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2084 assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
2085 assert_eq!(config.default_model.max_tokens, 512);
2086 assert!(config.safety.allow_untrusted_headless_tools);
2087 assert_eq!(config.web.searxng_url, "http://x:1");
2088 }
2089
2090 #[test]
2091 fn session_dedicated_flags_beat_dash_c() {
2092 let flags = SessionFlags {
2095 overrides: vec!["safety.network=allow".to_string()],
2096 deny_network: true,
2097 ..Default::default()
2098 };
2099 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2100 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2101 }
2102
2103 #[test]
2104 fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
2105 let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
2107 let mut warnings = Vec::new();
2108 collect_layer_warnings(
2109 &LayerSource {
2110 layer: ConfigLayer::User,
2111 origin: "x".to_string(),
2112 table: bad.clone(),
2113 },
2114 &mut warnings,
2115 );
2116 assert!(warnings.is_empty());
2117 assert!(
2119 merge_layers(vec![LayerSource {
2120 layer: ConfigLayer::User,
2121 origin: "x".to_string(),
2122 table: bad.clone(),
2123 }])
2124 .is_err()
2125 );
2126 let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
2128 let (config, _) = merge_layers(vec![
2129 LayerSource {
2130 layer: ConfigLayer::User,
2131 origin: "x".to_string(),
2132 table: bad,
2133 },
2134 LayerSource {
2135 layer: ConfigLayer::Session,
2136 origin: "command line".to_string(),
2137 table: fix,
2138 },
2139 ])
2140 .expect("later layer repairs the earlier one");
2141 assert_eq!(config.safety.mode, SafetyMode::Ask);
2142 }
2143
2144 #[test]
2145 fn project_layer_beats_user_and_loses_to_session() {
2146 let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
2147 let project: toml::Table = toml::from_str(
2148 "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
2149 )
2150 .unwrap();
2151 let session: toml::Table =
2152 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2153 let (config, _) = merge_layers(vec![
2154 LayerSource {
2155 layer: ConfigLayer::User,
2156 origin: "user".to_string(),
2157 table: user,
2158 },
2159 LayerSource {
2160 layer: ConfigLayer::Project,
2161 origin: "project".to_string(),
2162 table: project,
2163 },
2164 LayerSource {
2165 layer: ConfigLayer::Session,
2166 origin: "command line".to_string(),
2167 table: session,
2168 },
2169 ])
2170 .expect("merges");
2171 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2173 assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
2175 }
2176
2177 #[test]
2178 fn session_flags_survive_corrupt_user_layer_fallback() {
2179 let flags = SessionFlags {
2182 deny_network: true,
2183 ..Default::default()
2184 };
2185 let config = flags
2186 .to_table()
2187 .ok()
2188 .and_then(|table| finalize_config(table).ok())
2189 .map(|(config, _)| config)
2190 .unwrap_or_default();
2191 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2192 }
2193
2194 #[test]
2195 fn deep_set_segments_addresses_keys_containing_dots() {
2196 let mut table = toml::Table::new();
2199 deep_set_segments(
2200 &mut table,
2201 &["reasoning_per_model", "gemini/gemini-2.5-pro"],
2202 toml::Value::String("high".to_string()),
2203 )
2204 .unwrap();
2205 let (config, ignored) = finalize_config(table).unwrap();
2206 assert!(ignored.is_empty(), "got {ignored:?}");
2207 assert_eq!(
2208 config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
2209 Some(&ReasoningLevel::High)
2210 );
2211 }
2212
2213 #[test]
2214 fn deep_remove_segments_removes_leaf_only() {
2215 let mut table: toml::Table =
2216 toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
2217 .unwrap();
2218 assert!(deep_remove_segments(
2219 &mut table,
2220 &["ollama_num_ctx_per_model", "ollama/a"]
2221 ));
2222 assert_eq!(
2224 table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
2225 Some(2)
2226 );
2227 assert!(!deep_remove_segments(
2228 &mut table,
2229 &["ollama_num_ctx_per_model", "ollama/a"]
2230 ));
2231 assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
2232 }
2233
2234 #[test]
2235 fn update_user_config_table_preserves_unknown_keys() {
2236 let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
2237 std::fs::create_dir_all(&dir).expect("create temp dir");
2238 let path = dir.join("config.toml");
2239 std::fs::write(
2242 &path,
2243 "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
2244 )
2245 .expect("seed");
2246
2247 update_user_config_table_at(&path, |table| {
2248 deep_set_segments(
2249 table,
2250 &["last_used_model"],
2251 toml::Value::String("ollama/new".to_string()),
2252 )
2253 })
2254 .expect("persist");
2255
2256 let blob = std::fs::read_to_string(&path).expect("read back");
2257 let table: toml::Table = toml::from_str(&blob).expect("parse back");
2258 assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
2260 assert_eq!(table["future_key"].as_str(), Some("kept"));
2262 assert!(!blob.contains("safety"), "defaults must not be frozen in");
2264 assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
2265
2266 let _ = std::fs::remove_dir_all(&dir);
2267 }
2268
2269 #[test]
2270 fn mcp_tool_allowed_honors_enabled_and_disabled() {
2271 let cfg = McpServerConfig::default();
2273 assert!(cfg.tool_allowed("anything"));
2274 let cfg = McpServerConfig {
2276 enabled_tools: vec!["read".into(), "search".into()],
2277 ..Default::default()
2278 };
2279 assert!(cfg.tool_allowed("read"));
2280 assert!(!cfg.tool_allowed("write"));
2281 let cfg = McpServerConfig {
2283 enabled_tools: vec!["read".into(), "write".into()],
2284 disabled_tools: vec!["write".into()],
2285 ..Default::default()
2286 };
2287 assert!(cfg.tool_allowed("read"));
2288 assert!(!cfg.tool_allowed("write"));
2289 }
2290
2291 #[test]
2292 fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
2293 let cfg = McpServerConfig {
2295 command: "npx".to_string(),
2296 ..Default::default()
2297 };
2298 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
2299 let cfg = McpServerConfig {
2301 url: Some("https://example.com/mcp".to_string()),
2302 ..Default::default()
2303 };
2304 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
2305 let cfg = McpServerConfig {
2307 command: "npx".to_string(),
2308 url: Some("https://example.com/mcp".to_string()),
2309 ..Default::default()
2310 };
2311 assert!(
2312 cfg.transport_kind()
2313 .unwrap_err()
2314 .to_string()
2315 .contains("mutually exclusive")
2316 );
2317 let cfg = McpServerConfig::default();
2319 assert!(
2320 cfg.transport_kind()
2321 .unwrap_err()
2322 .to_string()
2323 .contains("neither")
2324 );
2325 }
2326
2327 #[test]
2328 fn mcp_transport_kind_gates_url_scheme() {
2329 let with_url = |url: &str| McpServerConfig {
2330 url: Some(url.to_string()),
2331 ..Default::default()
2332 };
2333 assert!(
2336 with_url("https://mcp.example.com/x")
2337 .transport_kind()
2338 .is_ok()
2339 );
2340 assert!(
2341 with_url("http://localhost:8080/mcp")
2342 .transport_kind()
2343 .is_ok()
2344 );
2345 assert!(
2346 with_url("http://127.0.0.1:8080/mcp")
2347 .transport_kind()
2348 .is_ok()
2349 );
2350 assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
2351 assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
2352 assert!(with_url("not a url").transport_kind().is_err());
2353 }
2354
2355 #[test]
2356 fn mcp_server_config_debug_masks_header_values() {
2357 let mut headers = HashMap::new();
2358 headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
2359 let mut env_headers = HashMap::new();
2360 env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
2361 let cfg = McpServerConfig {
2362 url: Some("https://example.com/mcp".to_string()),
2363 headers,
2364 env_headers,
2365 ..Default::default()
2366 };
2367 let rendered = format!("{cfg:?}");
2368 assert!(!rendered.contains("sk-secret"), "{rendered}");
2369 assert!(rendered.contains("Authorization"), "{rendered}");
2370 assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
2372 }
2373
2374 #[test]
2375 fn mcp_url_config_round_trips_through_toml_without_command() {
2376 let cfg = McpServerConfig {
2379 url: Some("https://example.com/mcp".to_string()),
2380 ..Default::default()
2381 };
2382 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2383 assert!(
2384 !blob.contains("command"),
2385 "empty command must be omitted: {blob}"
2386 );
2387 let back: McpServerConfig = toml::from_str(&blob).unwrap();
2388 assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
2389 assert!(back.command.is_empty());
2390 let cfg = McpServerConfig {
2392 command: "npx".to_string(),
2393 ..Default::default()
2394 };
2395 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2396 assert!(!blob.contains("url"), "{blob}");
2397 }
2398
2399 #[test]
2404 fn model_settings_deserializes_without_reasoning_field() {
2405 let toml_blob = r#"
2406 provider = "ollama"
2407 name = "qwen3-coder:30b"
2408 temperature = 0.7
2409 max_tokens = 4096
2410 "#;
2411 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
2412 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
2413 assert_eq!(settings.provider, "ollama");
2414 }
2415
2416 #[test]
2417 fn model_settings_round_trips_reasoning_high() {
2418 let original = ModelSettings {
2419 provider: "anthropic".to_string(),
2420 name: "claude-sonnet-4-6".to_string(),
2421 temperature: 0.5,
2422 max_tokens: 8192,
2423 reasoning: ReasoningLevel::High,
2424 };
2425 let toml_blob = toml::to_string(&original).expect("serialize");
2426 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
2427 assert_eq!(back.reasoning, ReasoningLevel::High);
2428 assert_eq!(back.name, "claude-sonnet-4-6");
2429 }
2430
2431 #[test]
2432 fn agents_config_defaults_and_parses_custom_types() {
2433 let config: Config = toml::from_str("").expect("empty config parses");
2435 assert_eq!(config.agents.timeout_secs, 1200);
2436 assert!(config.agents.types.is_empty());
2437
2438 let config: Config = toml::from_str(
2439 r#"
2440[agents]
2441timeout_secs = 300
2442
2443[agents.types.scout]
2444tools = ["read_file", "execute_command"]
2445safety = "read_only"
2446preamble = "You are a scout."
2447model = "ollama/qwen3:8b"
2448"#,
2449 )
2450 .expect("agents section parses");
2451 assert_eq!(config.agents.timeout_secs, 300);
2452 let scout = &config.agents.types["scout"];
2453 assert_eq!(
2454 scout.tools.as_deref(),
2455 Some(&["read_file".to_string(), "execute_command".to_string()][..])
2456 );
2457 assert_eq!(scout.safety.as_deref(), Some("read_only"));
2458 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
2459 }
2460
2461 #[test]
2462 fn configured_model_alias_resolves_explicit_prefix() {
2463 let mut config = Config::default();
2464 config
2465 .model_aliases
2466 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
2467 assert_eq!(
2468 resolve_model_alias("fast", &config).unwrap(),
2469 Some("ollama/qwen3-coder:14b".to_string())
2470 );
2471 assert_eq!(
2472 resolve_model_alias("alias:fast", &config).unwrap(),
2473 Some("ollama/qwen3-coder:14b".to_string())
2474 );
2475 }
2476
2477 #[test]
2478 fn alias_prefix_requires_configuration() {
2479 let config = Config::default();
2480 assert!(resolve_model_alias("alias:vision", &config).is_err());
2481 assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
2482 }
2483
2484 #[test]
2491 fn save_and_reload_preserves_reasoning_field() {
2492 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
2493 std::fs::create_dir_all(&dir).expect("create temp dir");
2494 let path = dir.join("config.toml");
2495
2496 let mut cfg = Config::default();
2497 cfg.default_model.provider = "ollama".to_string();
2498 cfg.default_model.name = "qwen3-coder:30b".to_string();
2499 cfg.default_model.reasoning = ReasoningLevel::Low;
2500
2501 save_config(&cfg, Some(path.clone())).expect("save");
2502
2503 let blob = std::fs::read_to_string(&path).expect("read");
2504 let loaded: Config = toml::from_str(&blob).expect("parse back");
2505 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
2506
2507 let _ = std::fs::remove_dir_all(&dir);
2508 }
2509
2510 #[test]
2515 fn save_and_reload_preserves_reasoning_per_model_table() {
2516 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
2517 std::fs::create_dir_all(&dir).expect("create temp dir");
2518 let path = dir.join("config.toml");
2519
2520 let mut cfg = Config::default();
2521 cfg.reasoning_per_model.insert(
2522 "anthropic/claude-sonnet-4-6".to_string(),
2523 ReasoningLevel::High,
2524 );
2525 cfg.reasoning_per_model
2526 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
2527
2528 save_config(&cfg, Some(path.clone())).expect("save");
2529
2530 let blob = std::fs::read_to_string(&path).expect("read");
2531 let loaded: Config = toml::from_str(&blob).expect("parse back");
2532 assert_eq!(
2533 loaded
2534 .reasoning_per_model
2535 .get("anthropic/claude-sonnet-4-6"),
2536 Some(&ReasoningLevel::High)
2537 );
2538 assert_eq!(
2539 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
2540 Some(&ReasoningLevel::Low)
2541 );
2542
2543 let _ = std::fs::remove_dir_all(&dir);
2544 }
2545
2546 #[test]
2549 fn save_and_reload_preserves_ollama_context_overrides() {
2550 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
2551 std::fs::create_dir_all(&dir).expect("create temp dir");
2552 let path = dir.join("config.toml");
2553
2554 let mut cfg = Config::default();
2555 cfg.ollama_num_ctx_per_model
2556 .insert("ollama/ornith:9b".to_string(), 131_072);
2557 cfg.ollama.allow_ram_offload = true;
2558 cfg.ollama.max_auto_num_ctx = Some(65_536);
2559
2560 save_config(&cfg, Some(path.clone())).expect("save");
2561 let blob = std::fs::read_to_string(&path).expect("read");
2562 let loaded: Config = toml::from_str(&blob).expect("parse back");
2563
2564 assert_eq!(
2565 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
2566 Some(&131_072)
2567 );
2568 assert!(loaded.ollama.allow_ram_offload);
2569 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
2570
2571 let _ = std::fs::remove_dir_all(&dir);
2572 }
2573
2574 #[test]
2577 fn config_deserializes_without_ollama_context_keys() {
2578 let toml_blob = r#"
2579[ollama]
2580host = "localhost"
2581port = 11434
2582"#;
2583 let cfg: Config = toml::from_str(toml_blob).expect("parse");
2584 assert!(cfg.ollama_num_ctx_per_model.is_empty());
2585 assert!(!cfg.ollama.allow_ram_offload);
2586 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
2587 assert!(cfg.ollama.auto_start);
2590 }
2591
2592 #[test]
2596 fn config_deserializes_without_reasoning_per_model() {
2597 let toml_blob = r#"
2598 last_used_model = "ollama/qwen3-coder:30b"
2599
2600 [default_model]
2601 provider = "ollama"
2602 name = "qwen3-coder:30b"
2603 temperature = 0.7
2604 max_tokens = 4096
2605 "#;
2606 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
2607 assert!(cfg.reasoning_per_model.is_empty());
2608 assert!(!cfg.prompt.is_customized());
2609 }
2610
2611 #[cfg(unix)]
2615 #[test]
2616 fn save_config_writes_owner_only_perms() {
2617 use std::os::unix::fs::PermissionsExt;
2618 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
2619 std::fs::create_dir_all(&dir).expect("create temp dir");
2620 let path = dir.join("config.toml");
2621 std::fs::write(&path, "stale").expect("seed");
2623 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
2624
2625 save_config(&Config::default(), Some(path.clone())).expect("save");
2626 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2627 assert_eq!(mode, 0o600, "config must be written owner-only");
2628
2629 let _ = std::fs::remove_dir_all(&dir);
2630 }
2631
2632 #[test]
2633 fn config_defaults_computer_use_auto_screenshot_on() {
2634 let cfg: Config = toml::from_str("").expect("empty config");
2636 assert!(cfg.computer_use.auto_screenshot);
2637 }
2638
2639 #[test]
2640 fn prompt_config_replaces_and_appends_without_persisting() {
2641 let mut cfg = Config::default();
2642 cfg.prompt.system_prompt = Some("base".to_string());
2643 cfg.prompt
2644 .append_system_prompt
2645 .push("extra instructions".to_string());
2646
2647 assert_eq!(
2648 cfg.prompt.render_system_prompt("default"),
2649 "base\n\nextra instructions"
2650 );
2651
2652 let blob = toml::to_string(&cfg).expect("serialize");
2653 assert!(!blob.contains("extra instructions"));
2654 let loaded: Config = toml::from_str(&blob).expect("deserialize");
2655 assert!(!loaded.prompt.is_customized());
2656 }
2657
2658 #[test]
2659 fn plan_config_defaults_parse_and_do_not_freeze() {
2660 let c: Config = toml::from_str("").expect("empty config parses");
2662 assert!(!c.plan.auto_approve);
2663 assert!(c.plan.post_approve.is_none());
2664 let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
2666 .expect("plan section parses");
2667 assert!(c.plan.auto_approve);
2668 assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
2669 assert_eq!(
2670 toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
2671 .expect("wait parses")
2672 .plan
2673 .post_approve,
2674 Some(PlanPostApprove::Wait)
2675 );
2676 let blob = toml::to_string(&Config::default()).expect("serialize");
2680 assert!(!blob.contains("post_approve"));
2681 }
2682}