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 let mut rendered = self
222 .system_prompt
223 .as_deref()
224 .unwrap_or(default_prompt)
225 .trim_end()
226 .to_string();
227
228 for extra in &self.append_system_prompt {
229 let extra = extra.trim();
230 if extra.is_empty() {
231 continue;
232 }
233 if !rendered.is_empty() {
234 rendered.push_str("\n\n");
235 }
236 rendered.push_str(extra);
237 }
238
239 rendered
240 }
241
242 pub fn is_customized(&self) -> bool {
243 self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
244 }
245}
246
247#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum NetworkPolicy {
253 #[default]
254 Allow,
255 Deny,
256}
257
258#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
264#[serde(rename_all = "snake_case")]
265pub enum FilesystemPolicy {
266 #[default]
267 Unrestricted,
268 Project,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(default)]
273pub struct SafetyConfig {
274 pub mode: SafetyMode,
275 pub checkpoint_on_mutation: bool,
276 #[serde(default)]
279 pub network: NetworkPolicy,
280 #[serde(default)]
284 pub filesystem: FilesystemPolicy,
285 #[serde(default)]
286 pub overrides: Vec<PolicyOverride>,
287 #[serde(default)]
294 pub external_writes: crate::runtime::FloorLevel,
295 #[serde(default)]
302 pub system_installs: crate::runtime::FloorLevel,
303 #[serde(default)]
307 pub auto_classifier_model: Option<String>,
308 #[serde(default)]
314 pub allow_untrusted_headless_tools: bool,
315}
316
317impl Default for SafetyConfig {
318 fn default() -> Self {
319 Self {
320 mode: SafetyMode::Ask,
324 checkpoint_on_mutation: true,
325 network: NetworkPolicy::default(),
326 filesystem: FilesystemPolicy::default(),
327 overrides: Vec::new(),
328 external_writes: crate::runtime::FloorLevel::default(),
329 system_installs: crate::runtime::FloorLevel::default(),
330 auto_classifier_model: None,
331 allow_untrusted_headless_tools: false,
332 }
333 }
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(default)]
339pub struct DaemonConfig {
340 pub max_concurrent_tasks: usize,
345 pub task_timeout_minutes: Option<u64>,
350 pub retention_days: i64,
354 pub outcomes_retention_days: i64,
359 pub scratchpad_retention_days: i64,
364}
365
366impl Default for DaemonConfig {
367 fn default() -> Self {
368 Self {
369 max_concurrent_tasks: 1,
370 task_timeout_minutes: None,
371 retention_days: 30,
372 outcomes_retention_days: 180,
373 scratchpad_retention_days: crate::session::scratchpad::RETENTION_DAYS as i64,
374 }
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum PlanPostApprove {
382 Start,
384 Wait,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393#[serde(rename_all = "snake_case")]
394pub enum PlanPermLevel {
395 Allow,
396 Auto,
397 Ask,
398 Deny,
399}
400
401impl PlanPermLevel {
402 pub fn as_str(self) -> &'static str {
403 match self {
404 PlanPermLevel::Allow => "allow",
405 PlanPermLevel::Auto => "auto",
406 PlanPermLevel::Ask => "ask",
407 PlanPermLevel::Deny => "deny",
408 }
409 }
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(default)]
418pub struct PlanPermissions {
419 pub builds: PlanPermLevel,
421 pub web: PlanPermLevel,
423 pub memory: PlanPermLevel,
425 pub tasks: PlanPermLevel,
430}
431
432impl Default for PlanPermissions {
433 fn default() -> Self {
434 Self {
435 builds: PlanPermLevel::Allow,
436 web: PlanPermLevel::Allow,
437 memory: PlanPermLevel::Allow,
438 tasks: PlanPermLevel::Deny,
439 }
440 }
441}
442
443impl PlanPermissions {
444 pub fn preset_name(&self) -> Option<&'static str> {
447 if *self == Self::default() {
448 Some("default")
449 } else if *self == Self::strict() {
450 Some("strict")
451 } else if *self == Self::open() {
452 Some("open")
453 } else {
454 None
455 }
456 }
457
458 pub fn strict() -> Self {
460 Self {
461 builds: PlanPermLevel::Deny,
462 web: PlanPermLevel::Deny,
463 memory: PlanPermLevel::Deny,
464 tasks: PlanPermLevel::Deny,
465 }
466 }
467
468 pub fn open() -> Self {
470 Self {
471 builds: PlanPermLevel::Allow,
472 web: PlanPermLevel::Allow,
473 memory: PlanPermLevel::Allow,
474 tasks: PlanPermLevel::Allow,
475 }
476 }
477}
478
479#[derive(Debug, Clone, Default, Serialize, Deserialize)]
481#[serde(default)]
482pub struct PlanConfig {
483 pub auto_approve: bool,
487 #[serde(skip_serializing_if = "Option::is_none")]
493 pub post_approve: Option<PlanPostApprove>,
494 pub permissions: PlanPermissions,
499 #[serde(skip_serializing_if = "Option::is_none")]
504 pub model: Option<String>,
505 #[serde(skip_serializing_if = "Option::is_none")]
507 pub reasoning: Option<crate::models::ReasoningLevel>,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default)]
513pub struct MemoryConfig {
514 pub enabled: bool,
517 pub index_cap_bytes: usize,
519}
520
521impl Default for MemoryConfig {
522 fn default() -> Self {
523 Self {
524 enabled: true,
525 index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
526 }
527 }
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532#[serde(default)]
533pub struct CompactionConfig {
534 pub max_truncation_recoveries: u8,
546}
547
548impl Default for CompactionConfig {
549 fn default() -> Self {
550 Self {
551 max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
552 }
553 }
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
558#[serde(default)]
559pub struct ComputerUseConfig {
560 pub auto_screenshot: bool,
566}
567
568impl Default for ComputerUseConfig {
569 fn default() -> Self {
570 Self {
571 auto_screenshot: true,
572 }
573 }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(default)]
579pub struct AgentsConfig {
580 pub timeout_secs: u64,
583 pub types: HashMap<String, AgentTypeConfig>,
594}
595
596impl Default for AgentsConfig {
597 fn default() -> Self {
598 Self {
599 timeout_secs: 1200,
600 types: HashMap::new(),
601 }
602 }
603}
604
605#[derive(Debug, Clone, Default, Serialize, Deserialize)]
608#[serde(default)]
609pub struct AgentTypeConfig {
610 pub tools: Option<Vec<String>>,
615 pub safety: Option<String>,
619 pub preamble: Option<String>,
622 pub model: Option<String>,
625}
626
627#[derive(Clone, Default, Serialize, Deserialize)]
631pub struct UserProviderConfig {
632 #[serde(default)]
635 pub base_url: Option<String>,
636 #[serde(default)]
640 pub api_key_env: Option<String>,
641 #[serde(default)]
643 pub extra_headers: HashMap<String, String>,
644 #[serde(default)]
649 pub env_headers: HashMap<String, String>,
650 #[serde(default)]
656 pub compat: Option<String>,
657 #[serde(default)]
661 pub default_model: Option<String>,
662}
663
664#[derive(Clone, Default, Serialize, Deserialize)]
666pub struct McpServerConfig {
667 #[serde(default, skip_serializing_if = "String::is_empty")]
670 pub command: String,
671 #[serde(default)]
673 pub args: Vec<String>,
674 #[serde(default)]
676 pub env: HashMap<String, String>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub url: Option<String>,
682 #[serde(default)]
685 pub headers: HashMap<String, String>,
686 #[serde(default)]
691 pub env_headers: HashMap<String, String>,
692 #[serde(default)]
696 pub allow_private_network: bool,
697 #[serde(default)]
699 pub enabled_tools: Vec<String>,
700 #[serde(default)]
702 pub disabled_tools: Vec<String>,
703 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub defer: Option<bool>,
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714pub enum TransportKind {
715 Stdio,
716 Http,
717}
718
719impl McpServerConfig {
720 pub fn transport_kind(&self) -> Result<TransportKind> {
725 match (&self.url, self.command.is_empty()) {
726 (Some(_), false) => Err(anyhow::anyhow!(
727 "MCP server config sets both `command` and `url`; they are mutually exclusive"
728 )),
729 (None, true) => Err(anyhow::anyhow!(
730 "MCP server config sets neither `command` nor `url`"
731 )),
732 (None, false) => Ok(TransportKind::Stdio),
733 (Some(url), true) => {
734 let parsed = reqwest::Url::parse(url)
735 .map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
736 let host = parsed.host_str().unwrap_or("");
737 match parsed.scheme() {
738 "https" => Ok(TransportKind::Http),
739 "http" if crate::utils::classify_host(host).is_loopback() => {
740 Ok(TransportKind::Http)
741 },
742 "http" => Err(anyhow::anyhow!(
743 "MCP server url '{url}' uses plaintext http to a non-loopback host; \
744 use https (auth headers would travel in cleartext)"
745 )),
746 other => Err(anyhow::anyhow!(
747 "MCP server url '{url}' has unsupported scheme '{other}' \
748 (expected https, or http to loopback)"
749 )),
750 }
751 },
752 }
753 }
754
755 pub fn tool_allowed(&self, tool_name: &str) -> bool {
759 if self.disabled_tools.iter().any(|t| t == tool_name) {
760 return false;
761 }
762 self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
763 }
764}
765
766fn debug_masked_map(
770 map: &HashMap<String, String>,
771) -> std::collections::BTreeMap<&str, &'static str> {
772 map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
773}
774
775impl std::fmt::Debug for McpServerConfig {
780 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
781 f.debug_struct("McpServerConfig")
782 .field("command", &self.command)
783 .field(
785 "args",
786 &self
787 .args
788 .iter()
789 .map(|a| crate::utils::redact_secrets(a))
790 .collect::<Vec<_>>(),
791 )
792 .field("env", &debug_masked_map(&self.env))
793 .field("url", &self.url)
794 .field("headers", &debug_masked_map(&self.headers))
796 .field("env_headers", &self.env_headers)
798 .field("allow_private_network", &self.allow_private_network)
799 .field("enabled_tools", &self.enabled_tools)
801 .field("disabled_tools", &self.disabled_tools)
802 .finish()
803 }
804}
805
806impl std::fmt::Debug for UserProviderConfig {
807 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
808 f.debug_struct("UserProviderConfig")
809 .field("base_url", &self.base_url)
810 .field("api_key_env", &self.api_key_env)
811 .field("extra_headers", &debug_masked_map(&self.extra_headers))
812 .field("env_headers", &self.env_headers)
814 .field("compat", &self.compat)
815 .field("default_model", &self.default_model)
816 .finish()
817 }
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize)]
822#[serde(default)]
823pub struct ModelSettings {
824 pub provider: String,
826 pub name: String,
828 pub temperature: f32,
830 pub max_tokens: usize,
832 pub reasoning: ReasoningLevel,
836}
837
838impl Default for ModelSettings {
839 fn default() -> Self {
840 Self {
841 provider: String::new(),
842 name: String::new(),
843 temperature: DEFAULT_TEMPERATURE,
844 max_tokens: 0,
848 reasoning: ReasoningLevel::default(),
849 }
850 }
851}
852
853#[derive(Debug, Clone, Serialize, Deserialize)]
855#[serde(default)]
856pub struct OllamaConfig {
857 pub host: String,
859 pub port: u16,
861 pub num_gpu: Option<i32>,
864 pub num_thread: Option<i32>,
867 pub num_ctx: Option<i32>,
870 pub numa: Option<bool>,
872 pub allow_ram_offload: bool,
878 pub max_auto_num_ctx: Option<usize>,
882 pub auto_start: bool,
887}
888
889impl Default for OllamaConfig {
890 fn default() -> Self {
891 Self {
892 host: String::from("localhost"),
893 port: DEFAULT_OLLAMA_PORT,
894 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, auto_start: true, }
902 }
903}
904
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
907#[serde(rename_all = "lowercase")]
908pub enum FetchBackend {
909 #[default]
912 Native,
913 Ollama,
915}
916
917#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
919#[serde(rename_all = "lowercase")]
920pub enum SearchBackend {
921 #[default]
925 Auto,
926 Ollama,
928 Searxng,
930}
931
932#[derive(Debug, Clone, Serialize, Deserialize)]
941#[serde(default)]
942pub struct WebConfig {
943 pub fetch_backend: FetchBackend,
946 pub search_backend: SearchBackend,
951 pub searxng_url: String,
956}
957
958impl Default for WebConfig {
959 fn default() -> Self {
960 Self {
961 fetch_backend: FetchBackend::Native,
962 search_backend: SearchBackend::Auto,
963 searxng_url: String::from("http://localhost:8080"),
964 }
965 }
966}
967
968#[derive(Debug, Clone, Serialize, Deserialize)]
970#[serde(default)]
971pub struct NonInteractiveConfig {
972 pub output_format: String,
974 pub max_tokens: usize,
976 pub no_execute: bool,
978}
979
980impl Default for NonInteractiveConfig {
981 fn default() -> Self {
982 Self {
983 output_format: String::from("text"),
984 max_tokens: 0,
986 no_execute: false,
987 }
988 }
989}
990
991#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
995pub enum ConfigLayer {
996 Defaults = 0,
999 User = 1,
1002 Profile = 2,
1006 Project = 3,
1009 Session = 4,
1013}
1014
1015impl ConfigLayer {
1016 fn name(self) -> &'static str {
1018 match self {
1019 ConfigLayer::Defaults => "defaults",
1020 ConfigLayer::User => "user config",
1021 ConfigLayer::Profile => "config profile",
1022 ConfigLayer::Project => "project config",
1023 ConfigLayer::Session => "session flags",
1024 }
1025 }
1026}
1027
1028#[derive(Debug, Clone)]
1030pub(crate) struct LayerSource {
1031 pub layer: ConfigLayer,
1033 pub origin: String,
1035 pub table: toml::Table,
1038}
1039
1040#[derive(Debug, Clone, Default)]
1043pub struct SessionFlags {
1044 pub overrides: Vec<String>,
1047 pub deny_network: bool,
1049 pub confine_fs: bool,
1051 pub max_tokens: Option<usize>,
1053 pub allow_untrusted_tools: bool,
1055 pub profile: Option<String>,
1059}
1060
1061impl SessionFlags {
1062 pub(crate) fn to_table(&self) -> Result<toml::Table> {
1066 let mut table = toml::Table::new();
1067 apply_cli_overrides(&mut table, &self.overrides)?;
1068 if self.deny_network {
1069 deep_set_segments(
1070 &mut table,
1071 &["safety", "network"],
1072 toml::Value::String("deny".into()),
1073 )?;
1074 }
1075 if self.confine_fs {
1076 deep_set_segments(
1077 &mut table,
1078 &["safety", "filesystem"],
1079 toml::Value::String("project".into()),
1080 )?;
1081 }
1082 if let Some(n) = self.max_tokens {
1083 deep_set_segments(
1084 &mut table,
1085 &["default_model", "max_tokens"],
1086 toml::Value::Integer(n as i64),
1087 )?;
1088 }
1089 if self.allow_untrusted_tools {
1090 deep_set_segments(
1091 &mut table,
1092 &["safety", "allow_untrusted_headless_tools"],
1093 toml::Value::Boolean(true),
1094 )?;
1095 }
1096 Ok(table)
1097 }
1098}
1099
1100fn take_profiles(table: &mut toml::Table) -> toml::Table {
1107 match table.remove("profiles") {
1108 Some(toml::Value::Table(profiles)) => profiles,
1109 _ => toml::Table::new(),
1112 }
1113}
1114
1115fn resolve_profile_layer(
1119 profiles: &toml::Table,
1120 name: &str,
1121 config_path: &std::path::Path,
1122) -> Result<LayerSource> {
1123 match profiles.get(name) {
1124 Some(toml::Value::Table(overlay)) => Ok(LayerSource {
1125 layer: ConfigLayer::Profile,
1126 origin: format!("profile:{} ({})", name, config_path.display()),
1127 table: overlay.clone(),
1128 }),
1129 Some(_) => anyhow::bail!(
1130 "config profile '{}' is not a table; define it as [profiles.{}] in {}",
1131 name,
1132 name,
1133 config_path.display()
1134 ),
1135 None => {
1136 let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
1137 available.sort_unstable();
1138 if available.is_empty() {
1139 anyhow::bail!(
1140 "no config profiles defined; add [profiles.{}] to {}",
1141 name,
1142 config_path.display()
1143 );
1144 }
1145 anyhow::bail!(
1146 "unknown config profile '{}'; available: {}",
1147 name,
1148 available.join(", ")
1149 )
1150 },
1151 }
1152}
1153
1154pub fn load_config() -> Result<Config> {
1159 let config_path = get_config_path()?;
1160 let mut table = read_config_table(&config_path)?;
1161 migrate_legacy_max_tokens(&mut table);
1162 migrate_legacy_model_profiles(&mut table);
1163 let _ = take_profiles(&mut table);
1164 Ok(finalize_config(table)?.0)
1165}
1166
1167pub struct LayeredLoad {
1170 pub config: Config,
1172 pub warnings: Vec<String>,
1174 pub notices: Vec<String>,
1176}
1177
1178pub fn load_layered_config(
1183 cwd: Option<&std::path::Path>,
1184 flags: &SessionFlags,
1185) -> Result<LayeredLoad> {
1186 let config_path = get_config_path()?;
1187 let mut user_table = read_config_table(&config_path)?;
1188 migrate_legacy_max_tokens(&mut user_table);
1189 migrate_legacy_model_profiles(&mut user_table);
1190 let profiles = take_profiles(&mut user_table);
1193 let mut layers = vec![LayerSource {
1194 layer: ConfigLayer::User,
1195 origin: config_path.display().to_string(),
1196 table: user_table.clone(),
1197 }];
1198 let mut sanitizer_warnings = Vec::new();
1199 let mut notices = Vec::new();
1200 if let Some(name) = flags.profile.as_deref() {
1201 let layer = resolve_profile_layer(&profiles, name, &config_path)?;
1202 notices.push(format!(
1203 "using config profile '{}' (from {})",
1204 name,
1205 config_path.display()
1206 ));
1207 layers.push(layer);
1208 }
1209 if let Some(cwd) = cwd {
1210 let base_safety = finalize_config(user_table)?.0.safety;
1213 let (layer, warnings, notice) =
1214 super::project_config::load_project_layer(cwd, &base_safety);
1215 sanitizer_warnings.extend(warnings);
1216 notices.extend(notice);
1217 if let Some(layer) = layer {
1218 layers.push(layer);
1219 }
1220 }
1221 layers.push(LayerSource {
1222 layer: ConfigLayer::Session,
1223 origin: "command line".to_string(),
1224 table: flags.to_table()?,
1225 });
1226 let (mut config, unknown_key_warnings) = merge_layers(layers)?;
1227 config.active_profile = flags.profile.clone();
1228 sanitizer_warnings.extend(unknown_key_warnings);
1231 Ok(LayeredLoad {
1232 config,
1233 warnings: sanitizer_warnings,
1234 notices,
1235 })
1236}
1237
1238pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
1243 fn load(cwd: &std::path::Path) -> Result<Config> {
1244 let config_path = get_config_path()?;
1245 let mut user_table = read_config_table(&config_path)?;
1246 migrate_legacy_max_tokens(&mut user_table);
1247 migrate_legacy_model_profiles(&mut user_table);
1248 let _ = take_profiles(&mut user_table);
1249 let base_safety = finalize_config(user_table.clone())?.0.safety;
1250 let mut layers = vec![LayerSource {
1251 layer: ConfigLayer::User,
1252 origin: config_path.display().to_string(),
1253 table: user_table,
1254 }];
1255 let (layer, _warnings, _notice) =
1256 super::project_config::load_project_layer(cwd, &base_safety);
1257 if let Some(layer) = layer {
1258 layers.push(layer);
1259 }
1260 Ok(merge_layers(layers)?.0)
1261 }
1262 load(cwd).unwrap_or_default()
1263}
1264
1265pub fn load_config_or_warn() -> Config {
1269 load_config().unwrap_or_else(|e| {
1270 eprintln!(
1271 "mermaid: {}",
1272 crate::utils::redact_secrets(&format!("{e:#}"))
1273 );
1274 Config::default()
1275 })
1276}
1277
1278pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
1280 if !path.exists() {
1281 return Ok(toml::Table::new());
1282 }
1283 let raw = std::fs::read_to_string(path)
1284 .with_context(|| format!("Failed to read {}", path.display()))?;
1285 toml::from_str::<toml::Table>(&raw).with_context(|| {
1286 format!(
1287 "Failed to parse {}. Run 'mermaid init' to regenerate.",
1288 path.display()
1289 )
1290 })
1291}
1292
1293pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
1297 let mut warnings = Vec::new();
1298 let mut merged = toml::Table::new();
1299 for layer in layers {
1300 collect_layer_warnings(&layer, &mut warnings);
1301 deep_merge(&mut merged, layer.table);
1302 }
1303 let (config, _) = finalize_config(merged)?;
1304 Ok((config, warnings))
1305}
1306
1307fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
1313 let mut ignored = Vec::new();
1314 let result: Result<Config, _> =
1315 serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
1316 ignored.push(path.to_string())
1317 });
1318 if result.is_ok() {
1319 for path in ignored {
1320 warnings.push(format!(
1321 "unknown config key '{path}' in {} ({}) — check for a typo",
1322 layer.layer.name(),
1323 layer.origin
1324 ));
1325 }
1326 }
1327}
1328
1329fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
1334 for (key, value) in overlay {
1335 match (base.get_mut(&key), value) {
1336 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
1337 deep_merge(base_table, overlay_table);
1338 },
1339 (_, value) => {
1340 base.insert(key, value);
1341 },
1342 }
1343 }
1344}
1345
1346fn migrate_legacy_max_tokens(table: &mut toml::Table) {
1355 if let Some(dm) = table
1356 .get_mut("default_model")
1357 .and_then(|v| v.as_table_mut())
1358 && dm.get("max_tokens").and_then(|v| v.as_integer())
1359 == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
1360 {
1361 dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
1362 }
1363}
1364
1365fn migrate_legacy_model_profiles(table: &mut toml::Table) {
1371 if table.contains_key("model_aliases") {
1372 table.remove("model_profiles");
1373 return;
1374 }
1375 if let Some(profiles) = table.remove("model_profiles") {
1376 table.insert("model_aliases".to_string(), profiles);
1377 }
1378}
1379
1380fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
1384 let mut ignored = Vec::new();
1385 let config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
1386 ignored.push(path.to_string());
1387 })
1388 .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
1389 Ok((config, ignored))
1390}
1391
1392fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
1396 for raw in overrides {
1397 let (key, val) = raw
1398 .split_once('=')
1399 .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
1400 let key = key.trim();
1401 if key.is_empty() {
1402 anyhow::bail!("invalid -c override '{raw}' (empty key)");
1403 }
1404 deep_set(table, key, parse_override_value(val.trim()))?;
1405 }
1406 Ok(())
1407}
1408
1409fn parse_override_value(s: &str) -> toml::Value {
1412 toml::from_str::<toml::Table>(&format!("x = {s}"))
1413 .ok()
1414 .and_then(|t| t.get("x").cloned())
1415 .unwrap_or_else(|| toml::Value::String(s.to_string()))
1416}
1417
1418fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
1424 let parts: Vec<&str> = key.split('.').collect();
1425 deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
1426}
1427
1428fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
1432 let Some((leaf, parents)) = path.split_last() else {
1433 anyhow::bail!("empty config key path");
1434 };
1435 let mut cur = table;
1436 for part in parents {
1437 let next = cur
1438 .entry((*part).to_string())
1439 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1440 cur = next
1441 .as_table_mut()
1442 .with_context(|| format!("'{part}' is not a table"))?;
1443 }
1444 cur.insert((*leaf).to_string(), value);
1445 Ok(())
1446}
1447
1448pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
1452 let Some((leaf, parents)) = path.split_last() else {
1453 return false;
1454 };
1455 let mut cur = table;
1456 for part in parents {
1457 match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
1458 Some(next) => cur = next,
1459 None => return false,
1460 }
1461 }
1462 cur.remove(*leaf).is_some()
1463}
1464
1465pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
1471 match load_layered_config(cwd, flags) {
1472 Ok(load) => {
1473 for notice in &load.notices {
1474 eprintln!("mermaid: {notice}");
1475 }
1476 for warning in &load.warnings {
1477 eprintln!("mermaid: warning: {warning}");
1478 }
1479 load.config
1480 },
1481 Err(e) => {
1482 eprintln!(
1486 "mermaid: {}",
1487 crate::utils::redact_secrets(&format!("{e:#}"))
1488 );
1489 flags
1490 .to_table()
1491 .ok()
1492 .and_then(|table| finalize_config(table).ok())
1493 .map(|(config, _)| config)
1494 .unwrap_or_default()
1495 },
1496 }
1497}
1498
1499pub fn get_config_path() -> Result<PathBuf> {
1501 Ok(get_config_dir()?.join("config.toml"))
1502}
1503
1504pub fn get_config_dir() -> Result<PathBuf> {
1506 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1507 let config_dir = proj_dirs.config_dir();
1508 std::fs::create_dir_all(config_dir)?;
1509 Ok(config_dir.to_path_buf())
1510 } else {
1511 let home = std::env::var("HOME")
1513 .or_else(|_| std::env::var("USERPROFILE"))
1514 .context("Could not determine home directory")?;
1515 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
1516 std::fs::create_dir_all(&config_dir)?;
1517 Ok(config_dir)
1518 }
1519}
1520
1521fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
1528 let path = if let Some(p) = path {
1529 p
1530 } else {
1531 get_config_dir()?.join("config.toml")
1532 };
1533 write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
1534}
1535
1536fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1547 #[cfg(unix)]
1548 crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
1549 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1550 #[cfg(not(unix))]
1551 crate::runtime::write_atomic(path, bytes)
1552 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1553 Ok(())
1554}
1555
1556pub fn init_config() -> Result<()> {
1558 let config_file = get_config_path()?;
1559
1560 if config_file.exists() {
1561 println!("Configuration already exists at: {}", config_file.display());
1562 } else {
1563 let default_config = Config::default();
1564 save_config(&default_config, Some(config_file.clone()))?;
1565 println!("Created configuration at: {}", config_file.display());
1566 }
1567
1568 Ok(())
1569}
1570
1571static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1577
1578fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
1586 update_user_config_table_at(&get_config_path()?, mutate)
1587}
1588
1589fn update_user_config_table_at(
1591 path: &std::path::Path,
1592 mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
1593) -> Result<()> {
1594 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1595 let mut table = read_config_table(path)?;
1596 migrate_legacy_max_tokens(&mut table);
1598 migrate_legacy_model_profiles(&mut table);
1599 mutate(&mut table)?;
1600 write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
1601}
1602
1603pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
1607 update_user_config_table(|table| deep_set_segments(table, path, value))
1608}
1609
1610pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
1615 update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
1616}
1617
1618pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
1621 let mut removed = false;
1622 update_user_config_table(|table| {
1623 removed = deep_remove_segments(table, path);
1624 Ok(())
1625 })?;
1626 Ok(removed)
1627}
1628
1629pub fn persist_last_model(model: &str) -> Result<()> {
1631 update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
1632}
1633
1634pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
1636 update_user_config_key(
1637 &["ui", "theme"],
1638 toml::Value::String(theme.as_str().to_string()),
1639 )
1640}
1641
1642pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
1645 update_user_config_key(
1646 &["default_model", "reasoning"],
1647 toml::Value::try_from(level)?,
1648 )
1649}
1650
1651pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
1657 update_user_config_key(
1658 &["reasoning_per_model", model_id],
1659 toml::Value::try_from(level)?,
1660 )
1661}
1662
1663pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
1666 match num_ctx {
1667 Some(n) => update_user_config_key(
1668 &["ollama_num_ctx_per_model", model_id],
1669 toml::Value::Integer(i64::from(n)),
1670 ),
1671 None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
1672 }
1673}
1674
1675pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
1677 update_user_config_key(
1678 &["ollama", "allow_ram_offload"],
1679 toml::Value::Boolean(enabled),
1680 )
1681}
1682
1683pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
1685 if let Some(model) = cli_model {
1686 if let Some(resolved) = resolve_model_alias(model, config)? {
1687 return Ok(resolved);
1688 }
1689 return Ok(model.to_string());
1690 }
1691 if let Some(last_model) = &config.last_used_model {
1692 if let Some(resolved) = resolve_model_alias(last_model, config)? {
1693 return Ok(resolved);
1694 }
1695 return Ok(last_model.clone());
1696 }
1697 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1698 return Ok(format!(
1699 "{}/{}",
1700 config.default_model.provider, config.default_model.name
1701 ));
1702 }
1703 let available = crate::ollama::require_any_model(config).await?;
1704 let first = available
1708 .first()
1709 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
1710 Ok(format!("ollama/{}", first))
1711}
1712
1713fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
1714 let alias = requested.strip_prefix("alias:").unwrap_or(requested);
1715 if let Some(model) = config.model_aliases.get(alias) {
1716 anyhow::ensure!(
1717 !model.trim().is_empty(),
1718 "model alias `{}` is configured with an empty model id",
1719 alias
1720 );
1721 return Ok(Some(model.clone()));
1722 }
1723 if requested.starts_with("alias:") {
1724 anyhow::bail!(
1725 "model alias `{}` is not configured; add it under [model_aliases]",
1726 alias
1727 );
1728 }
1729 Ok(None)
1730}
1731
1732#[cfg(test)]
1733mod tests {
1734 use super::*;
1735
1736 #[test]
1737 fn legacy_default_max_tokens_migrates_to_auto() {
1738 let mut table: toml::Table =
1740 toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
1741 migrate_legacy_max_tokens(&mut table);
1742 migrate_legacy_model_profiles(&mut table);
1743 let (config, _) = finalize_config(table).unwrap();
1744 assert_eq!(config.default_model.max_tokens, 0);
1745
1746 let mut table: toml::Table =
1748 toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
1749 migrate_legacy_max_tokens(&mut table);
1750 migrate_legacy_model_profiles(&mut table);
1751 let (config, _) = finalize_config(table).unwrap();
1752 assert_eq!(config.default_model.max_tokens, 8192);
1753
1754 let mut table = toml::Table::new();
1756 migrate_legacy_max_tokens(&mut table);
1757 migrate_legacy_model_profiles(&mut table);
1758 let (config, _) = finalize_config(table).unwrap();
1759 assert_eq!(config.default_model.max_tokens, 0);
1760 }
1761
1762 #[test]
1763 fn legacy_model_profiles_table_migrates_to_model_aliases() {
1764 let mut table: toml::Table =
1766 toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
1767 migrate_legacy_model_profiles(&mut table);
1768 let (config, ignored) = finalize_config(table).unwrap();
1769 assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
1770 assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
1771 let mut table: toml::Table =
1773 toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
1774 .unwrap();
1775 migrate_legacy_model_profiles(&mut table);
1776 let (config, ignored) = finalize_config(table).unwrap();
1777 assert_eq!(config.model_aliases["fast"], "new");
1778 assert!(ignored.is_empty());
1779 let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
1781 std::fs::create_dir_all(&dir).unwrap();
1782 let path = dir.join("config.toml");
1783 std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
1784 update_user_config_table_at(&path, |_| Ok(())).unwrap();
1785 let blob = std::fs::read_to_string(&path).unwrap();
1786 assert!(blob.contains("[model_aliases]"), "{blob}");
1787 assert!(!blob.contains("model_profiles"), "{blob}");
1788 let _ = std::fs::remove_dir_all(&dir);
1789 }
1790
1791 #[test]
1792 fn ui_theme_deserializes_defaults_and_rejects_typos() {
1793 let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
1794 assert_eq!(config.ui.theme, ThemeChoice::Light);
1795 let config: Config = toml::from_str("").unwrap();
1797 assert_eq!(config.ui.theme, ThemeChoice::Dark);
1798 assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
1799 assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
1801 }
1802
1803 #[test]
1804 fn finalize_config_flags_unknown_keys() {
1805 let table: toml::Table =
1806 toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
1807 .unwrap();
1808 let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
1809 assert_eq!(config.default_model.max_tokens, 512);
1810 assert!(
1811 ignored.iter().any(|p| p == "unknown_top"),
1812 "got {ignored:?}"
1813 );
1814 assert!(
1815 ignored.iter().any(|p| p.contains("bogus")),
1816 "got {ignored:?}"
1817 );
1818 }
1819
1820 #[test]
1821 fn cli_overrides_beat_file_and_create_nested_tables() {
1822 let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
1824 apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
1825 let (config, ignored) = finalize_config(table).unwrap();
1826 assert_eq!(config.default_model.max_tokens, 8192);
1827 assert!(ignored.is_empty());
1828 let mut empty = toml::Table::new();
1830 apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
1831 assert_eq!(
1832 finalize_config(empty).unwrap().0.default_model.max_tokens,
1833 256
1834 );
1835 }
1836
1837 #[test]
1838 fn parse_override_value_keeps_toml_types_with_string_fallback() {
1839 assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
1840 assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
1841 assert_eq!(
1842 parse_override_value("ollama/qwen"),
1843 toml::Value::String("ollama/qwen".to_string())
1844 );
1845 }
1846
1847 #[test]
1848 fn cli_override_invalid_format_errors() {
1849 let mut table = toml::Table::new();
1850 assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
1851 assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
1852 }
1853
1854 #[test]
1855 fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
1856 let mut base: toml::Table = toml::from_str(
1857 "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
1858 )
1859 .unwrap();
1860 let overlay: toml::Table =
1861 toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
1862 .unwrap();
1863 deep_merge(&mut base, overlay);
1864 assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
1866 assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
1868 assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
1870 assert_eq!(base["top"].as_integer(), Some(1));
1872 }
1873
1874 #[test]
1875 fn deep_merge_overlay_wins_on_kind_conflict() {
1876 let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
1878 let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
1879 deep_merge(&mut base, overlay);
1880 assert_eq!(base["a"].as_integer(), Some(5));
1881 assert_eq!(base["b"]["y"].as_integer(), Some(3));
1882 }
1883
1884 #[test]
1885 fn merge_layers_precedence_and_layer_attributed_warnings() {
1886 let user: toml::Table = toml::from_str(
1887 "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
1888 )
1889 .unwrap();
1890 let session: toml::Table =
1891 toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
1892 let (config, warnings) = merge_layers(vec![
1893 LayerSource {
1894 layer: ConfigLayer::User,
1895 origin: "/tmp/user.toml".to_string(),
1896 table: user,
1897 },
1898 LayerSource {
1899 layer: ConfigLayer::Session,
1900 origin: "command line".to_string(),
1901 table: session,
1902 },
1903 ])
1904 .expect("merges");
1905 assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
1907 assert_eq!(config.default_model.max_tokens, 100);
1908 assert!(
1910 warnings
1911 .iter()
1912 .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
1913 "got {warnings:?}"
1914 );
1915 assert!(
1916 warnings
1917 .iter()
1918 .any(|w| w.contains("session_typo") && w.contains("session flags")),
1919 "got {warnings:?}"
1920 );
1921 }
1922
1923 #[test]
1924 fn take_profiles_excises_and_tolerates_absence() {
1925 let mut table: toml::Table =
1926 toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
1927 let profiles = take_profiles(&mut table);
1928 assert!(table.is_empty(), "profiles must be excised: {table:?}");
1929 assert!(profiles.contains_key("fast"));
1930 let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
1932 assert!(take_profiles(&mut table).is_empty());
1933 assert_eq!(table.len(), 1);
1934 let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
1936 assert!(take_profiles(&mut table).is_empty());
1937 assert!(table.is_empty());
1938 }
1939
1940 #[test]
1941 fn resolve_profile_layer_errors_name_available_profiles() {
1942 let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
1943 let path = std::path::Path::new("/tmp/config.toml");
1944 let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
1945 assert!(err.to_string().contains("available: fast, work"), "{err}");
1946 let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
1948 assert!(
1949 err.to_string().contains("no config profiles defined"),
1950 "{err}"
1951 );
1952 let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
1954 let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
1955 assert!(err.to_string().contains("not a table"), "{err}");
1956 let profiles: toml::Table =
1958 toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
1959 let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
1960 assert_eq!(layer.layer, ConfigLayer::Profile);
1961 assert!(layer.origin.contains("profile:work"));
1962 }
1963
1964 #[test]
1965 fn profile_layer_beats_user_loses_to_project_and_session() {
1966 let user: toml::Table = toml::from_str(
1967 "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
1968 )
1969 .unwrap();
1970 let profile: toml::Table = toml::from_str(
1971 "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
1972 )
1973 .unwrap();
1974 let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
1975 let session: toml::Table =
1976 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1977 let (config, warnings) = merge_layers(vec![
1978 LayerSource {
1979 layer: ConfigLayer::User,
1980 origin: "/tmp/user.toml".to_string(),
1981 table: user,
1982 },
1983 LayerSource {
1984 layer: ConfigLayer::Profile,
1985 origin: "profile:work (/tmp/user.toml)".to_string(),
1986 table: profile,
1987 },
1988 LayerSource {
1989 layer: ConfigLayer::Project,
1990 origin: "/repo/.mermaid/config.toml".to_string(),
1991 table: project,
1992 },
1993 LayerSource {
1994 layer: ConfigLayer::Session,
1995 origin: "command line".to_string(),
1996 table: session,
1997 },
1998 ])
1999 .expect("merges");
2000 assert_eq!(config.default_model.temperature, 0.5);
2003 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2004 assert_eq!(config.default_model.max_tokens, 100);
2005 assert!(
2007 warnings.iter().any(|w| w.contains("profile_typo")
2008 && w.contains("config profile (profile:work (/tmp/user.toml))")),
2009 "got {warnings:?}"
2010 );
2011 }
2012
2013 #[test]
2014 fn persists_never_touch_profile_tables() {
2015 let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
2016 std::fs::create_dir_all(&dir).expect("create temp dir");
2017 let path = dir.join("config.toml");
2018 std::fs::write(
2019 &path,
2020 "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
2021 )
2022 .expect("seed");
2023
2024 update_user_config_table_at(&path, |table| {
2025 deep_set_segments(
2026 table,
2027 &["safety", "mode"],
2028 toml::Value::String("auto".to_string()),
2029 )
2030 })
2031 .expect("persist");
2032
2033 let table: toml::Table =
2034 toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
2035 assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
2036 assert_eq!(
2038 table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
2039 Some(0.1)
2040 );
2041 let _ = std::fs::remove_dir_all(&dir);
2042 }
2043
2044 #[test]
2045 fn session_flags_table_maps_each_flag() {
2046 let flags = SessionFlags {
2047 overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
2048 deny_network: true,
2049 confine_fs: true,
2050 max_tokens: Some(512),
2051 allow_untrusted_tools: true,
2052 profile: None,
2053 };
2054 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2055 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2056 assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
2057 assert_eq!(config.default_model.max_tokens, 512);
2058 assert!(config.safety.allow_untrusted_headless_tools);
2059 assert_eq!(config.web.searxng_url, "http://x:1");
2060 }
2061
2062 #[test]
2063 fn session_dedicated_flags_beat_dash_c() {
2064 let flags = SessionFlags {
2067 overrides: vec!["safety.network=allow".to_string()],
2068 deny_network: true,
2069 ..Default::default()
2070 };
2071 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2072 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2073 }
2074
2075 #[test]
2076 fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
2077 let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
2079 let mut warnings = Vec::new();
2080 collect_layer_warnings(
2081 &LayerSource {
2082 layer: ConfigLayer::User,
2083 origin: "x".to_string(),
2084 table: bad.clone(),
2085 },
2086 &mut warnings,
2087 );
2088 assert!(warnings.is_empty());
2089 assert!(
2091 merge_layers(vec![LayerSource {
2092 layer: ConfigLayer::User,
2093 origin: "x".to_string(),
2094 table: bad.clone(),
2095 }])
2096 .is_err()
2097 );
2098 let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
2100 let (config, _) = merge_layers(vec![
2101 LayerSource {
2102 layer: ConfigLayer::User,
2103 origin: "x".to_string(),
2104 table: bad,
2105 },
2106 LayerSource {
2107 layer: ConfigLayer::Session,
2108 origin: "command line".to_string(),
2109 table: fix,
2110 },
2111 ])
2112 .expect("later layer repairs the earlier one");
2113 assert_eq!(config.safety.mode, SafetyMode::Ask);
2114 }
2115
2116 #[test]
2117 fn project_layer_beats_user_and_loses_to_session() {
2118 let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
2119 let project: toml::Table = toml::from_str(
2120 "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
2121 )
2122 .unwrap();
2123 let session: toml::Table =
2124 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2125 let (config, _) = merge_layers(vec![
2126 LayerSource {
2127 layer: ConfigLayer::User,
2128 origin: "user".to_string(),
2129 table: user,
2130 },
2131 LayerSource {
2132 layer: ConfigLayer::Project,
2133 origin: "project".to_string(),
2134 table: project,
2135 },
2136 LayerSource {
2137 layer: ConfigLayer::Session,
2138 origin: "command line".to_string(),
2139 table: session,
2140 },
2141 ])
2142 .expect("merges");
2143 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2145 assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
2147 }
2148
2149 #[test]
2150 fn session_flags_survive_corrupt_user_layer_fallback() {
2151 let flags = SessionFlags {
2154 deny_network: true,
2155 ..Default::default()
2156 };
2157 let config = flags
2158 .to_table()
2159 .ok()
2160 .and_then(|table| finalize_config(table).ok())
2161 .map(|(config, _)| config)
2162 .unwrap_or_default();
2163 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2164 }
2165
2166 #[test]
2167 fn deep_set_segments_addresses_keys_containing_dots() {
2168 let mut table = toml::Table::new();
2171 deep_set_segments(
2172 &mut table,
2173 &["reasoning_per_model", "gemini/gemini-2.5-pro"],
2174 toml::Value::String("high".to_string()),
2175 )
2176 .unwrap();
2177 let (config, ignored) = finalize_config(table).unwrap();
2178 assert!(ignored.is_empty(), "got {ignored:?}");
2179 assert_eq!(
2180 config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
2181 Some(&ReasoningLevel::High)
2182 );
2183 }
2184
2185 #[test]
2186 fn deep_remove_segments_removes_leaf_only() {
2187 let mut table: toml::Table =
2188 toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
2189 .unwrap();
2190 assert!(deep_remove_segments(
2191 &mut table,
2192 &["ollama_num_ctx_per_model", "ollama/a"]
2193 ));
2194 assert_eq!(
2196 table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
2197 Some(2)
2198 );
2199 assert!(!deep_remove_segments(
2200 &mut table,
2201 &["ollama_num_ctx_per_model", "ollama/a"]
2202 ));
2203 assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
2204 }
2205
2206 #[test]
2207 fn update_user_config_table_preserves_unknown_keys() {
2208 let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
2209 std::fs::create_dir_all(&dir).expect("create temp dir");
2210 let path = dir.join("config.toml");
2211 std::fs::write(
2214 &path,
2215 "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
2216 )
2217 .expect("seed");
2218
2219 update_user_config_table_at(&path, |table| {
2220 deep_set_segments(
2221 table,
2222 &["last_used_model"],
2223 toml::Value::String("ollama/new".to_string()),
2224 )
2225 })
2226 .expect("persist");
2227
2228 let blob = std::fs::read_to_string(&path).expect("read back");
2229 let table: toml::Table = toml::from_str(&blob).expect("parse back");
2230 assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
2232 assert_eq!(table["future_key"].as_str(), Some("kept"));
2234 assert!(!blob.contains("safety"), "defaults must not be frozen in");
2236 assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
2237
2238 let _ = std::fs::remove_dir_all(&dir);
2239 }
2240
2241 #[test]
2242 fn mcp_tool_allowed_honors_enabled_and_disabled() {
2243 let cfg = McpServerConfig::default();
2245 assert!(cfg.tool_allowed("anything"));
2246 let cfg = McpServerConfig {
2248 enabled_tools: vec!["read".into(), "search".into()],
2249 ..Default::default()
2250 };
2251 assert!(cfg.tool_allowed("read"));
2252 assert!(!cfg.tool_allowed("write"));
2253 let cfg = McpServerConfig {
2255 enabled_tools: vec!["read".into(), "write".into()],
2256 disabled_tools: vec!["write".into()],
2257 ..Default::default()
2258 };
2259 assert!(cfg.tool_allowed("read"));
2260 assert!(!cfg.tool_allowed("write"));
2261 }
2262
2263 #[test]
2264 fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
2265 let cfg = McpServerConfig {
2267 command: "npx".to_string(),
2268 ..Default::default()
2269 };
2270 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
2271 let cfg = McpServerConfig {
2273 url: Some("https://example.com/mcp".to_string()),
2274 ..Default::default()
2275 };
2276 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
2277 let cfg = McpServerConfig {
2279 command: "npx".to_string(),
2280 url: Some("https://example.com/mcp".to_string()),
2281 ..Default::default()
2282 };
2283 assert!(
2284 cfg.transport_kind()
2285 .unwrap_err()
2286 .to_string()
2287 .contains("mutually exclusive")
2288 );
2289 let cfg = McpServerConfig::default();
2291 assert!(
2292 cfg.transport_kind()
2293 .unwrap_err()
2294 .to_string()
2295 .contains("neither")
2296 );
2297 }
2298
2299 #[test]
2300 fn mcp_transport_kind_gates_url_scheme() {
2301 let with_url = |url: &str| McpServerConfig {
2302 url: Some(url.to_string()),
2303 ..Default::default()
2304 };
2305 assert!(
2308 with_url("https://mcp.example.com/x")
2309 .transport_kind()
2310 .is_ok()
2311 );
2312 assert!(
2313 with_url("http://localhost:8080/mcp")
2314 .transport_kind()
2315 .is_ok()
2316 );
2317 assert!(
2318 with_url("http://127.0.0.1:8080/mcp")
2319 .transport_kind()
2320 .is_ok()
2321 );
2322 assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
2323 assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
2324 assert!(with_url("not a url").transport_kind().is_err());
2325 }
2326
2327 #[test]
2328 fn mcp_server_config_debug_masks_header_values() {
2329 let mut headers = HashMap::new();
2330 headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
2331 let mut env_headers = HashMap::new();
2332 env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
2333 let cfg = McpServerConfig {
2334 url: Some("https://example.com/mcp".to_string()),
2335 headers,
2336 env_headers,
2337 ..Default::default()
2338 };
2339 let rendered = format!("{cfg:?}");
2340 assert!(!rendered.contains("sk-secret"), "{rendered}");
2341 assert!(rendered.contains("Authorization"), "{rendered}");
2342 assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
2344 }
2345
2346 #[test]
2347 fn mcp_url_config_round_trips_through_toml_without_command() {
2348 let cfg = McpServerConfig {
2351 url: Some("https://example.com/mcp".to_string()),
2352 ..Default::default()
2353 };
2354 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2355 assert!(
2356 !blob.contains("command"),
2357 "empty command must be omitted: {blob}"
2358 );
2359 let back: McpServerConfig = toml::from_str(&blob).unwrap();
2360 assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
2361 assert!(back.command.is_empty());
2362 let cfg = McpServerConfig {
2364 command: "npx".to_string(),
2365 ..Default::default()
2366 };
2367 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2368 assert!(!blob.contains("url"), "{blob}");
2369 }
2370
2371 #[test]
2376 fn model_settings_deserializes_without_reasoning_field() {
2377 let toml_blob = r#"
2378 provider = "ollama"
2379 name = "qwen3-coder:30b"
2380 temperature = 0.7
2381 max_tokens = 4096
2382 "#;
2383 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
2384 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
2385 assert_eq!(settings.provider, "ollama");
2386 }
2387
2388 #[test]
2389 fn model_settings_round_trips_reasoning_high() {
2390 let original = ModelSettings {
2391 provider: "anthropic".to_string(),
2392 name: "claude-sonnet-4-6".to_string(),
2393 temperature: 0.5,
2394 max_tokens: 8192,
2395 reasoning: ReasoningLevel::High,
2396 };
2397 let toml_blob = toml::to_string(&original).expect("serialize");
2398 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
2399 assert_eq!(back.reasoning, ReasoningLevel::High);
2400 assert_eq!(back.name, "claude-sonnet-4-6");
2401 }
2402
2403 #[test]
2404 fn agents_config_defaults_and_parses_custom_types() {
2405 let config: Config = toml::from_str("").expect("empty config parses");
2407 assert_eq!(config.agents.timeout_secs, 1200);
2408 assert!(config.agents.types.is_empty());
2409
2410 let config: Config = toml::from_str(
2411 r#"
2412[agents]
2413timeout_secs = 300
2414
2415[agents.types.scout]
2416tools = ["read_file", "execute_command"]
2417safety = "read_only"
2418preamble = "You are a scout."
2419model = "ollama/qwen3:8b"
2420"#,
2421 )
2422 .expect("agents section parses");
2423 assert_eq!(config.agents.timeout_secs, 300);
2424 let scout = &config.agents.types["scout"];
2425 assert_eq!(
2426 scout.tools.as_deref(),
2427 Some(&["read_file".to_string(), "execute_command".to_string()][..])
2428 );
2429 assert_eq!(scout.safety.as_deref(), Some("read_only"));
2430 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
2431 }
2432
2433 #[test]
2434 fn configured_model_alias_resolves_explicit_prefix() {
2435 let mut config = Config::default();
2436 config
2437 .model_aliases
2438 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
2439 assert_eq!(
2440 resolve_model_alias("fast", &config).unwrap(),
2441 Some("ollama/qwen3-coder:14b".to_string())
2442 );
2443 assert_eq!(
2444 resolve_model_alias("alias:fast", &config).unwrap(),
2445 Some("ollama/qwen3-coder:14b".to_string())
2446 );
2447 }
2448
2449 #[test]
2450 fn alias_prefix_requires_configuration() {
2451 let config = Config::default();
2452 assert!(resolve_model_alias("alias:vision", &config).is_err());
2453 assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
2454 }
2455
2456 #[test]
2463 fn save_and_reload_preserves_reasoning_field() {
2464 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
2465 std::fs::create_dir_all(&dir).expect("create temp dir");
2466 let path = dir.join("config.toml");
2467
2468 let mut cfg = Config::default();
2469 cfg.default_model.provider = "ollama".to_string();
2470 cfg.default_model.name = "qwen3-coder:30b".to_string();
2471 cfg.default_model.reasoning = ReasoningLevel::Low;
2472
2473 save_config(&cfg, Some(path.clone())).expect("save");
2474
2475 let blob = std::fs::read_to_string(&path).expect("read");
2476 let loaded: Config = toml::from_str(&blob).expect("parse back");
2477 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
2478
2479 let _ = std::fs::remove_dir_all(&dir);
2480 }
2481
2482 #[test]
2487 fn save_and_reload_preserves_reasoning_per_model_table() {
2488 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
2489 std::fs::create_dir_all(&dir).expect("create temp dir");
2490 let path = dir.join("config.toml");
2491
2492 let mut cfg = Config::default();
2493 cfg.reasoning_per_model.insert(
2494 "anthropic/claude-sonnet-4-6".to_string(),
2495 ReasoningLevel::High,
2496 );
2497 cfg.reasoning_per_model
2498 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
2499
2500 save_config(&cfg, Some(path.clone())).expect("save");
2501
2502 let blob = std::fs::read_to_string(&path).expect("read");
2503 let loaded: Config = toml::from_str(&blob).expect("parse back");
2504 assert_eq!(
2505 loaded
2506 .reasoning_per_model
2507 .get("anthropic/claude-sonnet-4-6"),
2508 Some(&ReasoningLevel::High)
2509 );
2510 assert_eq!(
2511 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
2512 Some(&ReasoningLevel::Low)
2513 );
2514
2515 let _ = std::fs::remove_dir_all(&dir);
2516 }
2517
2518 #[test]
2521 fn save_and_reload_preserves_ollama_context_overrides() {
2522 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
2523 std::fs::create_dir_all(&dir).expect("create temp dir");
2524 let path = dir.join("config.toml");
2525
2526 let mut cfg = Config::default();
2527 cfg.ollama_num_ctx_per_model
2528 .insert("ollama/ornith:9b".to_string(), 131_072);
2529 cfg.ollama.allow_ram_offload = true;
2530 cfg.ollama.max_auto_num_ctx = Some(65_536);
2531
2532 save_config(&cfg, Some(path.clone())).expect("save");
2533 let blob = std::fs::read_to_string(&path).expect("read");
2534 let loaded: Config = toml::from_str(&blob).expect("parse back");
2535
2536 assert_eq!(
2537 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
2538 Some(&131_072)
2539 );
2540 assert!(loaded.ollama.allow_ram_offload);
2541 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
2542
2543 let _ = std::fs::remove_dir_all(&dir);
2544 }
2545
2546 #[test]
2549 fn config_deserializes_without_ollama_context_keys() {
2550 let toml_blob = r#"
2551[ollama]
2552host = "localhost"
2553port = 11434
2554"#;
2555 let cfg: Config = toml::from_str(toml_blob).expect("parse");
2556 assert!(cfg.ollama_num_ctx_per_model.is_empty());
2557 assert!(!cfg.ollama.allow_ram_offload);
2558 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
2559 assert!(cfg.ollama.auto_start);
2562 }
2563
2564 #[test]
2568 fn config_deserializes_without_reasoning_per_model() {
2569 let toml_blob = r#"
2570 last_used_model = "ollama/qwen3-coder:30b"
2571
2572 [default_model]
2573 provider = "ollama"
2574 name = "qwen3-coder:30b"
2575 temperature = 0.7
2576 max_tokens = 4096
2577 "#;
2578 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
2579 assert!(cfg.reasoning_per_model.is_empty());
2580 assert!(!cfg.prompt.is_customized());
2581 }
2582
2583 #[cfg(unix)]
2587 #[test]
2588 fn save_config_writes_owner_only_perms() {
2589 use std::os::unix::fs::PermissionsExt;
2590 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
2591 std::fs::create_dir_all(&dir).expect("create temp dir");
2592 let path = dir.join("config.toml");
2593 std::fs::write(&path, "stale").expect("seed");
2595 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
2596
2597 save_config(&Config::default(), Some(path.clone())).expect("save");
2598 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2599 assert_eq!(mode, 0o600, "config must be written owner-only");
2600
2601 let _ = std::fs::remove_dir_all(&dir);
2602 }
2603
2604 #[test]
2605 fn config_defaults_computer_use_auto_screenshot_on() {
2606 let cfg: Config = toml::from_str("").expect("empty config");
2608 assert!(cfg.computer_use.auto_screenshot);
2609 }
2610
2611 #[test]
2612 fn prompt_config_replaces_and_appends_without_persisting() {
2613 let mut cfg = Config::default();
2614 cfg.prompt.system_prompt = Some("base".to_string());
2615 cfg.prompt
2616 .append_system_prompt
2617 .push("extra instructions".to_string());
2618
2619 assert_eq!(
2620 cfg.prompt.render_system_prompt("default"),
2621 "base\n\nextra instructions"
2622 );
2623
2624 let blob = toml::to_string(&cfg).expect("serialize");
2625 assert!(!blob.contains("extra instructions"));
2626 let loaded: Config = toml::from_str(&blob).expect("deserialize");
2627 assert!(!loaded.prompt.is_customized());
2628 }
2629
2630 #[test]
2631 fn plan_config_defaults_parse_and_do_not_freeze() {
2632 let c: Config = toml::from_str("").expect("empty config parses");
2634 assert!(!c.plan.auto_approve);
2635 assert!(c.plan.post_approve.is_none());
2636 let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
2638 .expect("plan section parses");
2639 assert!(c.plan.auto_approve);
2640 assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
2641 assert_eq!(
2642 toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
2643 .expect("wait parses")
2644 .plan
2645 .post_approve,
2646 Some(PlanPostApprove::Wait)
2647 );
2648 let blob = toml::to_string(&Config::default()).expect("serialize");
2652 assert!(!blob.contains("post_approve"));
2653 }
2654}