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)]
291 pub auto_classifier_model: Option<String>,
292 #[serde(default)]
298 pub allow_untrusted_headless_tools: bool,
299}
300
301impl Default for SafetyConfig {
302 fn default() -> Self {
303 Self {
304 mode: SafetyMode::Ask,
308 checkpoint_on_mutation: true,
309 network: NetworkPolicy::default(),
310 filesystem: FilesystemPolicy::default(),
311 overrides: Vec::new(),
312 auto_classifier_model: None,
313 allow_untrusted_headless_tools: false,
314 }
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(default)]
321pub struct DaemonConfig {
322 pub max_concurrent_tasks: usize,
327 pub task_timeout_minutes: Option<u64>,
332 pub retention_days: i64,
336 pub outcomes_retention_days: i64,
341 pub scratchpad_retention_days: i64,
346}
347
348impl Default for DaemonConfig {
349 fn default() -> Self {
350 Self {
351 max_concurrent_tasks: 1,
352 task_timeout_minutes: None,
353 retention_days: 30,
354 outcomes_retention_days: 180,
355 scratchpad_retention_days: crate::session::scratchpad::RETENTION_DAYS as i64,
356 }
357 }
358}
359
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
362#[serde(rename_all = "snake_case")]
363pub enum PlanPostApprove {
364 Start,
366 Wait,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(rename_all = "snake_case")]
376pub enum PlanPermLevel {
377 Allow,
378 Auto,
379 Ask,
380 Deny,
381}
382
383impl PlanPermLevel {
384 pub fn as_str(self) -> &'static str {
385 match self {
386 PlanPermLevel::Allow => "allow",
387 PlanPermLevel::Auto => "auto",
388 PlanPermLevel::Ask => "ask",
389 PlanPermLevel::Deny => "deny",
390 }
391 }
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399#[serde(default)]
400pub struct PlanPermissions {
401 pub builds: PlanPermLevel,
403 pub web: PlanPermLevel,
405 pub memory: PlanPermLevel,
407 pub tasks: PlanPermLevel,
412}
413
414impl Default for PlanPermissions {
415 fn default() -> Self {
416 Self {
417 builds: PlanPermLevel::Allow,
418 web: PlanPermLevel::Allow,
419 memory: PlanPermLevel::Allow,
420 tasks: PlanPermLevel::Deny,
421 }
422 }
423}
424
425impl PlanPermissions {
426 pub fn preset_name(&self) -> Option<&'static str> {
429 if *self == Self::default() {
430 Some("default")
431 } else if *self == Self::strict() {
432 Some("strict")
433 } else if *self == Self::open() {
434 Some("open")
435 } else {
436 None
437 }
438 }
439
440 pub fn strict() -> Self {
442 Self {
443 builds: PlanPermLevel::Deny,
444 web: PlanPermLevel::Deny,
445 memory: PlanPermLevel::Deny,
446 tasks: PlanPermLevel::Deny,
447 }
448 }
449
450 pub fn open() -> Self {
452 Self {
453 builds: PlanPermLevel::Allow,
454 web: PlanPermLevel::Allow,
455 memory: PlanPermLevel::Allow,
456 tasks: PlanPermLevel::Allow,
457 }
458 }
459}
460
461#[derive(Debug, Clone, Default, Serialize, Deserialize)]
463#[serde(default)]
464pub struct PlanConfig {
465 pub auto_approve: bool,
469 #[serde(skip_serializing_if = "Option::is_none")]
475 pub post_approve: Option<PlanPostApprove>,
476 pub permissions: PlanPermissions,
481 #[serde(skip_serializing_if = "Option::is_none")]
486 pub model: Option<String>,
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub reasoning: Option<crate::models::ReasoningLevel>,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494#[serde(default)]
495pub struct MemoryConfig {
496 pub enabled: bool,
499 pub index_cap_bytes: usize,
501}
502
503impl Default for MemoryConfig {
504 fn default() -> Self {
505 Self {
506 enabled: true,
507 index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
508 }
509 }
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize)]
514#[serde(default)]
515pub struct CompactionConfig {
516 pub max_truncation_recoveries: u8,
528}
529
530impl Default for CompactionConfig {
531 fn default() -> Self {
532 Self {
533 max_truncation_recoveries: crate::constants::COMPACTION_MAX_TRUNCATION_RECOVERIES,
534 }
535 }
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(default)]
541pub struct ComputerUseConfig {
542 pub auto_screenshot: bool,
548}
549
550impl Default for ComputerUseConfig {
551 fn default() -> Self {
552 Self {
553 auto_screenshot: true,
554 }
555 }
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
560#[serde(default)]
561pub struct AgentsConfig {
562 pub timeout_secs: u64,
565 pub types: HashMap<String, AgentTypeConfig>,
576}
577
578impl Default for AgentsConfig {
579 fn default() -> Self {
580 Self {
581 timeout_secs: 1200,
582 types: HashMap::new(),
583 }
584 }
585}
586
587#[derive(Debug, Clone, Default, Serialize, Deserialize)]
590#[serde(default)]
591pub struct AgentTypeConfig {
592 pub tools: Option<Vec<String>>,
597 pub safety: Option<String>,
601 pub preamble: Option<String>,
604 pub model: Option<String>,
607}
608
609#[derive(Clone, Default, Serialize, Deserialize)]
613pub struct UserProviderConfig {
614 #[serde(default)]
617 pub base_url: Option<String>,
618 #[serde(default)]
622 pub api_key_env: Option<String>,
623 #[serde(default)]
625 pub extra_headers: HashMap<String, String>,
626 #[serde(default)]
631 pub env_headers: HashMap<String, String>,
632 #[serde(default)]
638 pub compat: Option<String>,
639 #[serde(default)]
643 pub default_model: Option<String>,
644}
645
646#[derive(Clone, Default, Serialize, Deserialize)]
648pub struct McpServerConfig {
649 #[serde(default, skip_serializing_if = "String::is_empty")]
652 pub command: String,
653 #[serde(default)]
655 pub args: Vec<String>,
656 #[serde(default)]
658 pub env: HashMap<String, String>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub url: Option<String>,
664 #[serde(default)]
667 pub headers: HashMap<String, String>,
668 #[serde(default)]
673 pub env_headers: HashMap<String, String>,
674 #[serde(default)]
678 pub allow_private_network: bool,
679 #[serde(default)]
681 pub enabled_tools: Vec<String>,
682 #[serde(default)]
684 pub disabled_tools: Vec<String>,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub defer: Option<bool>,
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
696pub enum TransportKind {
697 Stdio,
698 Http,
699}
700
701impl McpServerConfig {
702 pub fn transport_kind(&self) -> Result<TransportKind> {
707 match (&self.url, self.command.is_empty()) {
708 (Some(_), false) => Err(anyhow::anyhow!(
709 "MCP server config sets both `command` and `url`; they are mutually exclusive"
710 )),
711 (None, true) => Err(anyhow::anyhow!(
712 "MCP server config sets neither `command` nor `url`"
713 )),
714 (None, false) => Ok(TransportKind::Stdio),
715 (Some(url), true) => {
716 let parsed = reqwest::Url::parse(url)
717 .map_err(|e| anyhow::anyhow!("invalid MCP server url '{url}': {e}"))?;
718 let host = parsed.host_str().unwrap_or("");
719 match parsed.scheme() {
720 "https" => Ok(TransportKind::Http),
721 "http" if crate::utils::classify_host(host).is_loopback() => {
722 Ok(TransportKind::Http)
723 },
724 "http" => Err(anyhow::anyhow!(
725 "MCP server url '{url}' uses plaintext http to a non-loopback host; \
726 use https (auth headers would travel in cleartext)"
727 )),
728 other => Err(anyhow::anyhow!(
729 "MCP server url '{url}' has unsupported scheme '{other}' \
730 (expected https, or http to loopback)"
731 )),
732 }
733 },
734 }
735 }
736
737 pub fn tool_allowed(&self, tool_name: &str) -> bool {
741 if self.disabled_tools.iter().any(|t| t == tool_name) {
742 return false;
743 }
744 self.enabled_tools.is_empty() || self.enabled_tools.iter().any(|t| t == tool_name)
745 }
746}
747
748fn debug_masked_map(
752 map: &HashMap<String, String>,
753) -> std::collections::BTreeMap<&str, &'static str> {
754 map.keys().map(|k| (k.as_str(), "[REDACTED]")).collect()
755}
756
757impl std::fmt::Debug for McpServerConfig {
762 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
763 f.debug_struct("McpServerConfig")
764 .field("command", &self.command)
765 .field(
767 "args",
768 &self
769 .args
770 .iter()
771 .map(|a| crate::utils::redact_secrets(a))
772 .collect::<Vec<_>>(),
773 )
774 .field("env", &debug_masked_map(&self.env))
775 .field("url", &self.url)
776 .field("headers", &debug_masked_map(&self.headers))
778 .field("env_headers", &self.env_headers)
780 .field("allow_private_network", &self.allow_private_network)
781 .field("enabled_tools", &self.enabled_tools)
783 .field("disabled_tools", &self.disabled_tools)
784 .finish()
785 }
786}
787
788impl std::fmt::Debug for UserProviderConfig {
789 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
790 f.debug_struct("UserProviderConfig")
791 .field("base_url", &self.base_url)
792 .field("api_key_env", &self.api_key_env)
793 .field("extra_headers", &debug_masked_map(&self.extra_headers))
794 .field("env_headers", &self.env_headers)
796 .field("compat", &self.compat)
797 .field("default_model", &self.default_model)
798 .finish()
799 }
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize)]
804#[serde(default)]
805pub struct ModelSettings {
806 pub provider: String,
808 pub name: String,
810 pub temperature: f32,
812 pub max_tokens: usize,
814 pub reasoning: ReasoningLevel,
818}
819
820impl Default for ModelSettings {
821 fn default() -> Self {
822 Self {
823 provider: String::new(),
824 name: String::new(),
825 temperature: DEFAULT_TEMPERATURE,
826 max_tokens: 0,
830 reasoning: ReasoningLevel::default(),
831 }
832 }
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize)]
837#[serde(default)]
838pub struct OllamaConfig {
839 pub host: String,
841 pub port: u16,
843 pub num_gpu: Option<i32>,
846 pub num_thread: Option<i32>,
849 pub num_ctx: Option<i32>,
852 pub numa: Option<bool>,
854 pub allow_ram_offload: bool,
860 pub max_auto_num_ctx: Option<usize>,
864 pub auto_start: bool,
869}
870
871impl Default for OllamaConfig {
872 fn default() -> Self {
873 Self {
874 host: String::from("localhost"),
875 port: DEFAULT_OLLAMA_PORT,
876 num_gpu: None, num_thread: None, num_ctx: None, numa: None, allow_ram_offload: false, max_auto_num_ctx: None, auto_start: true, }
884 }
885}
886
887#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
889#[serde(rename_all = "lowercase")]
890pub enum FetchBackend {
891 #[default]
894 Native,
895 Ollama,
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
901#[serde(rename_all = "lowercase")]
902pub enum SearchBackend {
903 #[default]
907 Auto,
908 Ollama,
910 Searxng,
912}
913
914#[derive(Debug, Clone, Serialize, Deserialize)]
923#[serde(default)]
924pub struct WebConfig {
925 pub fetch_backend: FetchBackend,
928 pub search_backend: SearchBackend,
933 pub searxng_url: String,
938}
939
940impl Default for WebConfig {
941 fn default() -> Self {
942 Self {
943 fetch_backend: FetchBackend::Native,
944 search_backend: SearchBackend::Auto,
945 searxng_url: String::from("http://localhost:8080"),
946 }
947 }
948}
949
950#[derive(Debug, Clone, Serialize, Deserialize)]
952#[serde(default)]
953pub struct NonInteractiveConfig {
954 pub output_format: String,
956 pub max_tokens: usize,
958 pub no_execute: bool,
960}
961
962impl Default for NonInteractiveConfig {
963 fn default() -> Self {
964 Self {
965 output_format: String::from("text"),
966 max_tokens: 0,
968 no_execute: false,
969 }
970 }
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
977pub enum ConfigLayer {
978 Defaults = 0,
981 User = 1,
984 Profile = 2,
988 Project = 3,
991 Session = 4,
995}
996
997impl ConfigLayer {
998 fn name(self) -> &'static str {
1000 match self {
1001 ConfigLayer::Defaults => "defaults",
1002 ConfigLayer::User => "user config",
1003 ConfigLayer::Profile => "config profile",
1004 ConfigLayer::Project => "project config",
1005 ConfigLayer::Session => "session flags",
1006 }
1007 }
1008}
1009
1010#[derive(Debug, Clone)]
1012pub(crate) struct LayerSource {
1013 pub layer: ConfigLayer,
1015 pub origin: String,
1017 pub table: toml::Table,
1020}
1021
1022#[derive(Debug, Clone, Default)]
1025pub struct SessionFlags {
1026 pub overrides: Vec<String>,
1029 pub deny_network: bool,
1031 pub confine_fs: bool,
1033 pub max_tokens: Option<usize>,
1035 pub allow_untrusted_tools: bool,
1037 pub profile: Option<String>,
1041}
1042
1043impl SessionFlags {
1044 pub(crate) fn to_table(&self) -> Result<toml::Table> {
1048 let mut table = toml::Table::new();
1049 apply_cli_overrides(&mut table, &self.overrides)?;
1050 if self.deny_network {
1051 deep_set_segments(
1052 &mut table,
1053 &["safety", "network"],
1054 toml::Value::String("deny".into()),
1055 )?;
1056 }
1057 if self.confine_fs {
1058 deep_set_segments(
1059 &mut table,
1060 &["safety", "filesystem"],
1061 toml::Value::String("project".into()),
1062 )?;
1063 }
1064 if let Some(n) = self.max_tokens {
1065 deep_set_segments(
1066 &mut table,
1067 &["default_model", "max_tokens"],
1068 toml::Value::Integer(n as i64),
1069 )?;
1070 }
1071 if self.allow_untrusted_tools {
1072 deep_set_segments(
1073 &mut table,
1074 &["safety", "allow_untrusted_headless_tools"],
1075 toml::Value::Boolean(true),
1076 )?;
1077 }
1078 Ok(table)
1079 }
1080}
1081
1082fn take_profiles(table: &mut toml::Table) -> toml::Table {
1089 match table.remove("profiles") {
1090 Some(toml::Value::Table(profiles)) => profiles,
1091 _ => toml::Table::new(),
1094 }
1095}
1096
1097fn resolve_profile_layer(
1101 profiles: &toml::Table,
1102 name: &str,
1103 config_path: &std::path::Path,
1104) -> Result<LayerSource> {
1105 match profiles.get(name) {
1106 Some(toml::Value::Table(overlay)) => Ok(LayerSource {
1107 layer: ConfigLayer::Profile,
1108 origin: format!("profile:{} ({})", name, config_path.display()),
1109 table: overlay.clone(),
1110 }),
1111 Some(_) => anyhow::bail!(
1112 "config profile '{}' is not a table; define it as [profiles.{}] in {}",
1113 name,
1114 name,
1115 config_path.display()
1116 ),
1117 None => {
1118 let mut available: Vec<&str> = profiles.keys().map(String::as_str).collect();
1119 available.sort_unstable();
1120 if available.is_empty() {
1121 anyhow::bail!(
1122 "no config profiles defined; add [profiles.{}] to {}",
1123 name,
1124 config_path.display()
1125 );
1126 }
1127 anyhow::bail!(
1128 "unknown config profile '{}'; available: {}",
1129 name,
1130 available.join(", ")
1131 )
1132 },
1133 }
1134}
1135
1136pub fn load_config() -> Result<Config> {
1141 let config_path = get_config_path()?;
1142 let mut table = read_config_table(&config_path)?;
1143 migrate_legacy_max_tokens(&mut table);
1144 migrate_legacy_model_profiles(&mut table);
1145 let _ = take_profiles(&mut table);
1146 Ok(finalize_config(table)?.0)
1147}
1148
1149pub struct LayeredLoad {
1152 pub config: Config,
1154 pub warnings: Vec<String>,
1156 pub notices: Vec<String>,
1158}
1159
1160pub fn load_layered_config(
1165 cwd: Option<&std::path::Path>,
1166 flags: &SessionFlags,
1167) -> Result<LayeredLoad> {
1168 let config_path = get_config_path()?;
1169 let mut user_table = read_config_table(&config_path)?;
1170 migrate_legacy_max_tokens(&mut user_table);
1171 migrate_legacy_model_profiles(&mut user_table);
1172 let profiles = take_profiles(&mut user_table);
1175 let mut layers = vec![LayerSource {
1176 layer: ConfigLayer::User,
1177 origin: config_path.display().to_string(),
1178 table: user_table.clone(),
1179 }];
1180 let mut sanitizer_warnings = Vec::new();
1181 let mut notices = Vec::new();
1182 if let Some(name) = flags.profile.as_deref() {
1183 let layer = resolve_profile_layer(&profiles, name, &config_path)?;
1184 notices.push(format!(
1185 "using config profile '{}' (from {})",
1186 name,
1187 config_path.display()
1188 ));
1189 layers.push(layer);
1190 }
1191 if let Some(cwd) = cwd {
1192 let base_safety = finalize_config(user_table)?.0.safety;
1195 let (layer, warnings, notice) =
1196 super::project_config::load_project_layer(cwd, &base_safety);
1197 sanitizer_warnings.extend(warnings);
1198 notices.extend(notice);
1199 if let Some(layer) = layer {
1200 layers.push(layer);
1201 }
1202 }
1203 layers.push(LayerSource {
1204 layer: ConfigLayer::Session,
1205 origin: "command line".to_string(),
1206 table: flags.to_table()?,
1207 });
1208 let (mut config, unknown_key_warnings) = merge_layers(layers)?;
1209 config.active_profile = flags.profile.clone();
1210 sanitizer_warnings.extend(unknown_key_warnings);
1213 Ok(LayeredLoad {
1214 config,
1215 warnings: sanitizer_warnings,
1216 notices,
1217 })
1218}
1219
1220pub fn load_project_scoped_config(cwd: &std::path::Path) -> Config {
1225 fn load(cwd: &std::path::Path) -> Result<Config> {
1226 let config_path = get_config_path()?;
1227 let mut user_table = read_config_table(&config_path)?;
1228 migrate_legacy_max_tokens(&mut user_table);
1229 migrate_legacy_model_profiles(&mut user_table);
1230 let _ = take_profiles(&mut user_table);
1231 let base_safety = finalize_config(user_table.clone())?.0.safety;
1232 let mut layers = vec![LayerSource {
1233 layer: ConfigLayer::User,
1234 origin: config_path.display().to_string(),
1235 table: user_table,
1236 }];
1237 let (layer, _warnings, _notice) =
1238 super::project_config::load_project_layer(cwd, &base_safety);
1239 if let Some(layer) = layer {
1240 layers.push(layer);
1241 }
1242 Ok(merge_layers(layers)?.0)
1243 }
1244 load(cwd).unwrap_or_default()
1245}
1246
1247pub fn load_config_or_warn() -> Config {
1251 load_config().unwrap_or_else(|e| {
1252 eprintln!(
1253 "mermaid: {}",
1254 crate::utils::redact_secrets(&format!("{e:#}"))
1255 );
1256 Config::default()
1257 })
1258}
1259
1260pub(crate) fn read_config_table(path: &std::path::Path) -> Result<toml::Table> {
1262 if !path.exists() {
1263 return Ok(toml::Table::new());
1264 }
1265 let raw = std::fs::read_to_string(path)
1266 .with_context(|| format!("Failed to read {}", path.display()))?;
1267 toml::from_str::<toml::Table>(&raw).with_context(|| {
1268 format!(
1269 "Failed to parse {}. Run 'mermaid init' to regenerate.",
1270 path.display()
1271 )
1272 })
1273}
1274
1275pub(crate) fn merge_layers(layers: Vec<LayerSource>) -> Result<(Config, Vec<String>)> {
1279 let mut warnings = Vec::new();
1280 let mut merged = toml::Table::new();
1281 for layer in layers {
1282 collect_layer_warnings(&layer, &mut warnings);
1283 deep_merge(&mut merged, layer.table);
1284 }
1285 let (config, _) = finalize_config(merged)?;
1286 Ok((config, warnings))
1287}
1288
1289fn collect_layer_warnings(layer: &LayerSource, warnings: &mut Vec<String>) {
1295 let mut ignored = Vec::new();
1296 let result: Result<Config, _> =
1297 serde_ignored::deserialize(toml::Value::Table(layer.table.clone()), |path| {
1298 ignored.push(path.to_string())
1299 });
1300 if result.is_ok() {
1301 for path in ignored {
1302 warnings.push(format!(
1303 "unknown config key '{path}' in {} ({}) — check for a typo",
1304 layer.layer.name(),
1305 layer.origin
1306 ));
1307 }
1308 }
1309}
1310
1311fn deep_merge(base: &mut toml::Table, overlay: toml::Table) {
1316 for (key, value) in overlay {
1317 match (base.get_mut(&key), value) {
1318 (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
1319 deep_merge(base_table, overlay_table);
1320 },
1321 (_, value) => {
1322 base.insert(key, value);
1323 },
1324 }
1325 }
1326}
1327
1328fn migrate_legacy_max_tokens(table: &mut toml::Table) {
1337 if let Some(dm) = table
1338 .get_mut("default_model")
1339 .and_then(|v| v.as_table_mut())
1340 && dm.get("max_tokens").and_then(|v| v.as_integer())
1341 == Some(LEGACY_DEFAULT_MAX_TOKENS as i64)
1342 {
1343 dm.insert("max_tokens".to_string(), toml::Value::Integer(0));
1344 }
1345}
1346
1347fn migrate_legacy_model_profiles(table: &mut toml::Table) {
1353 if table.contains_key("model_aliases") {
1354 table.remove("model_profiles");
1355 return;
1356 }
1357 if let Some(profiles) = table.remove("model_profiles") {
1358 table.insert("model_aliases".to_string(), profiles);
1359 }
1360}
1361
1362fn finalize_config(table: toml::Table) -> Result<(Config, Vec<String>)> {
1366 let mut ignored = Vec::new();
1367 let config: Config = serde_ignored::deserialize(toml::Value::Table(table), |path| {
1368 ignored.push(path.to_string());
1369 })
1370 .context("Failed to interpret configuration. Run 'mermaid init' to regenerate.")?;
1371 Ok((config, ignored))
1372}
1373
1374fn apply_cli_overrides(table: &mut toml::Table, overrides: &[String]) -> Result<()> {
1378 for raw in overrides {
1379 let (key, val) = raw
1380 .split_once('=')
1381 .with_context(|| format!("invalid -c override '{raw}' (expected KEY=VALUE)"))?;
1382 let key = key.trim();
1383 if key.is_empty() {
1384 anyhow::bail!("invalid -c override '{raw}' (empty key)");
1385 }
1386 deep_set(table, key, parse_override_value(val.trim()))?;
1387 }
1388 Ok(())
1389}
1390
1391fn parse_override_value(s: &str) -> toml::Value {
1394 toml::from_str::<toml::Table>(&format!("x = {s}"))
1395 .ok()
1396 .and_then(|t| t.get("x").cloned())
1397 .unwrap_or_else(|| toml::Value::String(s.to_string()))
1398}
1399
1400fn deep_set(table: &mut toml::Table, key: &str, value: toml::Value) -> Result<()> {
1406 let parts: Vec<&str> = key.split('.').collect();
1407 deep_set_segments(table, &parts, value).with_context(|| format!("cannot set '{key}'"))
1408}
1409
1410fn deep_set_segments(table: &mut toml::Table, path: &[&str], value: toml::Value) -> Result<()> {
1414 let Some((leaf, parents)) = path.split_last() else {
1415 anyhow::bail!("empty config key path");
1416 };
1417 let mut cur = table;
1418 for part in parents {
1419 let next = cur
1420 .entry((*part).to_string())
1421 .or_insert_with(|| toml::Value::Table(toml::Table::new()));
1422 cur = next
1423 .as_table_mut()
1424 .with_context(|| format!("'{part}' is not a table"))?;
1425 }
1426 cur.insert((*leaf).to_string(), value);
1427 Ok(())
1428}
1429
1430pub(crate) fn deep_remove_segments(table: &mut toml::Table, path: &[&str]) -> bool {
1434 let Some((leaf, parents)) = path.split_last() else {
1435 return false;
1436 };
1437 let mut cur = table;
1438 for part in parents {
1439 match cur.get_mut(*part).and_then(|v| v.as_table_mut()) {
1440 Some(next) => cur = next,
1441 None => return false,
1442 }
1443 }
1444 cur.remove(*leaf).is_some()
1445}
1446
1447pub fn load_layered_config_or_warn(cwd: Option<&std::path::Path>, flags: &SessionFlags) -> Config {
1453 match load_layered_config(cwd, flags) {
1454 Ok(load) => {
1455 for notice in &load.notices {
1456 eprintln!("mermaid: {notice}");
1457 }
1458 for warning in &load.warnings {
1459 eprintln!("mermaid: warning: {warning}");
1460 }
1461 load.config
1462 },
1463 Err(e) => {
1464 eprintln!(
1468 "mermaid: {}",
1469 crate::utils::redact_secrets(&format!("{e:#}"))
1470 );
1471 flags
1472 .to_table()
1473 .ok()
1474 .and_then(|table| finalize_config(table).ok())
1475 .map(|(config, _)| config)
1476 .unwrap_or_default()
1477 },
1478 }
1479}
1480
1481pub fn get_config_path() -> Result<PathBuf> {
1483 Ok(get_config_dir()?.join("config.toml"))
1484}
1485
1486pub fn get_config_dir() -> Result<PathBuf> {
1488 if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
1489 let config_dir = proj_dirs.config_dir();
1490 std::fs::create_dir_all(config_dir)?;
1491 Ok(config_dir.to_path_buf())
1492 } else {
1493 let home = std::env::var("HOME")
1495 .or_else(|_| std::env::var("USERPROFILE"))
1496 .context("Could not determine home directory")?;
1497 let config_dir = PathBuf::from(home).join(".config").join("mermaid");
1498 std::fs::create_dir_all(&config_dir)?;
1499 Ok(config_dir)
1500 }
1501}
1502
1503fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
1510 let path = if let Some(p) = path {
1511 p
1512 } else {
1513 get_config_dir()?.join("config.toml")
1514 };
1515 write_config_bytes(&path, toml::to_string_pretty(config)?.as_bytes())
1516}
1517
1518fn write_config_bytes(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
1529 #[cfg(unix)]
1530 crate::runtime::write_atomic_with_mode(path, bytes, 0o600)
1531 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1532 #[cfg(not(unix))]
1533 crate::runtime::write_atomic(path, bytes)
1534 .with_context(|| format!("Failed to write config to {}", path.display()))?;
1535 Ok(())
1536}
1537
1538pub fn init_config() -> Result<()> {
1540 let config_file = get_config_path()?;
1541
1542 if config_file.exists() {
1543 println!("Configuration already exists at: {}", config_file.display());
1544 } else {
1545 let default_config = Config::default();
1546 save_config(&default_config, Some(config_file.clone()))?;
1547 println!("Created configuration at: {}", config_file.display());
1548 }
1549
1550 Ok(())
1551}
1552
1553static PERSIST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1559
1560fn update_user_config_table(mutate: impl FnOnce(&mut toml::Table) -> Result<()>) -> Result<()> {
1568 update_user_config_table_at(&get_config_path()?, mutate)
1569}
1570
1571fn update_user_config_table_at(
1573 path: &std::path::Path,
1574 mutate: impl FnOnce(&mut toml::Table) -> Result<()>,
1575) -> Result<()> {
1576 let _guard = PERSIST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1577 let mut table = read_config_table(path)?;
1578 migrate_legacy_max_tokens(&mut table);
1580 migrate_legacy_model_profiles(&mut table);
1581 mutate(&mut table)?;
1582 write_config_bytes(path, toml::to_string_pretty(&table)?.as_bytes())
1583}
1584
1585pub fn update_user_config_key(path: &[&str], value: toml::Value) -> Result<()> {
1589 update_user_config_table(|table| deep_set_segments(table, path, value))
1590}
1591
1592pub fn persist_plan_config(plan: &PlanConfig) -> Result<()> {
1597 update_user_config_key(&["plan"], toml::Value::try_from(plan)?)
1598}
1599
1600pub fn remove_user_config_key(path: &[&str]) -> Result<bool> {
1603 let mut removed = false;
1604 update_user_config_table(|table| {
1605 removed = deep_remove_segments(table, path);
1606 Ok(())
1607 })?;
1608 Ok(removed)
1609}
1610
1611pub fn persist_last_model(model: &str) -> Result<()> {
1613 update_user_config_key(&["last_used_model"], toml::Value::String(model.to_string()))
1614}
1615
1616pub fn persist_ui_theme(theme: ThemeChoice) -> Result<()> {
1618 update_user_config_key(
1619 &["ui", "theme"],
1620 toml::Value::String(theme.as_str().to_string()),
1621 )
1622}
1623
1624pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
1627 update_user_config_key(
1628 &["default_model", "reasoning"],
1629 toml::Value::try_from(level)?,
1630 )
1631}
1632
1633pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
1639 update_user_config_key(
1640 &["reasoning_per_model", model_id],
1641 toml::Value::try_from(level)?,
1642 )
1643}
1644
1645pub fn persist_ollama_num_ctx_for_model(model_id: &str, num_ctx: Option<u32>) -> Result<()> {
1648 match num_ctx {
1649 Some(n) => update_user_config_key(
1650 &["ollama_num_ctx_per_model", model_id],
1651 toml::Value::Integer(i64::from(n)),
1652 ),
1653 None => remove_user_config_key(&["ollama_num_ctx_per_model", model_id]).map(|_| ()),
1654 }
1655}
1656
1657pub fn persist_ollama_allow_ram_offload(enabled: bool) -> Result<()> {
1659 update_user_config_key(
1660 &["ollama", "allow_ram_offload"],
1661 toml::Value::Boolean(enabled),
1662 )
1663}
1664
1665pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
1667 if let Some(model) = cli_model {
1668 if let Some(resolved) = resolve_model_alias(model, config)? {
1669 return Ok(resolved);
1670 }
1671 return Ok(model.to_string());
1672 }
1673 if let Some(last_model) = &config.last_used_model {
1674 if let Some(resolved) = resolve_model_alias(last_model, config)? {
1675 return Ok(resolved);
1676 }
1677 return Ok(last_model.clone());
1678 }
1679 if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
1680 return Ok(format!(
1681 "{}/{}",
1682 config.default_model.provider, config.default_model.name
1683 ));
1684 }
1685 let available = crate::ollama::require_any_model(config).await?;
1686 let first = available
1690 .first()
1691 .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
1692 Ok(format!("ollama/{}", first))
1693}
1694
1695fn resolve_model_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
1696 let alias = requested.strip_prefix("alias:").unwrap_or(requested);
1697 if let Some(model) = config.model_aliases.get(alias) {
1698 anyhow::ensure!(
1699 !model.trim().is_empty(),
1700 "model alias `{}` is configured with an empty model id",
1701 alias
1702 );
1703 return Ok(Some(model.clone()));
1704 }
1705 if requested.starts_with("alias:") {
1706 anyhow::bail!(
1707 "model alias `{}` is not configured; add it under [model_aliases]",
1708 alias
1709 );
1710 }
1711 Ok(None)
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716 use super::*;
1717
1718 #[test]
1719 fn legacy_default_max_tokens_migrates_to_auto() {
1720 let mut table: toml::Table =
1722 toml::from_str("[default_model]\nmax_tokens = 4096\n").unwrap();
1723 migrate_legacy_max_tokens(&mut table);
1724 migrate_legacy_model_profiles(&mut table);
1725 let (config, _) = finalize_config(table).unwrap();
1726 assert_eq!(config.default_model.max_tokens, 0);
1727
1728 let mut table: toml::Table =
1730 toml::from_str("[default_model]\nmax_tokens = 8192\n").unwrap();
1731 migrate_legacy_max_tokens(&mut table);
1732 migrate_legacy_model_profiles(&mut table);
1733 let (config, _) = finalize_config(table).unwrap();
1734 assert_eq!(config.default_model.max_tokens, 8192);
1735
1736 let mut table = toml::Table::new();
1738 migrate_legacy_max_tokens(&mut table);
1739 migrate_legacy_model_profiles(&mut table);
1740 let (config, _) = finalize_config(table).unwrap();
1741 assert_eq!(config.default_model.max_tokens, 0);
1742 }
1743
1744 #[test]
1745 fn legacy_model_profiles_table_migrates_to_model_aliases() {
1746 let mut table: toml::Table =
1748 toml::from_str("[model_profiles]\nfast = \"ollama/qwen3:8b\"\n").unwrap();
1749 migrate_legacy_model_profiles(&mut table);
1750 let (config, ignored) = finalize_config(table).unwrap();
1751 assert_eq!(config.model_aliases["fast"], "ollama/qwen3:8b");
1752 assert!(ignored.is_empty(), "no unknown-key warning: {ignored:?}");
1753 let mut table: toml::Table =
1755 toml::from_str("[model_profiles]\nfast = \"old\"\n[model_aliases]\nfast = \"new\"\n")
1756 .unwrap();
1757 migrate_legacy_model_profiles(&mut table);
1758 let (config, ignored) = finalize_config(table).unwrap();
1759 assert_eq!(config.model_aliases["fast"], "new");
1760 assert!(ignored.is_empty());
1761 let dir = std::env::temp_dir().join("mermaid_test_model_profiles_migrate");
1763 std::fs::create_dir_all(&dir).unwrap();
1764 let path = dir.join("config.toml");
1765 std::fs::write(&path, "[model_profiles]\nfast = \"ollama/x\"\n").unwrap();
1766 update_user_config_table_at(&path, |_| Ok(())).unwrap();
1767 let blob = std::fs::read_to_string(&path).unwrap();
1768 assert!(blob.contains("[model_aliases]"), "{blob}");
1769 assert!(!blob.contains("model_profiles"), "{blob}");
1770 let _ = std::fs::remove_dir_all(&dir);
1771 }
1772
1773 #[test]
1774 fn ui_theme_deserializes_defaults_and_rejects_typos() {
1775 let config: Config = toml::from_str("[ui]\ntheme = \"light\"\n").unwrap();
1776 assert_eq!(config.ui.theme, ThemeChoice::Light);
1777 let config: Config = toml::from_str("").unwrap();
1779 assert_eq!(config.ui.theme, ThemeChoice::Dark);
1780 assert_eq!(Config::default().ui.theme, ThemeChoice::Dark);
1781 assert!(toml::from_str::<Config>("[ui]\ntheme = \"solarized\"\n").is_err());
1783 }
1784
1785 #[test]
1786 fn finalize_config_flags_unknown_keys() {
1787 let table: toml::Table =
1788 toml::from_str("unknown_top = 1\n[default_model]\nmax_tokens = 512\nbogus = true\n")
1789 .unwrap();
1790 let (config, ignored) = finalize_config(table).expect("finalizes despite unknown keys");
1791 assert_eq!(config.default_model.max_tokens, 512);
1792 assert!(
1793 ignored.iter().any(|p| p == "unknown_top"),
1794 "got {ignored:?}"
1795 );
1796 assert!(
1797 ignored.iter().any(|p| p.contains("bogus")),
1798 "got {ignored:?}"
1799 );
1800 }
1801
1802 #[test]
1803 fn cli_overrides_beat_file_and_create_nested_tables() {
1804 let mut table: toml::Table = toml::from_str("[default_model]\nmax_tokens = 100\n").unwrap();
1806 apply_cli_overrides(&mut table, &["default_model.max_tokens=8192".to_string()]).unwrap();
1807 let (config, ignored) = finalize_config(table).unwrap();
1808 assert_eq!(config.default_model.max_tokens, 8192);
1809 assert!(ignored.is_empty());
1810 let mut empty = toml::Table::new();
1812 apply_cli_overrides(&mut empty, &["default_model.max_tokens=256".to_string()]).unwrap();
1813 assert_eq!(
1814 finalize_config(empty).unwrap().0.default_model.max_tokens,
1815 256
1816 );
1817 }
1818
1819 #[test]
1820 fn parse_override_value_keeps_toml_types_with_string_fallback() {
1821 assert_eq!(parse_override_value("true"), toml::Value::Boolean(true));
1822 assert_eq!(parse_override_value("42"), toml::Value::Integer(42));
1823 assert_eq!(
1824 parse_override_value("ollama/qwen"),
1825 toml::Value::String("ollama/qwen".to_string())
1826 );
1827 }
1828
1829 #[test]
1830 fn cli_override_invalid_format_errors() {
1831 let mut table = toml::Table::new();
1832 assert!(apply_cli_overrides(&mut table, &["noequalssign".to_string()]).is_err());
1833 assert!(apply_cli_overrides(&mut table, &["=novalue".to_string()]).is_err());
1834 }
1835
1836 #[test]
1837 fn deep_merge_recurses_tables_and_replaces_scalars_and_arrays() {
1838 let mut base: toml::Table = toml::from_str(
1839 "top = 1\n[ollama]\nhost = \"localhost\"\nport = 11434\n[safety]\noverrides = [\"a\", \"b\"]\n",
1840 )
1841 .unwrap();
1842 let overlay: toml::Table =
1843 toml::from_str("[ollama]\nhost = \"gpu-box\"\n[safety]\noverrides = [\"c\"]\n")
1844 .unwrap();
1845 deep_merge(&mut base, overlay);
1846 assert_eq!(base["ollama"]["port"].as_integer(), Some(11434));
1848 assert_eq!(base["ollama"]["host"].as_str(), Some("gpu-box"));
1850 assert_eq!(base["safety"]["overrides"].as_array().unwrap().len(), 1);
1852 assert_eq!(base["top"].as_integer(), Some(1));
1854 }
1855
1856 #[test]
1857 fn deep_merge_overlay_wins_on_kind_conflict() {
1858 let mut base: toml::Table = toml::from_str("[a]\nx = 1\nb = 2\n").unwrap();
1860 let overlay: toml::Table = toml::from_str("a = 5\n[b]\ny = 3\n").unwrap();
1861 deep_merge(&mut base, overlay);
1862 assert_eq!(base["a"].as_integer(), Some(5));
1863 assert_eq!(base["b"]["y"].as_integer(), Some(3));
1864 }
1865
1866 #[test]
1867 fn merge_layers_precedence_and_layer_attributed_warnings() {
1868 let user: toml::Table = toml::from_str(
1869 "last_used_model = \"ollama/a\"\nuser_typo = 1\n[default_model]\nmax_tokens = 100\n",
1870 )
1871 .unwrap();
1872 let session: toml::Table =
1873 toml::from_str("last_used_model = \"ollama/b\"\nsession_typo = 2\n").unwrap();
1874 let (config, warnings) = merge_layers(vec![
1875 LayerSource {
1876 layer: ConfigLayer::User,
1877 origin: "/tmp/user.toml".to_string(),
1878 table: user,
1879 },
1880 LayerSource {
1881 layer: ConfigLayer::Session,
1882 origin: "command line".to_string(),
1883 table: session,
1884 },
1885 ])
1886 .expect("merges");
1887 assert_eq!(config.last_used_model.as_deref(), Some("ollama/b"));
1889 assert_eq!(config.default_model.max_tokens, 100);
1890 assert!(
1892 warnings
1893 .iter()
1894 .any(|w| w.contains("user_typo") && w.contains("user config (/tmp/user.toml)")),
1895 "got {warnings:?}"
1896 );
1897 assert!(
1898 warnings
1899 .iter()
1900 .any(|w| w.contains("session_typo") && w.contains("session flags")),
1901 "got {warnings:?}"
1902 );
1903 }
1904
1905 #[test]
1906 fn take_profiles_excises_and_tolerates_absence() {
1907 let mut table: toml::Table =
1908 toml::from_str("[profiles.fast.default_model]\ntemperature = 0.1\n").unwrap();
1909 let profiles = take_profiles(&mut table);
1910 assert!(table.is_empty(), "profiles must be excised: {table:?}");
1911 assert!(profiles.contains_key("fast"));
1912 let mut table: toml::Table = toml::from_str("last_used_model = \"x\"\n").unwrap();
1914 assert!(take_profiles(&mut table).is_empty());
1915 assert_eq!(table.len(), 1);
1916 let mut table: toml::Table = toml::from_str("profiles = 3\n").unwrap();
1918 assert!(take_profiles(&mut table).is_empty());
1919 assert!(table.is_empty());
1920 }
1921
1922 #[test]
1923 fn resolve_profile_layer_errors_name_available_profiles() {
1924 let profiles: toml::Table = toml::from_str("[work]\n[fast]\n").unwrap();
1925 let path = std::path::Path::new("/tmp/config.toml");
1926 let err = resolve_profile_layer(&profiles, "nope", path).unwrap_err();
1927 assert!(err.to_string().contains("available: fast, work"), "{err}");
1928 let err = resolve_profile_layer(&toml::Table::new(), "work", path).unwrap_err();
1930 assert!(
1931 err.to_string().contains("no config profiles defined"),
1932 "{err}"
1933 );
1934 let profiles: toml::Table = toml::from_str("work = 1\n").unwrap();
1936 let err = resolve_profile_layer(&profiles, "work", path).unwrap_err();
1937 assert!(err.to_string().contains("not a table"), "{err}");
1938 let profiles: toml::Table =
1940 toml::from_str("[work.default_model]\ntemperature = 0.2\n").unwrap();
1941 let layer = resolve_profile_layer(&profiles, "work", path).unwrap();
1942 assert_eq!(layer.layer, ConfigLayer::Profile);
1943 assert!(layer.origin.contains("profile:work"));
1944 }
1945
1946 #[test]
1947 fn profile_layer_beats_user_loses_to_project_and_session() {
1948 let user: toml::Table = toml::from_str(
1949 "last_used_model = \"ollama/user\"\n[default_model]\ntemperature = 0.9\nmax_tokens = 100\n",
1950 )
1951 .unwrap();
1952 let profile: toml::Table = toml::from_str(
1953 "last_used_model = \"ollama/profile\"\n[default_model]\ntemperature = 0.1\nprofile_typo = 1\n",
1954 )
1955 .unwrap();
1956 let project: toml::Table = toml::from_str("[default_model]\ntemperature = 0.5\n").unwrap();
1957 let session: toml::Table =
1958 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
1959 let (config, warnings) = merge_layers(vec![
1960 LayerSource {
1961 layer: ConfigLayer::User,
1962 origin: "/tmp/user.toml".to_string(),
1963 table: user,
1964 },
1965 LayerSource {
1966 layer: ConfigLayer::Profile,
1967 origin: "profile:work (/tmp/user.toml)".to_string(),
1968 table: profile,
1969 },
1970 LayerSource {
1971 layer: ConfigLayer::Project,
1972 origin: "/repo/.mermaid/config.toml".to_string(),
1973 table: project,
1974 },
1975 LayerSource {
1976 layer: ConfigLayer::Session,
1977 origin: "command line".to_string(),
1978 table: session,
1979 },
1980 ])
1981 .expect("merges");
1982 assert_eq!(config.default_model.temperature, 0.5);
1985 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
1986 assert_eq!(config.default_model.max_tokens, 100);
1987 assert!(
1989 warnings.iter().any(|w| w.contains("profile_typo")
1990 && w.contains("config profile (profile:work (/tmp/user.toml))")),
1991 "got {warnings:?}"
1992 );
1993 }
1994
1995 #[test]
1996 fn persists_never_touch_profile_tables() {
1997 let dir = std::env::temp_dir().join("mermaid_test_profiles_persist");
1998 std::fs::create_dir_all(&dir).expect("create temp dir");
1999 let path = dir.join("config.toml");
2000 std::fs::write(
2001 &path,
2002 "[profiles.fast.default_model]\ntemperature = 0.1\n\n[safety]\nmode = \"ask\"\n",
2003 )
2004 .expect("seed");
2005
2006 update_user_config_table_at(&path, |table| {
2007 deep_set_segments(
2008 table,
2009 &["safety", "mode"],
2010 toml::Value::String("auto".to_string()),
2011 )
2012 })
2013 .expect("persist");
2014
2015 let table: toml::Table =
2016 toml::from_str(&std::fs::read_to_string(&path).expect("read back")).expect("parse");
2017 assert_eq!(table["safety"]["mode"].as_str(), Some("auto"));
2018 assert_eq!(
2020 table["profiles"]["fast"]["default_model"]["temperature"].as_float(),
2021 Some(0.1)
2022 );
2023 let _ = std::fs::remove_dir_all(&dir);
2024 }
2025
2026 #[test]
2027 fn session_flags_table_maps_each_flag() {
2028 let flags = SessionFlags {
2029 overrides: vec!["web.searxng_url=\"http://x:1\"".to_string()],
2030 deny_network: true,
2031 confine_fs: true,
2032 max_tokens: Some(512),
2033 allow_untrusted_tools: true,
2034 profile: None,
2035 };
2036 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2037 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2038 assert_eq!(config.safety.filesystem, FilesystemPolicy::Project);
2039 assert_eq!(config.default_model.max_tokens, 512);
2040 assert!(config.safety.allow_untrusted_headless_tools);
2041 assert_eq!(config.web.searxng_url, "http://x:1");
2042 }
2043
2044 #[test]
2045 fn session_dedicated_flags_beat_dash_c() {
2046 let flags = SessionFlags {
2049 overrides: vec!["safety.network=allow".to_string()],
2050 deny_network: true,
2051 ..Default::default()
2052 };
2053 let (config, _) = finalize_config(flags.to_table().unwrap()).unwrap();
2054 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2055 }
2056
2057 #[test]
2058 fn corrupt_layer_yields_no_warnings_but_merged_error_surfaces() {
2059 let bad: toml::Table = toml::from_str("[safety]\nmode = 42\n").unwrap();
2061 let mut warnings = Vec::new();
2062 collect_layer_warnings(
2063 &LayerSource {
2064 layer: ConfigLayer::User,
2065 origin: "x".to_string(),
2066 table: bad.clone(),
2067 },
2068 &mut warnings,
2069 );
2070 assert!(warnings.is_empty());
2071 assert!(
2073 merge_layers(vec![LayerSource {
2074 layer: ConfigLayer::User,
2075 origin: "x".to_string(),
2076 table: bad.clone(),
2077 }])
2078 .is_err()
2079 );
2080 let fix: toml::Table = toml::from_str("[safety]\nmode = \"ask\"\n").unwrap();
2082 let (config, _) = merge_layers(vec![
2083 LayerSource {
2084 layer: ConfigLayer::User,
2085 origin: "x".to_string(),
2086 table: bad,
2087 },
2088 LayerSource {
2089 layer: ConfigLayer::Session,
2090 origin: "command line".to_string(),
2091 table: fix,
2092 },
2093 ])
2094 .expect("later layer repairs the earlier one");
2095 assert_eq!(config.safety.mode, SafetyMode::Ask);
2096 }
2097
2098 #[test]
2099 fn project_layer_beats_user_and_loses_to_session() {
2100 let user: toml::Table = toml::from_str("last_used_model = \"ollama/user\"\n").unwrap();
2101 let project: toml::Table = toml::from_str(
2102 "last_used_model = \"ollama/project\"\n[default_model]\nreasoning = \"low\"\n",
2103 )
2104 .unwrap();
2105 let session: toml::Table =
2106 toml::from_str("last_used_model = \"ollama/session\"\n").unwrap();
2107 let (config, _) = merge_layers(vec![
2108 LayerSource {
2109 layer: ConfigLayer::User,
2110 origin: "user".to_string(),
2111 table: user,
2112 },
2113 LayerSource {
2114 layer: ConfigLayer::Project,
2115 origin: "project".to_string(),
2116 table: project,
2117 },
2118 LayerSource {
2119 layer: ConfigLayer::Session,
2120 origin: "command line".to_string(),
2121 table: session,
2122 },
2123 ])
2124 .expect("merges");
2125 assert_eq!(config.last_used_model.as_deref(), Some("ollama/session"));
2127 assert_eq!(config.default_model.reasoning, ReasoningLevel::Low);
2129 }
2130
2131 #[test]
2132 fn session_flags_survive_corrupt_user_layer_fallback() {
2133 let flags = SessionFlags {
2136 deny_network: true,
2137 ..Default::default()
2138 };
2139 let config = flags
2140 .to_table()
2141 .ok()
2142 .and_then(|table| finalize_config(table).ok())
2143 .map(|(config, _)| config)
2144 .unwrap_or_default();
2145 assert_eq!(config.safety.network, NetworkPolicy::Deny);
2146 }
2147
2148 #[test]
2149 fn deep_set_segments_addresses_keys_containing_dots() {
2150 let mut table = toml::Table::new();
2153 deep_set_segments(
2154 &mut table,
2155 &["reasoning_per_model", "gemini/gemini-2.5-pro"],
2156 toml::Value::String("high".to_string()),
2157 )
2158 .unwrap();
2159 let (config, ignored) = finalize_config(table).unwrap();
2160 assert!(ignored.is_empty(), "got {ignored:?}");
2161 assert_eq!(
2162 config.reasoning_per_model.get("gemini/gemini-2.5-pro"),
2163 Some(&ReasoningLevel::High)
2164 );
2165 }
2166
2167 #[test]
2168 fn deep_remove_segments_removes_leaf_only() {
2169 let mut table: toml::Table =
2170 toml::from_str("[ollama_num_ctx_per_model]\n\"ollama/a\" = 1\n\"ollama/b\" = 2\n")
2171 .unwrap();
2172 assert!(deep_remove_segments(
2173 &mut table,
2174 &["ollama_num_ctx_per_model", "ollama/a"]
2175 ));
2176 assert_eq!(
2178 table["ollama_num_ctx_per_model"]["ollama/b"].as_integer(),
2179 Some(2)
2180 );
2181 assert!(!deep_remove_segments(
2182 &mut table,
2183 &["ollama_num_ctx_per_model", "ollama/a"]
2184 ));
2185 assert!(!deep_remove_segments(&mut table, &["nope", "x"]));
2186 }
2187
2188 #[test]
2189 fn update_user_config_table_preserves_unknown_keys() {
2190 let dir = std::env::temp_dir().join("mermaid_test_config_targeted_persist");
2191 std::fs::create_dir_all(&dir).expect("create temp dir");
2192 let path = dir.join("config.toml");
2193 std::fs::write(
2196 &path,
2197 "future_key = \"kept\"\nlast_used_model = \"ollama/old\"\n\n[ollama]\nport = 12345\n",
2198 )
2199 .expect("seed");
2200
2201 update_user_config_table_at(&path, |table| {
2202 deep_set_segments(
2203 table,
2204 &["last_used_model"],
2205 toml::Value::String("ollama/new".to_string()),
2206 )
2207 })
2208 .expect("persist");
2209
2210 let blob = std::fs::read_to_string(&path).expect("read back");
2211 let table: toml::Table = toml::from_str(&blob).expect("parse back");
2212 assert_eq!(table["last_used_model"].as_str(), Some("ollama/new"));
2214 assert_eq!(table["future_key"].as_str(), Some("kept"));
2216 assert!(!blob.contains("safety"), "defaults must not be frozen in");
2218 assert_eq!(table["ollama"]["port"].as_integer(), Some(12345));
2219
2220 let _ = std::fs::remove_dir_all(&dir);
2221 }
2222
2223 #[test]
2224 fn mcp_tool_allowed_honors_enabled_and_disabled() {
2225 let cfg = McpServerConfig::default();
2227 assert!(cfg.tool_allowed("anything"));
2228 let cfg = McpServerConfig {
2230 enabled_tools: vec!["read".into(), "search".into()],
2231 ..Default::default()
2232 };
2233 assert!(cfg.tool_allowed("read"));
2234 assert!(!cfg.tool_allowed("write"));
2235 let cfg = McpServerConfig {
2237 enabled_tools: vec!["read".into(), "write".into()],
2238 disabled_tools: vec!["write".into()],
2239 ..Default::default()
2240 };
2241 assert!(cfg.tool_allowed("read"));
2242 assert!(!cfg.tool_allowed("write"));
2243 }
2244
2245 #[test]
2246 fn mcp_transport_kind_requires_exactly_one_of_command_and_url() {
2247 let cfg = McpServerConfig {
2249 command: "npx".to_string(),
2250 ..Default::default()
2251 };
2252 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Stdio);
2253 let cfg = McpServerConfig {
2255 url: Some("https://example.com/mcp".to_string()),
2256 ..Default::default()
2257 };
2258 assert_eq!(cfg.transport_kind().unwrap(), TransportKind::Http);
2259 let cfg = McpServerConfig {
2261 command: "npx".to_string(),
2262 url: Some("https://example.com/mcp".to_string()),
2263 ..Default::default()
2264 };
2265 assert!(
2266 cfg.transport_kind()
2267 .unwrap_err()
2268 .to_string()
2269 .contains("mutually exclusive")
2270 );
2271 let cfg = McpServerConfig::default();
2273 assert!(
2274 cfg.transport_kind()
2275 .unwrap_err()
2276 .to_string()
2277 .contains("neither")
2278 );
2279 }
2280
2281 #[test]
2282 fn mcp_transport_kind_gates_url_scheme() {
2283 let with_url = |url: &str| McpServerConfig {
2284 url: Some(url.to_string()),
2285 ..Default::default()
2286 };
2287 assert!(
2290 with_url("https://mcp.example.com/x")
2291 .transport_kind()
2292 .is_ok()
2293 );
2294 assert!(
2295 with_url("http://localhost:8080/mcp")
2296 .transport_kind()
2297 .is_ok()
2298 );
2299 assert!(
2300 with_url("http://127.0.0.1:8080/mcp")
2301 .transport_kind()
2302 .is_ok()
2303 );
2304 assert!(with_url("http://192.168.1.5/mcp").transport_kind().is_err());
2305 assert!(with_url("ftp://example.com/mcp").transport_kind().is_err());
2306 assert!(with_url("not a url").transport_kind().is_err());
2307 }
2308
2309 #[test]
2310 fn mcp_server_config_debug_masks_header_values() {
2311 let mut headers = HashMap::new();
2312 headers.insert("Authorization".to_string(), "Bearer sk-secret".to_string());
2313 let mut env_headers = HashMap::new();
2314 env_headers.insert("X-Api-Key".to_string(), "MY_TOKEN_VAR".to_string());
2315 let cfg = McpServerConfig {
2316 url: Some("https://example.com/mcp".to_string()),
2317 headers,
2318 env_headers,
2319 ..Default::default()
2320 };
2321 let rendered = format!("{cfg:?}");
2322 assert!(!rendered.contains("sk-secret"), "{rendered}");
2323 assert!(rendered.contains("Authorization"), "{rendered}");
2324 assert!(rendered.contains("MY_TOKEN_VAR"), "{rendered}");
2326 }
2327
2328 #[test]
2329 fn mcp_url_config_round_trips_through_toml_without_command() {
2330 let cfg = McpServerConfig {
2333 url: Some("https://example.com/mcp".to_string()),
2334 ..Default::default()
2335 };
2336 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2337 assert!(
2338 !blob.contains("command"),
2339 "empty command must be omitted: {blob}"
2340 );
2341 let back: McpServerConfig = toml::from_str(&blob).unwrap();
2342 assert_eq!(back.url.as_deref(), Some("https://example.com/mcp"));
2343 assert!(back.command.is_empty());
2344 let cfg = McpServerConfig {
2346 command: "npx".to_string(),
2347 ..Default::default()
2348 };
2349 let blob = toml::to_string(&toml::Value::try_from(&cfg).unwrap()).unwrap();
2350 assert!(!blob.contains("url"), "{blob}");
2351 }
2352
2353 #[test]
2358 fn model_settings_deserializes_without_reasoning_field() {
2359 let toml_blob = r#"
2360 provider = "ollama"
2361 name = "qwen3-coder:30b"
2362 temperature = 0.7
2363 max_tokens = 4096
2364 "#;
2365 let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
2366 assert_eq!(settings.reasoning, ReasoningLevel::Medium);
2367 assert_eq!(settings.provider, "ollama");
2368 }
2369
2370 #[test]
2371 fn model_settings_round_trips_reasoning_high() {
2372 let original = ModelSettings {
2373 provider: "anthropic".to_string(),
2374 name: "claude-sonnet-4-6".to_string(),
2375 temperature: 0.5,
2376 max_tokens: 8192,
2377 reasoning: ReasoningLevel::High,
2378 };
2379 let toml_blob = toml::to_string(&original).expect("serialize");
2380 let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
2381 assert_eq!(back.reasoning, ReasoningLevel::High);
2382 assert_eq!(back.name, "claude-sonnet-4-6");
2383 }
2384
2385 #[test]
2386 fn agents_config_defaults_and_parses_custom_types() {
2387 let config: Config = toml::from_str("").expect("empty config parses");
2389 assert_eq!(config.agents.timeout_secs, 1200);
2390 assert!(config.agents.types.is_empty());
2391
2392 let config: Config = toml::from_str(
2393 r#"
2394[agents]
2395timeout_secs = 300
2396
2397[agents.types.scout]
2398tools = ["read_file", "execute_command"]
2399safety = "read_only"
2400preamble = "You are a scout."
2401model = "ollama/qwen3:8b"
2402"#,
2403 )
2404 .expect("agents section parses");
2405 assert_eq!(config.agents.timeout_secs, 300);
2406 let scout = &config.agents.types["scout"];
2407 assert_eq!(
2408 scout.tools.as_deref(),
2409 Some(&["read_file".to_string(), "execute_command".to_string()][..])
2410 );
2411 assert_eq!(scout.safety.as_deref(), Some("read_only"));
2412 assert_eq!(scout.model.as_deref(), Some("ollama/qwen3:8b"));
2413 }
2414
2415 #[test]
2416 fn configured_model_alias_resolves_explicit_prefix() {
2417 let mut config = Config::default();
2418 config
2419 .model_aliases
2420 .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
2421 assert_eq!(
2422 resolve_model_alias("fast", &config).unwrap(),
2423 Some("ollama/qwen3-coder:14b".to_string())
2424 );
2425 assert_eq!(
2426 resolve_model_alias("alias:fast", &config).unwrap(),
2427 Some("ollama/qwen3-coder:14b".to_string())
2428 );
2429 }
2430
2431 #[test]
2432 fn alias_prefix_requires_configuration() {
2433 let config = Config::default();
2434 assert!(resolve_model_alias("alias:vision", &config).is_err());
2435 assert_eq!(resolve_model_alias("vision", &config).unwrap(), None);
2436 }
2437
2438 #[test]
2445 fn save_and_reload_preserves_reasoning_field() {
2446 let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
2447 std::fs::create_dir_all(&dir).expect("create temp dir");
2448 let path = dir.join("config.toml");
2449
2450 let mut cfg = Config::default();
2451 cfg.default_model.provider = "ollama".to_string();
2452 cfg.default_model.name = "qwen3-coder:30b".to_string();
2453 cfg.default_model.reasoning = ReasoningLevel::Low;
2454
2455 save_config(&cfg, Some(path.clone())).expect("save");
2456
2457 let blob = std::fs::read_to_string(&path).expect("read");
2458 let loaded: Config = toml::from_str(&blob).expect("parse back");
2459 assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
2460
2461 let _ = std::fs::remove_dir_all(&dir);
2462 }
2463
2464 #[test]
2469 fn save_and_reload_preserves_reasoning_per_model_table() {
2470 let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
2471 std::fs::create_dir_all(&dir).expect("create temp dir");
2472 let path = dir.join("config.toml");
2473
2474 let mut cfg = Config::default();
2475 cfg.reasoning_per_model.insert(
2476 "anthropic/claude-sonnet-4-6".to_string(),
2477 ReasoningLevel::High,
2478 );
2479 cfg.reasoning_per_model
2480 .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
2481
2482 save_config(&cfg, Some(path.clone())).expect("save");
2483
2484 let blob = std::fs::read_to_string(&path).expect("read");
2485 let loaded: Config = toml::from_str(&blob).expect("parse back");
2486 assert_eq!(
2487 loaded
2488 .reasoning_per_model
2489 .get("anthropic/claude-sonnet-4-6"),
2490 Some(&ReasoningLevel::High)
2491 );
2492 assert_eq!(
2493 loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
2494 Some(&ReasoningLevel::Low)
2495 );
2496
2497 let _ = std::fs::remove_dir_all(&dir);
2498 }
2499
2500 #[test]
2503 fn save_and_reload_preserves_ollama_context_overrides() {
2504 let dir = std::env::temp_dir().join("mermaid_test_config_ollama_ctx");
2505 std::fs::create_dir_all(&dir).expect("create temp dir");
2506 let path = dir.join("config.toml");
2507
2508 let mut cfg = Config::default();
2509 cfg.ollama_num_ctx_per_model
2510 .insert("ollama/ornith:9b".to_string(), 131_072);
2511 cfg.ollama.allow_ram_offload = true;
2512 cfg.ollama.max_auto_num_ctx = Some(65_536);
2513
2514 save_config(&cfg, Some(path.clone())).expect("save");
2515 let blob = std::fs::read_to_string(&path).expect("read");
2516 let loaded: Config = toml::from_str(&blob).expect("parse back");
2517
2518 assert_eq!(
2519 loaded.ollama_num_ctx_per_model.get("ollama/ornith:9b"),
2520 Some(&131_072)
2521 );
2522 assert!(loaded.ollama.allow_ram_offload);
2523 assert_eq!(loaded.ollama.max_auto_num_ctx, Some(65_536));
2524
2525 let _ = std::fs::remove_dir_all(&dir);
2526 }
2527
2528 #[test]
2531 fn config_deserializes_without_ollama_context_keys() {
2532 let toml_blob = r#"
2533[ollama]
2534host = "localhost"
2535port = 11434
2536"#;
2537 let cfg: Config = toml::from_str(toml_blob).expect("parse");
2538 assert!(cfg.ollama_num_ctx_per_model.is_empty());
2539 assert!(!cfg.ollama.allow_ram_offload);
2540 assert_eq!(cfg.ollama.max_auto_num_ctx, None);
2541 assert!(cfg.ollama.auto_start);
2544 }
2545
2546 #[test]
2550 fn config_deserializes_without_reasoning_per_model() {
2551 let toml_blob = r#"
2552 last_used_model = "ollama/qwen3-coder:30b"
2553
2554 [default_model]
2555 provider = "ollama"
2556 name = "qwen3-coder:30b"
2557 temperature = 0.7
2558 max_tokens = 4096
2559 "#;
2560 let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
2561 assert!(cfg.reasoning_per_model.is_empty());
2562 assert!(!cfg.prompt.is_customized());
2563 }
2564
2565 #[cfg(unix)]
2569 #[test]
2570 fn save_config_writes_owner_only_perms() {
2571 use std::os::unix::fs::PermissionsExt;
2572 let dir = std::env::temp_dir().join("mermaid_test_config_perms");
2573 std::fs::create_dir_all(&dir).expect("create temp dir");
2574 let path = dir.join("config.toml");
2575 std::fs::write(&path, "stale").expect("seed");
2577 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
2578
2579 save_config(&Config::default(), Some(path.clone())).expect("save");
2580 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2581 assert_eq!(mode, 0o600, "config must be written owner-only");
2582
2583 let _ = std::fs::remove_dir_all(&dir);
2584 }
2585
2586 #[test]
2587 fn config_defaults_computer_use_auto_screenshot_on() {
2588 let cfg: Config = toml::from_str("").expect("empty config");
2590 assert!(cfg.computer_use.auto_screenshot);
2591 }
2592
2593 #[test]
2594 fn prompt_config_replaces_and_appends_without_persisting() {
2595 let mut cfg = Config::default();
2596 cfg.prompt.system_prompt = Some("base".to_string());
2597 cfg.prompt
2598 .append_system_prompt
2599 .push("extra instructions".to_string());
2600
2601 assert_eq!(
2602 cfg.prompt.render_system_prompt("default"),
2603 "base\n\nextra instructions"
2604 );
2605
2606 let blob = toml::to_string(&cfg).expect("serialize");
2607 assert!(!blob.contains("extra instructions"));
2608 let loaded: Config = toml::from_str(&blob).expect("deserialize");
2609 assert!(!loaded.prompt.is_customized());
2610 }
2611
2612 #[test]
2613 fn plan_config_defaults_parse_and_do_not_freeze() {
2614 let c: Config = toml::from_str("").expect("empty config parses");
2616 assert!(!c.plan.auto_approve);
2617 assert!(c.plan.post_approve.is_none());
2618 let c: Config = toml::from_str("[plan]\nauto_approve = true\npost_approve = \"start\"\n")
2620 .expect("plan section parses");
2621 assert!(c.plan.auto_approve);
2622 assert_eq!(c.plan.post_approve, Some(PlanPostApprove::Start));
2623 assert_eq!(
2624 toml::from_str::<Config>("[plan]\npost_approve = \"wait\"\n")
2625 .expect("wait parses")
2626 .plan
2627 .post_approve,
2628 Some(PlanPostApprove::Wait)
2629 );
2630 let blob = toml::to_string(&Config::default()).expect("serialize");
2634 assert!(!blob.contains("post_approve"));
2635 }
2636}