1use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::PawError;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct CustomCli {
18 pub command: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub display_name: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub submit_delay_ms: Option<u64>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub settings_path: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct Preset {
50 pub branches: Vec<String>,
52 pub cli: String,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
73pub struct GovernanceConfig {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub adr: Option<PathBuf>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub test_strategy: Option<PathBuf>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub security: Option<PathBuf>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub dod: Option<PathBuf>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub constitution: Option<PathBuf>,
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
98pub struct SpecsConfig {
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub dir: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
104 pub spec_type: Option<String>,
105}
106
107#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
114#[serde(rename_all = "lowercase")]
115pub enum RoleGatingMode {
116 #[default]
119 Warn,
120 Block,
124 Off,
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134pub struct OpsxConfig {
135 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub role_gating: Option<RoleGatingMode>,
140}
141
142impl OpsxConfig {
143 #[must_use]
146 pub fn role_gating_mode(&self) -> RoleGatingMode {
147 self.role_gating.unwrap_or_default()
148 }
149}
150
151#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
153pub struct LoggingConfig {
154 #[serde(default)]
156 pub enabled: bool,
157}
158
159#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "kebab-case")]
172pub enum ApprovalLevel {
173 Manual,
175 #[default]
177 Auto,
178 FullAuto,
180}
181
182#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
184pub struct DashboardConfig {
185 #[serde(default)]
191 pub show_message_log: bool,
192 #[serde(default)]
196 pub broker_log: BrokerLogConfig,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
205pub struct BrokerLogConfig {
206 #[serde(default = "BrokerLogConfig::default_max_messages")]
210 pub max_messages: usize,
211 #[serde(default = "BrokerLogConfig::default_visible")]
215 pub default_visible: bool,
216}
217
218impl Default for BrokerLogConfig {
219 fn default() -> Self {
220 Self {
221 max_messages: Self::default_max_messages(),
222 default_visible: Self::default_visible(),
223 }
224 }
225}
226
227impl BrokerLogConfig {
228 fn default_max_messages() -> usize {
229 500
230 }
231
232 fn default_visible() -> bool {
233 true
234 }
235}
236
237#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
243pub struct SupervisorConfig {
244 #[serde(default)]
246 pub enabled: bool,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub cli: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub test_command: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub lint_command: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub build_command: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub doc_build_command: Option<String>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub doc_tool_command: Option<String>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub spec_validate_command: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub fmt_check_command: Option<String>,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub security_audit_command: Option<String>,
323 #[serde(default)]
325 pub agent_approval: ApprovalLevel,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub auto_approve: Option<AutoApproveConfig>,
333 #[serde(default)]
341 pub conflict: ConflictConfig,
342 #[serde(default)]
349 pub learnings: bool,
350 #[serde(default)]
357 pub learnings_config: LearningsConfig,
358 #[serde(default)]
365 pub common_dev_allowlist: CommonDevAllowlistConfig,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub verify_on_commit_nudge: Option<bool>,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub strict_branch_guard: Option<bool>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub auto_revert: Option<bool>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub manual_approvals_log: Option<bool>,
415 #[serde(default, skip_serializing_if = "TellConfig::is_default")]
423 pub tell: TellConfig,
424}
425
426#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
433#[serde(rename_all = "kebab-case")]
434pub enum TellMode {
435 #[default]
438 Feedback,
439 SendKeys,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
452pub struct TellConfig {
453 #[serde(default)]
455 pub mode: TellMode,
456 #[serde(default = "TellConfig::default_inventory_max_age_seconds")]
459 pub inventory_max_age_seconds: u64,
460}
461
462impl Default for TellConfig {
463 fn default() -> Self {
464 Self {
465 mode: TellMode::default(),
466 inventory_max_age_seconds: Self::default_inventory_max_age_seconds(),
467 }
468 }
469}
470
471impl TellConfig {
472 fn default_inventory_max_age_seconds() -> u64 {
473 60
474 }
475
476 #[must_use]
482 pub fn is_default(&self) -> bool {
483 *self == Self::default()
484 }
485}
486
487impl SupervisorConfig {
488 #[must_use]
491 pub fn strict_branch_guard(&self) -> bool {
492 self.strict_branch_guard.unwrap_or(true)
493 }
494
495 #[must_use]
499 pub fn auto_revert(&self) -> bool {
500 self.auto_revert.unwrap_or(false)
501 }
502
503 #[must_use]
508 pub fn manual_approvals_log_enabled(&self) -> bool {
509 self.manual_approvals_log.unwrap_or(true)
510 }
511
512 #[must_use]
516 pub fn gate_commands(&self) -> crate::skills::GateCommands<'_> {
517 crate::skills::GateCommands {
518 test_command: self.test_command.as_deref(),
519 lint_command: self.lint_command.as_deref(),
520 build_command: self.build_command.as_deref(),
521 doc_build_command: self.doc_build_command.as_deref(),
522 spec_validate_command: self.spec_validate_command.as_deref(),
523 fmt_check_command: self.fmt_check_command.as_deref(),
524 security_audit_command: self.security_audit_command.as_deref(),
525 doc_tool_command: self.doc_tool_command.as_deref(),
526 }
527 }
528
529 #[must_use]
536 pub fn verify_on_commit_nudge_enabled(&self) -> bool {
537 self.verify_on_commit_nudge.unwrap_or(true)
538 }
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
550pub struct CommonDevAllowlistConfig {
551 #[serde(default = "CommonDevAllowlistConfig::default_enabled")]
557 pub enabled: bool,
558 #[serde(default)]
565 pub extra: Vec<String>,
566}
567
568impl Default for CommonDevAllowlistConfig {
569 fn default() -> Self {
570 Self {
571 enabled: Self::default_enabled(),
572 extra: Vec::new(),
573 }
574 }
575}
576
577impl CommonDevAllowlistConfig {
578 fn default_enabled() -> bool {
579 true
580 }
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
590pub struct LearningsConfig {
591 #[serde(default = "LearningsConfig::default_flush_interval_seconds")]
593 pub flush_interval_seconds: u64,
594 #[serde(default)]
602 pub broker_publish: BrokerPublish,
603}
604
605impl Default for LearningsConfig {
606 fn default() -> Self {
607 Self {
608 flush_interval_seconds: Self::default_flush_interval_seconds(),
609 broker_publish: BrokerPublish::default(),
610 }
611 }
612}
613
614impl LearningsConfig {
615 fn default_flush_interval_seconds() -> u64 {
616 60
617 }
618}
619
620#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
625#[serde(rename_all = "snake_case")]
626pub enum BrokerPublish {
627 #[default]
630 Auto,
631 ForceOff,
633}
634
635impl BrokerPublish {
636 #[must_use]
639 pub fn resolve(self, broker_enabled: bool) -> bool {
640 match self {
641 Self::Auto => broker_enabled,
642 Self::ForceOff => false,
643 }
644 }
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
655pub struct ConflictConfig {
656 #[serde(default = "ConflictConfig::default_window_seconds")]
659 pub window_seconds: u64,
660 #[serde(default = "ConflictConfig::default_true")]
666 pub warn_on_intent_overlap: bool,
667 #[serde(default = "ConflictConfig::default_true")]
672 pub escalate_on_violation: bool,
673}
674
675impl Default for ConflictConfig {
676 fn default() -> Self {
677 Self {
678 window_seconds: Self::default_window_seconds(),
679 warn_on_intent_overlap: true,
680 escalate_on_violation: true,
681 }
682 }
683}
684
685impl ConflictConfig {
686 fn default_window_seconds() -> u64 {
687 120
688 }
689
690 fn default_true() -> bool {
691 true
692 }
693}
694
695#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
707#[serde(rename_all = "kebab-case")]
708pub enum ApprovalLevelPreset {
709 Off,
711 Conservative,
713 #[default]
715 Safe,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
729pub struct AutoApproveConfig {
730 #[serde(default = "AutoApproveConfig::default_enabled")]
732 pub enabled: bool,
733 #[serde(default)]
737 pub safe_commands: Vec<String>,
738 #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
741 pub stall_threshold_seconds: u64,
742 #[serde(default)]
749 pub approval_level: ApprovalLevelPreset,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
760 pub approve_worktree_writes: Option<bool>,
761}
762
763impl Default for AutoApproveConfig {
764 fn default() -> Self {
765 Self {
766 enabled: Self::default_enabled(),
767 safe_commands: Vec::new(),
768 stall_threshold_seconds: Self::default_stall_threshold_seconds(),
769 approval_level: ApprovalLevelPreset::Safe,
770 approve_worktree_writes: None,
771 }
772 }
773}
774
775impl AutoApproveConfig {
776 pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
779
780 fn default_enabled() -> bool {
781 true
782 }
783
784 fn default_stall_threshold_seconds() -> u64 {
785 30
786 }
787
788 #[must_use]
795 pub fn resolved(&self) -> Self {
796 let mut out = self.clone();
797 if out.approval_level == ApprovalLevelPreset::Off {
798 out.enabled = false;
799 }
800 if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
801 eprintln!(
802 "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
803 out.stall_threshold_seconds,
804 Self::MIN_STALL_THRESHOLD_SECONDS
805 );
806 out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
807 }
808 out
809 }
810
811 #[must_use]
818 pub fn approve_worktree_writes(&self) -> bool {
819 self.approve_worktree_writes.unwrap_or(true)
820 }
821
822 #[must_use]
829 pub fn effective_whitelist(&self) -> Vec<String> {
830 let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
831 .iter()
832 .map(|s| (*s).to_string())
833 .collect();
834 for extra in &self.safe_commands {
835 if !out.iter().any(|e| e == extra) {
836 out.push(extra.clone());
837 }
838 }
839 if self.approval_level == ApprovalLevelPreset::Conservative {
840 out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
841 }
842 out
843 }
844}
845
846#[must_use]
866pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
867 match (cli, level) {
868 ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
869 ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
870 ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
871 _ => "",
872 }
873}
874
875#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
883pub struct WatcherConfig {
884 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub republish_working_ttl_seconds: Option<u64>,
895}
896
897impl WatcherConfig {
898 pub const DEFAULT_REPUBLISH_TTL_SECONDS: u64 = 60;
900 pub const MIN_REPUBLISH_TTL_SECONDS: u64 = 5;
902
903 #[must_use]
911 pub fn republish_working_ttl_seconds(&self) -> u64 {
912 match self.republish_working_ttl_seconds {
913 None => Self::DEFAULT_REPUBLISH_TTL_SECONDS,
914 Some(0) => 0,
915 Some(n) if n < Self::MIN_REPUBLISH_TTL_SECONDS => {
916 eprintln!(
917 "warning: [broker.watcher] republish_working_ttl_seconds = {n} clamped to {}s minimum",
918 Self::MIN_REPUBLISH_TTL_SECONDS
919 );
920 Self::MIN_REPUBLISH_TTL_SECONDS
921 }
922 Some(n) => n,
923 }
924 }
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
929pub struct BrokerConfig {
930 #[serde(default)]
932 pub enabled: bool,
933 #[serde(default = "BrokerConfig::default_port")]
935 pub port: u16,
936 #[serde(default = "BrokerConfig::default_bind")]
938 pub bind: String,
939 #[serde(default)]
941 pub watcher: WatcherConfig,
942}
943
944impl Default for BrokerConfig {
945 fn default() -> Self {
946 Self {
947 enabled: false,
948 port: 9119,
949 bind: "127.0.0.1".to_string(),
950 watcher: WatcherConfig::default(),
951 }
952 }
953}
954
955impl BrokerConfig {
956 pub fn url(&self) -> String {
958 format!("http://{}:{}", self.bind, self.port)
959 }
960
961 fn default_port() -> u16 {
962 9119
963 }
964
965 fn default_bind() -> String {
966 "127.0.0.1".to_string()
967 }
968}
969
970#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
975pub struct LayoutConfig {
976 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub border_affordances: Option<bool>,
985}
986
987impl LayoutConfig {
988 #[must_use]
990 pub fn border_affordances_enabled(&self) -> bool {
991 self.border_affordances.unwrap_or(true)
992 }
993}
994
995#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
999pub struct PawConfig {
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1002 pub default_cli: Option<String>,
1003
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1006 pub default_spec_cli: Option<String>,
1007
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub branch_prefix: Option<String>,
1011
1012 #[serde(default, skip_serializing_if = "Option::is_none")]
1014 pub mouse: Option<bool>,
1015
1016 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1018 pub clis: HashMap<String, CustomCli>,
1019
1020 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1022 pub presets: HashMap<String, Preset>,
1023
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub specs: Option<SpecsConfig>,
1027
1028 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub logging: Option<LoggingConfig>,
1031
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub dashboard: Option<DashboardConfig>,
1035
1036 #[serde(default)]
1038 pub broker: BrokerConfig,
1039
1040 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 pub supervisor: Option<SupervisorConfig>,
1043
1044 #[serde(default)]
1050 pub governance: GovernanceConfig,
1051
1052 #[serde(default, skip_serializing_if = "Option::is_none")]
1058 pub layout: Option<LayoutConfig>,
1059
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1066 pub opsx: Option<OpsxConfig>,
1067}
1068
1069impl PawConfig {
1070 #[must_use]
1075 pub fn merged_with(&self, overlay: &Self) -> Self {
1076 let mut clis = self.clis.clone();
1077 for (k, v) in &overlay.clis {
1078 clis.insert(k.clone(), v.clone());
1079 }
1080
1081 let mut presets = self.presets.clone();
1082 for (k, v) in &overlay.presets {
1083 presets.insert(k.clone(), v.clone());
1084 }
1085
1086 Self {
1087 default_cli: overlay
1088 .default_cli
1089 .clone()
1090 .or_else(|| self.default_cli.clone()),
1091 default_spec_cli: overlay
1092 .default_spec_cli
1093 .clone()
1094 .or_else(|| self.default_spec_cli.clone()),
1095 branch_prefix: overlay
1096 .branch_prefix
1097 .clone()
1098 .or_else(|| self.branch_prefix.clone()),
1099 mouse: overlay.mouse.or(self.mouse),
1100 clis,
1101 presets,
1102 specs: overlay.specs.clone().or_else(|| self.specs.clone()),
1103 logging: overlay.logging.clone().or_else(|| self.logging.clone()),
1104 dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
1105 broker: if overlay.broker == BrokerConfig::default() {
1106 self.broker.clone()
1107 } else {
1108 overlay.broker.clone()
1109 },
1110 supervisor: overlay
1111 .supervisor
1112 .clone()
1113 .or_else(|| self.supervisor.clone()),
1114 governance: GovernanceConfig {
1115 adr: overlay
1116 .governance
1117 .adr
1118 .clone()
1119 .or_else(|| self.governance.adr.clone()),
1120 test_strategy: overlay
1121 .governance
1122 .test_strategy
1123 .clone()
1124 .or_else(|| self.governance.test_strategy.clone()),
1125 security: overlay
1126 .governance
1127 .security
1128 .clone()
1129 .or_else(|| self.governance.security.clone()),
1130 dod: overlay
1131 .governance
1132 .dod
1133 .clone()
1134 .or_else(|| self.governance.dod.clone()),
1135 constitution: overlay
1136 .governance
1137 .constitution
1138 .clone()
1139 .or_else(|| self.governance.constitution.clone()),
1140 },
1141 layout: overlay.layout.clone().or_else(|| self.layout.clone()),
1142 opsx: overlay.opsx.clone().or_else(|| self.opsx.clone()),
1143 }
1144 }
1145
1146 #[must_use]
1150 pub fn role_gating_mode(&self) -> RoleGatingMode {
1151 self.opsx
1152 .as_ref()
1153 .map(OpsxConfig::role_gating_mode)
1154 .unwrap_or_default()
1155 }
1156
1157 #[must_use]
1161 pub fn border_affordances_enabled(&self) -> bool {
1162 self.layout
1163 .as_ref()
1164 .is_none_or(LayoutConfig::border_affordances_enabled)
1165 }
1166
1167 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
1169 self.presets.get(name)
1170 }
1171
1172 pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
1174 self.dashboard.as_ref()
1175 }
1176}
1177
1178pub fn global_config_path() -> Result<PathBuf, PawError> {
1180 crate::dirs::config_dir()
1181 .map(|d| d.join("git-paw").join("config.toml"))
1182 .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
1183}
1184
1185pub fn repo_config_path(repo_root: &Path) -> PathBuf {
1187 repo_root.join(".git-paw").join("config.toml")
1188}
1189
1190fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
1192 match fs::read_to_string(path) {
1193 Ok(contents) => {
1194 let config: PawConfig = toml::from_str(&contents)
1195 .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
1196 Ok(Some(config))
1197 }
1198 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1199 Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
1200 }
1201}
1202
1203pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
1211 let mut config = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1212 auto_wire_governance(&mut config, repo_root);
1213 Ok(config)
1214}
1215
1216fn auto_wire_governance(config: &mut PawConfig, repo_root: &Path) {
1234 if config.governance.constitution.is_some() {
1235 return;
1236 }
1237 let Some(specs_cfg) = config.specs.as_ref() else {
1238 return;
1239 };
1240 let Some(spec_type) = specs_cfg.spec_type.as_deref() else {
1241 return;
1242 };
1243 if spec_type != "speckit" {
1244 return;
1245 }
1246 let dir = specs_cfg.dir.as_deref().unwrap_or("specs");
1247 let specs_dir = repo_root.join(dir);
1248 if let Some(detected) = crate::specs::speckit::detect_constitution(&specs_dir) {
1249 config.governance.constitution = Some(detected);
1250 }
1251}
1252
1253pub fn load_config(
1279 repo_root: &Path,
1280 user_config_path: Option<&Path>,
1281) -> Result<PawConfig, PawError> {
1282 let global_path = match user_config_path {
1283 Some(p) => p.to_path_buf(),
1284 None => global_config_path()?,
1285 };
1286 load_config_from(&global_path, repo_root)
1287}
1288
1289pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
1294 let global = load_config_file(global_path)?.unwrap_or_default();
1295 let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1296 let mut merged = global.merged_with(&repo);
1297 auto_wire_governance(&mut merged, repo_root);
1298 Ok(merged)
1299}
1300
1301pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
1303 save_config_to(&repo_config_path(repo_root), config)
1304}
1305
1306fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
1308 let dir = path
1309 .parent()
1310 .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
1311 fs::create_dir_all(dir)
1312 .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
1313
1314 let contents =
1315 toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
1316
1317 let tmp = path.with_extension("toml.tmp");
1319 fs::write(&tmp, &contents)
1320 .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
1321 fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
1322
1323 Ok(())
1324}
1325
1326pub fn add_custom_cli(
1330 name: &str,
1331 command: &str,
1332 display_name: Option<&str>,
1333) -> Result<(), PawError> {
1334 add_custom_cli_to(&global_config_path()?, name, command, display_name)
1335}
1336
1337pub fn add_custom_cli_to(
1341 config_path: &Path,
1342 name: &str,
1343 command: &str,
1344 display_name: Option<&str>,
1345) -> Result<(), PawError> {
1346 let resolved_command = if Path::new(command).is_absolute() {
1347 command.to_string()
1348 } else {
1349 which::which(command)
1350 .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
1351 .to_string_lossy()
1352 .into_owned()
1353 };
1354
1355 let mut config = load_config_file(config_path)?.unwrap_or_default();
1356
1357 config.clis.insert(
1358 name.to_string(),
1359 CustomCli {
1360 command: resolved_command,
1361 display_name: display_name.map(String::from),
1362 submit_delay_ms: None,
1363 settings_path: None,
1364 },
1365 );
1366
1367 save_config_to(config_path, &config)
1368}
1369
1370#[allow(clippy::too_many_lines)] pub fn generate_default_config() -> String {
1374 r#"# git-paw configuration
1375# See https://github.com/bearicorn/git-paw for documentation.
1376
1377# Pre-select a CLI in the interactive picker (user can still change).
1378# Omit to show the full picker with no default.
1379# default_cli = ""
1380
1381# Enable tmux mouse mode for sessions (default: true).
1382# mouse = true
1383
1384# Bypass the CLI picker entirely for --from-specs mode.
1385# Omit to prompt or use per-spec paw_cli fields.
1386# default_spec_cli = ""
1387
1388# Prefix for spec-derived branch names (default: "spec/" ).
1389# branch_prefix = "spec/"
1390
1391# Dashboard message log configuration.
1392# [dashboard]
1393# show_message_log = false
1394
1395# Spec scanning configuration.
1396# [specs]
1397# dir = "specs"
1398#
1399# OpenSpec format (directory-based, default):
1400# type = "openspec"
1401#
1402# Markdown format (frontmatter-based):
1403# type = "markdown"
1404# Each .md file uses YAML frontmatter fields:
1405# paw_status — "pending" | "done" | "in-progress" (required)
1406# paw_branch — branch name suffix (optional, falls back to filename)
1407# paw_cli — CLI override for this spec (optional)
1408
1409# Session logging configuration.
1410# [logging]
1411# enabled = false
1412
1413# HTTP broker for agent coordination (requires --broker flag on start).
1414# [broker]
1415# enabled = true
1416# port = 9119
1417# bind = "127.0.0.1"
1418
1419# Supervisor mode — git-paw acts as a coordinating layer in front of the
1420# agent CLI, enforcing approval policy and running configured gate
1421# commands during the five-gate verification workflow.
1422#
1423# Gate command templates feed the supervisor skill's five gates: gate 1
1424# Testing (fmt_check / lint / build / test), gate 3 Spec audit
1425# (spec_validate), gate 4 Doc audit (doc_build), gate 5 Security audit
1426# (security_audit). When a key is omitted, the matching placeholder
1427# renders as `(not configured)` in the supervisor skill and the agent
1428# skips that tooling step (the gate's manual review still applies).
1429# `{{CHANGE_ID}}` inside spec_validate_command is substituted by the
1430# supervisor agent at verification time with the change name.
1431# [supervisor]
1432# enabled = true
1433# cli = "claude"
1434# test_command = "just check" # or: "cargo test", "npm test", "pytest"
1435# lint_command = "cargo clippy -- -D warnings" # or: "npm run lint", "ruff check .", "golangci-lint run"
1436# build_command = "cargo build" # or: "npm run build", "mvn package", "go build ./..."
1437# fmt_check_command = "cargo fmt --check" # or: "prettier --check .", "gofmt -l ."
1438# doc_build_command = "mdbook build docs/" # or: "sphinx-build", "mkdocs build"
1439# doc_tool_command = "cargo doc --no-deps" # or: "sphinx-build -W docs docs/_build", "javadoc", "npx typedoc"
1440# spec_validate_command = "openspec validate {{CHANGE_ID}} --strict" # OpenSpec only
1441# security_audit_command = "cargo audit" # or: "npm audit", "bandit -r ."
1442# agent_approval = "auto" # one of: "manual", "auto", "full-auto"
1443# verify_on_commit_nudge = true # broker nudges the supervisor to verify each commit promptly (default true)
1444#
1445# Routing through the supervisor (the /tell and /agents commands). The user
1446# types in the supervisor pane and the supervisor routes the prompt to the
1447# named agent. `mode` selects the default delivery channel:
1448# "feedback" (default) — queue an agent.feedback; the agent picks it up on
1449# its next inbox poll. Safe for mixed-mode sessions.
1450# "send-keys" — inject the prompt directly into the target pane;
1451# used only when the target is in accept-edits mode,
1452# otherwise /tell falls back to feedback.
1453# `inventory_max_age_seconds` is how stale the cached /agents inventory may be
1454# before /tell or /agents re-polls the broker (default 60).
1455# [supervisor.tell]
1456# mode = "feedback"
1457# inventory_max_age_seconds = 60
1458#
1459# Conflict detector tuning. Active only when supervisor mode is enabled.
1460# [supervisor.conflict]
1461# window_seconds = 120 # escalate unresolved in-flight conflicts after this many seconds
1462# warn_on_intent_overlap = true # emit feedback when two agent.intent declarations overlap
1463# escalate_on_violation = true # also publish agent.question to supervisor on ownership violations
1464
1465# Common dev-command allowlist. When supervisor mode starts a session,
1466# git-paw seeds .claude/settings.json::allowed_bash_prefixes with a
1467# curated preset (cargo, git, just, mdbook, openspec, find, grep, sed -n)
1468# so agents do not hit a permission prompt for each variant. Opt out by
1469# setting enabled = false; extend with project-specific prefixes via extra.
1470# [supervisor.common_dev_allowlist]
1471# enabled = true
1472# extra = ["pnpm test", "deno fmt"]
1473
1474# opsx (OpenSpec) role gating. When the session's spec engine is OpenSpec,
1475# git-paw's post-commit guard detects archive activity (`/opsx:archive` /
1476# `openspec archive`) by a non-supervisor agent and reacts per this mode:
1477# "warn" (default) — feedback to the offending agent + a permission_pattern
1478# learning the user sees in learnings.
1479# "block" — warn behaviour PLUS a feedback to the supervisor
1480# requesting it revert the offending commit.
1481# "off" — guard disabled entirely.
1482# The guard is inert under non-OpenSpec engines (speckit, markdown).
1483# [opsx]
1484# role_gating = "warn"
1485
1486# Custom CLI definitions.
1487# [clis.my-agent]
1488# command = "/usr/local/bin/my-agent"
1489# display_name = "My Agent"
1490
1491# Named presets for quick launches.
1492# [presets.my-preset]
1493# branches = ["feat/api", "fix/db"]
1494# cli = ""
1495"#
1496 .to_string()
1497}
1498
1499pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
1503 remove_custom_cli_from(&global_config_path()?, name)
1504}
1505
1506pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
1510 let mut config = load_config_file(config_path)?.unwrap_or_default();
1511
1512 if config.clis.remove(name).is_none() {
1513 return Err(PawError::CliNotFound(name.to_string()));
1514 }
1515
1516 save_config_to(config_path, &config)
1517}
1518
1519#[cfg(test)]
1520mod tests {
1521 use super::*;
1522 use tempfile::TempDir;
1523
1524 fn write_file(path: &Path, content: &str) {
1525 if let Some(parent) = path.parent() {
1526 fs::create_dir_all(parent).unwrap();
1527 }
1528 fs::write(path, content).unwrap();
1529 }
1530
1531 #[test]
1534 fn parses_config_with_all_fields() {
1535 let tmp = TempDir::new().unwrap();
1536 let path = tmp.path().join("config.toml");
1537 write_file(
1538 &path,
1539 r#"
1540default_cli = "claude"
1541mouse = false
1542default_spec_cli = "gemini"
1543branch_prefix = "spec/"
1544
1545[clis.my-agent]
1546command = "/usr/local/bin/my-agent"
1547display_name = "My Agent"
1548
1549[clis.local-llm]
1550command = "ollama-code"
1551
1552[presets.backend]
1553branches = ["feature/api", "fix/db"]
1554cli = "claude"
1555
1556[specs]
1557dir = "my-specs"
1558type = "openspec"
1559
1560[logging]
1561enabled = true
1562"#,
1563 );
1564
1565 let config = load_config_file(&path).unwrap().unwrap();
1566 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1567 assert_eq!(config.mouse, Some(false));
1568 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1569 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1570 assert_eq!(config.clis.len(), 2);
1571 assert_eq!(
1572 config.clis["my-agent"].display_name.as_deref(),
1573 Some("My Agent")
1574 );
1575 assert_eq!(config.clis["local-llm"].command, "ollama-code");
1576 assert_eq!(config.presets["backend"].cli, "claude");
1577 assert_eq!(
1578 config.presets["backend"].branches,
1579 vec!["feature/api", "fix/db"]
1580 );
1581 let specs = config.specs.unwrap();
1582 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1583 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1584 let logging = config.logging.unwrap();
1585 assert!(logging.enabled);
1586 }
1587
1588 #[test]
1589 fn all_fields_are_optional() {
1590 let tmp = TempDir::new().unwrap();
1591 let path = tmp.path().join("config.toml");
1592 write_file(&path, "default_cli = \"gemini\"\n");
1593
1594 let config = load_config_file(&path).unwrap().unwrap();
1595 assert_eq!(config.default_cli.as_deref(), Some("gemini"));
1596 assert_eq!(config.mouse, None);
1597 assert!(config.clis.is_empty());
1598 assert!(config.presets.is_empty());
1599 }
1600
1601 #[test]
1602 fn returns_defaults_when_no_files_exist() {
1603 let tmp = TempDir::new().unwrap();
1604 let global_path = tmp.path().join("nonexistent").join("config.toml");
1605 let repo_root = tmp.path().join("repo");
1606 fs::create_dir_all(&repo_root).unwrap();
1607
1608 let config = load_config_from(&global_path, &repo_root).unwrap();
1609 assert_eq!(config.default_cli, None);
1610 assert_eq!(config.mouse, None);
1611 assert!(config.clis.is_empty());
1612 assert!(config.presets.is_empty());
1613 }
1614
1615 #[test]
1616 fn reports_error_for_invalid_toml() {
1617 let tmp = TempDir::new().unwrap();
1618 let path = tmp.path().join("bad.toml");
1619 write_file(&path, "this is not [valid toml");
1620
1621 let err = load_config_file(&path).unwrap_err();
1622 assert!(err.to_string().contains("bad.toml"));
1623 }
1624
1625 #[test]
1628 fn repo_config_overrides_global_scalars() {
1629 let tmp = TempDir::new().unwrap();
1630 let global_path = tmp.path().join("global").join("config.toml");
1631 let repo_root = tmp.path().join("repo");
1632 fs::create_dir_all(&repo_root).unwrap();
1633
1634 write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
1635 write_file(
1636 &repo_config_path(&repo_root),
1637 "default_cli = \"gemini\"\n", );
1639
1640 let config = load_config_from(&global_path, &repo_root).unwrap();
1641 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
1644
1645 #[test]
1646 fn repo_config_merges_cli_maps() {
1647 let tmp = TempDir::new().unwrap();
1648 let global_path = tmp.path().join("global").join("config.toml");
1649 let repo_root = tmp.path().join("repo");
1650 fs::create_dir_all(&repo_root).unwrap();
1651
1652 write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
1653 write_file(
1654 &repo_config_path(&repo_root),
1655 "[clis.agent-b]\ncommand = \"/bin/b\"\n",
1656 );
1657
1658 let config = load_config_from(&global_path, &repo_root).unwrap();
1659 assert_eq!(config.clis.len(), 2);
1660 assert!(config.clis.contains_key("agent-a"));
1661 assert!(config.clis.contains_key("agent-b"));
1662 }
1663
1664 #[test]
1665 fn repo_cli_overrides_global_cli_with_same_name() {
1666 let tmp = TempDir::new().unwrap();
1667 let global_path = tmp.path().join("global").join("config.toml");
1668 let repo_root = tmp.path().join("repo");
1669 fs::create_dir_all(&repo_root).unwrap();
1670
1671 write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
1672 write_file(
1673 &repo_config_path(&repo_root),
1674 "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
1675 );
1676
1677 let config = load_config_from(&global_path, &repo_root).unwrap();
1678 assert_eq!(config.clis["my-agent"].command, "/new/path");
1679 assert_eq!(
1680 config.clis["my-agent"].display_name.as_deref(),
1681 Some("Overridden")
1682 );
1683 }
1684
1685 #[test]
1686 fn load_config_from_reads_global_file_when_no_repo() {
1687 let tmp = TempDir::new().unwrap();
1688 let global_path = tmp.path().join("global").join("config.toml");
1689 let repo_root = tmp.path().join("repo");
1690 fs::create_dir_all(&repo_root).unwrap();
1691
1692 write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
1693 let config = load_config_from(&global_path, &repo_root).unwrap();
1696 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1697 assert_eq!(config.mouse, Some(false));
1698 }
1699
1700 #[test]
1701 fn load_config_from_reads_repo_file_when_no_global() {
1702 let tmp = TempDir::new().unwrap();
1703 let global_path = tmp.path().join("nonexistent").join("config.toml");
1704 let repo_root = tmp.path().join("repo");
1705 fs::create_dir_all(&repo_root).unwrap();
1706
1707 write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
1708
1709 let config = load_config_from(&global_path, &repo_root).unwrap();
1710 assert_eq!(config.default_cli.as_deref(), Some("codex"));
1711 }
1712
1713 #[test]
1716 fn preset_accessible_by_name() {
1717 let tmp = TempDir::new().unwrap();
1718 let global_path = tmp.path().join("global").join("config.toml");
1719 let repo_root = tmp.path().join("repo");
1720 fs::create_dir_all(&repo_root).unwrap();
1721
1722 write_file(
1723 &repo_config_path(&repo_root),
1724 "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
1725 );
1726
1727 let config = load_config_from(&global_path, &repo_root).unwrap();
1728 let preset = config.get_preset("backend").unwrap();
1729 assert_eq!(preset.cli, "claude");
1730 assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
1731 }
1732
1733 #[test]
1734 fn preset_returns_none_when_not_in_config() {
1735 let tmp = TempDir::new().unwrap();
1736 let global_path = tmp.path().join("config.toml");
1737 write_file(&global_path, "default_cli = \"claude\"\n");
1738
1739 let config = load_config_file(&global_path).unwrap().unwrap();
1740 assert!(config.get_preset("nonexistent").is_none());
1741 }
1742
1743 #[test]
1746 fn add_cli_writes_to_config_file() {
1747 let tmp = TempDir::new().unwrap();
1748 let config_path = tmp.path().join("git-paw").join("config.toml");
1749
1750 add_custom_cli_to(
1752 &config_path,
1753 "my-agent",
1754 "/usr/local/bin/my-agent",
1755 Some("My Agent"),
1756 )
1757 .unwrap();
1758
1759 let config = load_config_file(&config_path).unwrap().unwrap();
1761 assert_eq!(config.clis.len(), 1);
1762 assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
1763 assert_eq!(
1764 config.clis["my-agent"].display_name.as_deref(),
1765 Some("My Agent")
1766 );
1767 }
1768
1769 #[test]
1770 fn add_cli_preserves_existing_entries() {
1771 let tmp = TempDir::new().unwrap();
1772 let config_path = tmp.path().join("git-paw").join("config.toml");
1773
1774 add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
1775 add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
1776
1777 let config = load_config_file(&config_path).unwrap().unwrap();
1778 assert_eq!(config.clis.len(), 2);
1779 assert!(config.clis.contains_key("first"));
1780 assert!(config.clis.contains_key("second"));
1781 }
1782
1783 #[test]
1784 fn add_cli_errors_when_command_not_on_path() {
1785 let tmp = TempDir::new().unwrap();
1786 let config_path = tmp.path().join("config.toml");
1787
1788 let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
1789 .unwrap_err();
1790 assert!(err.to_string().contains("not found on PATH"));
1791 }
1792
1793 #[test]
1796 fn remove_cli_deletes_entry_from_config_file() {
1797 let tmp = TempDir::new().unwrap();
1798 let config_path = tmp.path().join("git-paw").join("config.toml");
1799
1800 add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
1802 add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
1803
1804 remove_custom_cli_from(&config_path, "remove-me").unwrap();
1806
1807 let config = load_config_file(&config_path).unwrap().unwrap();
1809 assert_eq!(config.clis.len(), 1);
1810 assert!(config.clis.contains_key("keep-me"));
1811 assert!(!config.clis.contains_key("remove-me"));
1812 }
1813
1814 #[test]
1815 fn remove_nonexistent_cli_returns_cli_not_found_error() {
1816 let tmp = TempDir::new().unwrap();
1817 let config_path = tmp.path().join("config.toml");
1818 write_file(&config_path, "");
1820
1821 let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
1822 match err {
1823 PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
1824 other => panic!("expected CliNotFound, got: {other}"),
1825 }
1826 }
1827
1828 #[test]
1829 fn remove_cli_from_empty_config_returns_error() {
1830 let tmp = TempDir::new().unwrap();
1831 let config_path = tmp.path().join("config.toml");
1832 let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
1835 match err {
1836 PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
1837 other => panic!("expected CliNotFound, got: {other}"),
1838 }
1839 }
1840
1841 #[test]
1846 fn parses_default_spec_cli_when_present() {
1847 let tmp = TempDir::new().unwrap();
1848 let path = tmp.path().join("config.toml");
1849 write_file(&path, "default_spec_cli = \"claude\"\n");
1850
1851 let config = load_config_file(&path).unwrap().unwrap();
1852 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1853 }
1854
1855 #[test]
1856 fn default_spec_cli_defaults_to_none() {
1857 let tmp = TempDir::new().unwrap();
1858 let path = tmp.path().join("config.toml");
1859 write_file(&path, "default_cli = \"claude\"\n");
1860
1861 let config = load_config_file(&path).unwrap().unwrap();
1862 assert_eq!(config.default_spec_cli, None);
1863 }
1864
1865 #[test]
1866 fn repo_overrides_global_default_spec_cli() {
1867 let tmp = TempDir::new().unwrap();
1868 let global_path = tmp.path().join("global").join("config.toml");
1869 let repo_root = tmp.path().join("repo");
1870 fs::create_dir_all(&repo_root).unwrap();
1871
1872 write_file(&global_path, "default_spec_cli = \"claude\"\n");
1873 write_file(
1874 &repo_config_path(&repo_root),
1875 "default_spec_cli = \"gemini\"\n",
1876 );
1877
1878 let config = load_config_from(&global_path, &repo_root).unwrap();
1879 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1880 }
1881
1882 #[test]
1883 fn global_default_spec_cli_preserved_when_repo_absent() {
1884 let tmp = TempDir::new().unwrap();
1885 let global_path = tmp.path().join("global").join("config.toml");
1886 let repo_root = tmp.path().join("repo");
1887 fs::create_dir_all(&repo_root).unwrap();
1888
1889 write_file(&global_path, "default_spec_cli = \"claude\"\n");
1890
1891 let config = load_config_from(&global_path, &repo_root).unwrap();
1892 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1893 }
1894
1895 #[test]
1898 fn config_survives_save_and_load() {
1899 let tmp = TempDir::new().unwrap();
1900 let config_path = tmp.path().join("config.toml");
1901
1902 let original = PawConfig {
1903 default_cli: Some("claude".into()),
1904 default_spec_cli: None,
1905 branch_prefix: None,
1906 mouse: Some(true),
1907 clis: HashMap::from([(
1908 "test".into(),
1909 CustomCli {
1910 command: "/bin/test".into(),
1911 display_name: Some("Test CLI".into()),
1912 submit_delay_ms: None,
1913 settings_path: None,
1914 },
1915 )]),
1916 presets: HashMap::from([(
1917 "dev".into(),
1918 Preset {
1919 branches: vec!["main".into()],
1920 cli: "claude".into(),
1921 },
1922 )]),
1923 specs: None,
1924 logging: None,
1925 dashboard: None,
1926 broker: BrokerConfig::default(),
1927 supervisor: None,
1928 governance: GovernanceConfig::default(),
1929 layout: None,
1930 opsx: None,
1931 };
1932
1933 save_config_to(&config_path, &original).unwrap();
1934 let loaded = load_config_file(&config_path).unwrap().unwrap();
1935 assert_eq!(original, loaded);
1936 }
1937
1938 #[test]
1941 fn parses_specs_section_with_populated_fields() {
1942 let tmp = TempDir::new().unwrap();
1943 let path = tmp.path().join("config.toml");
1944 write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
1945
1946 let config = load_config_file(&path).unwrap().unwrap();
1947 let specs = config.specs.unwrap();
1948 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1949 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1950 }
1951
1952 #[test]
1955 fn parses_logging_section_with_enabled() {
1956 let tmp = TempDir::new().unwrap();
1957 let path = tmp.path().join("config.toml");
1958 write_file(&path, "[logging]\nenabled = true\n");
1959
1960 let config = load_config_file(&path).unwrap().unwrap();
1961 let logging = config.logging.unwrap();
1962 assert!(logging.enabled);
1963 }
1964
1965 #[test]
1968 fn round_trip_with_specs_and_logging() {
1969 let tmp = TempDir::new().unwrap();
1970 let config_path = tmp.path().join("config.toml");
1971
1972 let original = PawConfig {
1973 specs: Some(SpecsConfig {
1974 dir: Some("specs".into()),
1975 spec_type: Some("openspec".into()),
1976 }),
1977 logging: Some(LoggingConfig { enabled: true }),
1978 ..Default::default()
1979 };
1980
1981 save_config_to(&config_path, &original).unwrap();
1982 let loaded = load_config_file(&config_path).unwrap().unwrap();
1983 assert_eq!(original, loaded);
1984 assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
1985 assert!(loaded.logging.unwrap().enabled);
1986 }
1987
1988 #[test]
1991 fn generated_default_config_is_valid_toml() {
1992 let raw = generate_default_config();
1993 let stripped: String = raw
1994 .lines()
1995 .filter(|line| !line.trim_start().starts_with('#'))
1996 .collect::<Vec<&str>>()
1997 .join("\n");
1998
1999 let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
2000 assert!(
2001 parsed.is_ok(),
2002 "generated config with comments stripped should be valid TOML, got: {:?}",
2003 parsed.unwrap_err()
2004 );
2005 }
2006
2007 #[test]
2010 fn branch_prefix_repo_overrides_global() {
2011 let tmp = TempDir::new().unwrap();
2012 let global_path = tmp.path().join("global").join("config.toml");
2013 let repo_root = tmp.path().join("repo");
2014 fs::create_dir_all(&repo_root).unwrap();
2015
2016 write_file(&global_path, "branch_prefix = \"feat/\"\n");
2017 write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
2018
2019 let config = load_config_from(&global_path, &repo_root).unwrap();
2020 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
2021 }
2022
2023 #[test]
2024 fn generated_default_config_contains_commented_examples() {
2025 let output = generate_default_config();
2026 assert!(
2027 output.contains("default_spec_cli"),
2028 "should contain default_spec_cli"
2029 );
2030 assert!(
2031 output.contains("branch_prefix"),
2032 "should contain branch_prefix"
2033 );
2034 assert!(output.contains("[specs]"), "should contain [specs]");
2035 assert!(output.contains("[logging]"), "should contain [logging]");
2036 assert!(output.contains("[broker]"), "should contain [broker]");
2037 }
2038
2039 #[test]
2042 fn broker_config_defaults() {
2043 let config = BrokerConfig::default();
2044 assert!(!config.enabled);
2045 assert_eq!(config.port, 9119);
2046 assert_eq!(config.bind, "127.0.0.1");
2047 }
2048
2049 #[test]
2050 fn broker_config_url() {
2051 let config = BrokerConfig::default();
2052 assert_eq!(config.url(), "http://127.0.0.1:9119");
2053
2054 let custom = BrokerConfig {
2055 enabled: true,
2056 port: 8080,
2057 bind: "0.0.0.0".to_string(),
2058 ..Default::default()
2059 };
2060 assert_eq!(custom.url(), "http://0.0.0.0:8080");
2061 }
2062
2063 #[test]
2064 fn empty_config_gets_broker_defaults() {
2065 let tmp = TempDir::new().unwrap();
2066 let path = tmp.path().join("config.toml");
2067 write_file(&path, "");
2068
2069 let config = load_config_file(&path).unwrap().unwrap();
2070 assert!(!config.broker.enabled);
2071 assert_eq!(config.broker.port, 9119);
2072 assert_eq!(config.broker.bind, "127.0.0.1");
2073 }
2074
2075 #[test]
2076 fn parses_full_broker_section() {
2077 let tmp = TempDir::new().unwrap();
2078 let path = tmp.path().join("config.toml");
2079 write_file(
2080 &path,
2081 "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
2082 );
2083
2084 let config = load_config_file(&path).unwrap().unwrap();
2085 assert!(config.broker.enabled);
2086 assert_eq!(config.broker.port, 8080);
2087 assert_eq!(config.broker.bind, "0.0.0.0");
2088 }
2089
2090 #[test]
2091 fn parses_partial_broker_section() {
2092 let tmp = TempDir::new().unwrap();
2093 let path = tmp.path().join("config.toml");
2094 write_file(&path, "[broker]\nenabled = true\n");
2095
2096 let config = load_config_file(&path).unwrap().unwrap();
2097 assert!(config.broker.enabled);
2098 assert_eq!(config.broker.port, 9119);
2099 assert_eq!(config.broker.bind, "127.0.0.1");
2100 }
2101
2102 #[test]
2105 fn supervisor_is_none_when_section_absent() {
2106 let tmp = TempDir::new().unwrap();
2107 let path = tmp.path().join("config.toml");
2108 write_file(&path, "default_cli = \"claude\"\n");
2109
2110 let config = load_config_file(&path).unwrap().unwrap();
2111 assert!(config.supervisor.is_none());
2112 }
2113
2114 #[test]
2115 fn parses_full_supervisor_section() {
2116 let tmp = TempDir::new().unwrap();
2117 let path = tmp.path().join("config.toml");
2118 write_file(
2119 &path,
2120 "[supervisor]\n\
2121 enabled = true\n\
2122 cli = \"claude\"\n\
2123 test_command = \"just check\"\n\
2124 agent_approval = \"full-auto\"\n",
2125 );
2126
2127 let config = load_config_file(&path).unwrap().unwrap();
2128 let supervisor = config.supervisor.unwrap();
2129 assert!(supervisor.enabled);
2130 assert_eq!(supervisor.cli.as_deref(), Some("claude"));
2131 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2132 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
2133 }
2134
2135 #[test]
2136 fn parses_partial_supervisor_section() {
2137 let tmp = TempDir::new().unwrap();
2138 let path = tmp.path().join("config.toml");
2139 write_file(&path, "[supervisor]\nenabled = true\n");
2140
2141 let config = load_config_file(&path).unwrap().unwrap();
2142 let supervisor = config.supervisor.unwrap();
2143 assert!(supervisor.enabled);
2144 assert_eq!(supervisor.cli, None);
2145 assert_eq!(supervisor.test_command, None);
2146 assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
2147 }
2148
2149 #[test]
2152 fn verify_on_commit_nudge_defaults_true_when_absent() {
2153 let tmp = TempDir::new().unwrap();
2154 let path = tmp.path().join("config.toml");
2155 write_file(&path, "[supervisor]\nenabled = true\n");
2156
2157 let config = load_config_file(&path).unwrap().unwrap();
2158 let supervisor = config.supervisor.unwrap();
2159 assert_eq!(
2160 supervisor.verify_on_commit_nudge, None,
2161 "an omitted field must deserialise as None"
2162 );
2163 assert!(
2164 supervisor.verify_on_commit_nudge_enabled(),
2165 "an unset verify_on_commit_nudge must resolve to true (default on)"
2166 );
2167 }
2168
2169 #[test]
2170 fn verify_on_commit_nudge_explicit_false_disables() {
2171 let tmp = TempDir::new().unwrap();
2172 let path = tmp.path().join("config.toml");
2173 write_file(
2174 &path,
2175 "[supervisor]\nenabled = true\nverify_on_commit_nudge = false\n",
2176 );
2177
2178 let config = load_config_file(&path).unwrap().unwrap();
2179 let supervisor = config.supervisor.unwrap();
2180 assert_eq!(supervisor.verify_on_commit_nudge, Some(false));
2181 assert!(
2182 !supervisor.verify_on_commit_nudge_enabled(),
2183 "an explicit `false` must disable the nudge"
2184 );
2185 }
2186
2187 #[test]
2188 fn verify_on_commit_nudge_explicit_true_enables() {
2189 let tmp = TempDir::new().unwrap();
2190 let path = tmp.path().join("config.toml");
2191 write_file(
2192 &path,
2193 "[supervisor]\nenabled = true\nverify_on_commit_nudge = true\n",
2194 );
2195
2196 let config = load_config_file(&path).unwrap().unwrap();
2197 let supervisor = config.supervisor.unwrap();
2198 assert_eq!(supervisor.verify_on_commit_nudge, Some(true));
2199 assert!(supervisor.verify_on_commit_nudge_enabled());
2200 }
2201
2202 #[test]
2203 fn rejects_invalid_approval_level() {
2204 let tmp = TempDir::new().unwrap();
2205 let path = tmp.path().join("config.toml");
2206 write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
2207
2208 let err = load_config_file(&path).unwrap_err();
2209 assert!(
2210 err.to_string().contains("yolo"),
2211 "error should mention invalid value, got: {err}"
2212 );
2213 }
2214
2215 #[test]
2216 fn supervisor_round_trips_through_save_and_load() {
2217 let tmp = TempDir::new().unwrap();
2218 let config_path = tmp.path().join("config.toml");
2219
2220 let original = PawConfig {
2221 supervisor: Some(SupervisorConfig {
2222 enabled: true,
2223 cli: Some("claude".into()),
2224 test_command: Some("just check".into()),
2225 lint_command: None,
2226 build_command: None,
2227 doc_build_command: None,
2228 doc_tool_command: None,
2229 spec_validate_command: None,
2230 fmt_check_command: None,
2231 security_audit_command: None,
2232 agent_approval: ApprovalLevel::FullAuto,
2233 auto_approve: None,
2234 conflict: ConflictConfig::default(),
2235 learnings: false,
2236 learnings_config: LearningsConfig::default(),
2237 common_dev_allowlist: CommonDevAllowlistConfig::default(),
2238 verify_on_commit_nudge: None,
2239 strict_branch_guard: None,
2240 auto_revert: None,
2241 manual_approvals_log: None,
2242 tell: TellConfig::default(),
2243 }),
2244 ..Default::default()
2245 };
2246
2247 save_config_to(&config_path, &original).unwrap();
2248 let loaded = load_config_file(&config_path).unwrap().unwrap();
2249 assert_eq!(loaded.supervisor, original.supervisor);
2250 }
2251
2252 #[test]
2255 fn manual_approvals_log_defaults_to_true_when_absent() {
2256 let tmp = TempDir::new().unwrap();
2258 let path = tmp.path().join("config.toml");
2259 write_file(&path, "[supervisor]\nenabled = true\n");
2260 let cfg = load_config_file(&path).unwrap().unwrap();
2261 let sup = cfg.supervisor.unwrap();
2262 assert_eq!(sup.manual_approvals_log, None);
2263 assert!(
2264 sup.manual_approvals_log_enabled(),
2265 "absent field must resolve to true"
2266 );
2267 }
2268
2269 #[test]
2270 fn manual_approvals_log_explicit_false_opts_out() {
2271 let tmp = TempDir::new().unwrap();
2272 let path = tmp.path().join("config.toml");
2273 write_file(
2274 &path,
2275 "[supervisor]\nenabled = true\nmanual_approvals_log = false\n",
2276 );
2277 let cfg = load_config_file(&path).unwrap().unwrap();
2278 let sup = cfg.supervisor.unwrap();
2279 assert_eq!(sup.manual_approvals_log, Some(false));
2280 assert!(!sup.manual_approvals_log_enabled());
2281 }
2282
2283 #[test]
2284 fn pre_v050_config_parses_with_manual_approvals_log_absent() {
2285 let tmp = TempDir::new().unwrap();
2288 let path = tmp.path().join("config.toml");
2289 write_file(
2290 &path,
2291 "[supervisor]\nenabled = true\ncli = \"claude\"\nlearnings = true\n",
2292 );
2293 let cfg = load_config_file(&path).unwrap().unwrap();
2294 let sup = cfg.supervisor.unwrap();
2295 assert_eq!(sup.manual_approvals_log, None);
2296 assert!(sup.manual_approvals_log_enabled());
2297 }
2298
2299 #[test]
2302 fn strict_branch_guard_defaults_to_true_and_honours_opt_out() {
2303 let on = TempDir::new().unwrap();
2305 let on_path = on.path().join("config.toml");
2306 write_file(&on_path, "[supervisor]\nenabled = true\n");
2307 let cfg = load_config_file(&on_path).unwrap().unwrap();
2308 let sup = cfg.supervisor.unwrap();
2309 assert_eq!(sup.strict_branch_guard, None);
2310 assert!(sup.strict_branch_guard(), "default must resolve to true");
2311
2312 let off = TempDir::new().unwrap();
2314 let off_path = off.path().join("config.toml");
2315 write_file(
2316 &off_path,
2317 "[supervisor]\nenabled = true\nstrict_branch_guard = false\n",
2318 );
2319 let cfg = load_config_file(&off_path).unwrap().unwrap();
2320 let sup = cfg.supervisor.unwrap();
2321 assert_eq!(sup.strict_branch_guard, Some(false));
2322 assert!(!sup.strict_branch_guard());
2323 }
2324
2325 #[test]
2326 fn gate_command_fields_default_to_none() {
2327 let tmp = TempDir::new().unwrap();
2328 let path = tmp.path().join("config.toml");
2329 write_file(&path, "[supervisor]\nenabled = true\n");
2330
2331 let config = load_config_file(&path).unwrap().unwrap();
2332 let supervisor = config.supervisor.unwrap();
2333 assert_eq!(supervisor.test_command, None);
2334 assert_eq!(supervisor.lint_command, None);
2335 assert_eq!(supervisor.build_command, None);
2336 assert_eq!(supervisor.doc_build_command, None);
2337 assert_eq!(supervisor.doc_tool_command, None);
2338 assert_eq!(supervisor.spec_validate_command, None);
2339 assert_eq!(supervisor.fmt_check_command, None);
2340 assert_eq!(supervisor.security_audit_command, None);
2341 }
2342
2343 #[test]
2344 fn gate_command_fields_round_trip() {
2345 let tmp = TempDir::new().unwrap();
2346 let config_path = tmp.path().join("config.toml");
2347
2348 let original = PawConfig {
2349 supervisor: Some(SupervisorConfig {
2350 enabled: true,
2351 cli: Some("claude".into()),
2352 test_command: Some("just check".into()),
2353 lint_command: Some("cargo clippy -- -D warnings".into()),
2354 build_command: Some("cargo build".into()),
2355 doc_build_command: Some("mdbook build docs/".into()),
2356 doc_tool_command: Some("cargo doc --no-deps".into()),
2357 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict".into()),
2358 fmt_check_command: Some("cargo fmt --check".into()),
2359 security_audit_command: Some("cargo audit".into()),
2360 ..Default::default()
2361 }),
2362 ..Default::default()
2363 };
2364
2365 save_config_to(&config_path, &original).unwrap();
2366 let loaded = load_config_file(&config_path).unwrap().unwrap();
2367 assert_eq!(loaded.supervisor, original.supervisor);
2368 }
2369
2370 #[test]
2371 fn gate_command_fields_omit_from_toml_when_none() {
2372 let supervisor = SupervisorConfig {
2373 enabled: true,
2374 test_command: None,
2375 lint_command: None,
2376 build_command: None,
2377 doc_build_command: None,
2378 doc_tool_command: None,
2379 spec_validate_command: None,
2380 fmt_check_command: None,
2381 security_audit_command: None,
2382 ..Default::default()
2383 };
2384 let serialized = toml::to_string_pretty(&supervisor).unwrap();
2385 for key in [
2386 "test_command",
2387 "lint_command",
2388 "build_command",
2389 "doc_build_command",
2390 "doc_tool_command",
2391 "spec_validate_command",
2392 "fmt_check_command",
2393 "security_audit_command",
2394 ] {
2395 assert!(
2396 !serialized.contains(key),
2397 "TOML serialised with None gate fields should omit `{key}`; got:\n{serialized}",
2398 );
2399 }
2400 }
2401
2402 #[test]
2405 fn doc_tool_command_default_none() {
2406 let tmp = TempDir::new().unwrap();
2407 let path = tmp.path().join("config.toml");
2408 write_file(&path, "[supervisor]\nenabled = true\n");
2409
2410 let config = load_config_file(&path).unwrap().unwrap();
2411 let supervisor = config.supervisor.unwrap();
2412 assert_eq!(supervisor.doc_tool_command, None);
2413 }
2414
2415 #[test]
2416 fn doc_tool_command_explicit_value_preserved() {
2417 let tmp = TempDir::new().unwrap();
2418 let path = tmp.path().join("config.toml");
2419 write_file(
2420 &path,
2421 "[supervisor]\n\
2422 enabled = true\n\
2423 doc_tool_command = \"sphinx-build -W docs docs/_build\"\n",
2424 );
2425
2426 let config = load_config_file(&path).unwrap().unwrap();
2427 let supervisor = config.supervisor.unwrap();
2428 assert_eq!(
2429 supervisor.doc_tool_command.as_deref(),
2430 Some("sphinx-build -W docs docs/_build"),
2431 "explicit doc_tool_command value (including all whitespace) must be preserved verbatim",
2432 );
2433 }
2434
2435 #[test]
2436 fn doc_tool_command_v0_5_config_parses_without_field() {
2437 let tmp = TempDir::new().unwrap();
2440 let path = tmp.path().join("config.toml");
2441 write_file(
2442 &path,
2443 "[supervisor]\n\
2444 enabled = true\n\
2445 test_command = \"just check\"\n\
2446 lint_command = \"cargo clippy -- -D warnings\"\n\
2447 build_command = \"cargo build\"\n\
2448 doc_build_command = \"mdbook build docs/\"\n",
2449 );
2450
2451 let config = load_config_file(&path).unwrap().unwrap();
2452 let supervisor = config.supervisor.unwrap();
2453 assert_eq!(supervisor.doc_tool_command, None);
2454 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2455 }
2456
2457 #[test]
2458 fn doc_tool_command_flows_into_gate_commands() {
2459 let supervisor = SupervisorConfig {
2460 doc_tool_command: Some("javadoc -d docs/api src/**/*.java".into()),
2461 ..Default::default()
2462 };
2463 let gates = supervisor.gate_commands();
2464 assert_eq!(
2465 gates.doc_tool_command,
2466 Some("javadoc -d docs/api src/**/*.java"),
2467 );
2468 }
2469
2470 #[test]
2473 fn supervisor_common_dev_allowlist_defaults_when_section_absent() {
2474 let tmp = TempDir::new().unwrap();
2475 let path = tmp.path().join("config.toml");
2476 write_file(&path, "[supervisor]\nenabled = true\n");
2477
2478 let config = load_config_file(&path).unwrap().unwrap();
2479 let supervisor = config.supervisor.unwrap();
2480 assert!(supervisor.common_dev_allowlist.enabled);
2481 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2482 }
2483
2484 #[test]
2485 fn supervisor_common_dev_allowlist_disabled_opt_out() {
2486 let tmp = TempDir::new().unwrap();
2487 let path = tmp.path().join("config.toml");
2488 write_file(
2489 &path,
2490 "[supervisor]\nenabled = true\n\
2491 [supervisor.common_dev_allowlist]\nenabled = false\n",
2492 );
2493
2494 let config = load_config_file(&path).unwrap().unwrap();
2495 let supervisor = config.supervisor.unwrap();
2496 assert!(!supervisor.common_dev_allowlist.enabled);
2497 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2499 }
2500
2501 #[test]
2502 fn supervisor_common_dev_allowlist_extra_parsed() {
2503 let tmp = TempDir::new().unwrap();
2504 let path = tmp.path().join("config.toml");
2505 write_file(
2506 &path,
2507 "[supervisor]\nenabled = true\n\
2508 [supervisor.common_dev_allowlist]\nextra = [\"pnpm test\", \"deno fmt\"]\n",
2509 );
2510
2511 let config = load_config_file(&path).unwrap().unwrap();
2512 let supervisor = config.supervisor.unwrap();
2513 assert_eq!(
2514 supervisor.common_dev_allowlist.extra,
2515 vec!["pnpm test".to_string(), "deno fmt".to_string()],
2516 );
2517 assert!(supervisor.common_dev_allowlist.enabled);
2519 }
2520
2521 #[test]
2522 fn supervisor_common_dev_allowlist_round_trips_through_save_and_load() {
2523 let tmp = TempDir::new().unwrap();
2524 let config_path = tmp.path().join("config.toml");
2525
2526 let original = PawConfig {
2527 supervisor: Some(SupervisorConfig {
2528 enabled: true,
2529 common_dev_allowlist: CommonDevAllowlistConfig {
2530 enabled: false,
2531 extra: vec!["pnpm test".into(), "uv pip install".into()],
2532 },
2533 ..Default::default()
2534 }),
2535 ..Default::default()
2536 };
2537
2538 save_config_to(&config_path, &original).unwrap();
2539 let loaded = load_config_file(&config_path).unwrap().unwrap();
2540 assert_eq!(loaded.supervisor, original.supervisor);
2541 }
2542
2543 #[test]
2544 fn existing_pre_v05_config_loads_with_default_common_dev_allowlist() {
2545 let tmp = TempDir::new().unwrap();
2548 let path = tmp.path().join("config.toml");
2549 write_file(
2550 &path,
2551 "[supervisor]\n\
2552 enabled = true\n\
2553 cli = \"claude\"\n\
2554 test_command = \"just check\"\n\
2555 agent_approval = \"auto\"\n\
2556 [supervisor.conflict]\n\
2557 window_seconds = 60\n",
2558 );
2559
2560 let config = load_config_file(&path).unwrap().unwrap();
2561 let supervisor = config.supervisor.unwrap();
2562 assert!(supervisor.common_dev_allowlist.enabled);
2563 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2564 }
2565
2566 #[test]
2567 fn generated_default_config_template_contains_common_dev_allowlist_section() {
2568 let template = generate_default_config();
2569 assert!(
2570 template.contains("[supervisor.common_dev_allowlist]"),
2571 "default template should document the new sub-table",
2572 );
2573 assert!(
2574 template.contains("enabled = true"),
2575 "template should show the enabled default",
2576 );
2577 assert!(
2578 template.contains("extra ="),
2579 "template should illustrate the extra field",
2580 );
2581 }
2582
2583 #[test]
2586 fn learnings_defaults_to_false_when_supervisor_section_absent_field() {
2587 let tmp = TempDir::new().unwrap();
2589 let path = tmp.path().join("config.toml");
2590 write_file(&path, "[supervisor]\nenabled = true\n");
2591
2592 let config = load_config_file(&path).unwrap().unwrap();
2593 let supervisor = config.supervisor.unwrap();
2594 assert!(!supervisor.learnings);
2595 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2596 }
2597
2598 #[test]
2599 fn learnings_true_loads() {
2600 let tmp = TempDir::new().unwrap();
2601 let path = tmp.path().join("config.toml");
2602 write_file(&path, "[supervisor]\nenabled = true\nlearnings = true\n");
2603
2604 let config = load_config_file(&path).unwrap().unwrap();
2605 let supervisor = config.supervisor.unwrap();
2606 assert!(supervisor.learnings);
2607 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2609 }
2610
2611 #[test]
2612 fn learnings_config_custom_flush_interval_is_honoured() {
2613 let tmp = TempDir::new().unwrap();
2614 let path = tmp.path().join("config.toml");
2615 write_file(
2616 &path,
2617 "[supervisor]\n\
2618 enabled = true\n\
2619 learnings = true\n\
2620 [supervisor.learnings_config]\n\
2621 flush_interval_seconds = 30\n",
2622 );
2623
2624 let config = load_config_file(&path).unwrap().unwrap();
2625 let supervisor = config.supervisor.unwrap();
2626 assert!(supervisor.learnings);
2627 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 30);
2628 }
2629
2630 #[test]
2631 fn learnings_config_defaults_when_table_absent() {
2632 let cfg = LearningsConfig::default();
2634 assert_eq!(cfg.flush_interval_seconds, 60);
2635 }
2636
2637 #[test]
2638 fn pre_v050_config_loads_with_learnings_false() {
2639 let tmp = TempDir::new().unwrap();
2643 let path = tmp.path().join("config.toml");
2644 write_file(
2645 &path,
2646 "default_cli = \"claude\"\n\
2647 [supervisor]\n\
2648 enabled = true\n\
2649 agent_approval = \"auto\"\n",
2650 );
2651
2652 let config = load_config_file(&path).unwrap().unwrap();
2653 let supervisor = config.supervisor.unwrap();
2654 assert!(!supervisor.learnings);
2655 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2656 }
2657
2658 #[test]
2659 fn learnings_round_trips_through_save_and_load() {
2660 let tmp = TempDir::new().unwrap();
2661 let config_path = tmp.path().join("config.toml");
2662
2663 let original = PawConfig {
2664 supervisor: Some(SupervisorConfig {
2665 enabled: true,
2666 learnings: true,
2667 learnings_config: LearningsConfig {
2668 flush_interval_seconds: 90,
2669 broker_publish: BrokerPublish::ForceOff,
2670 },
2671 ..Default::default()
2672 }),
2673 ..Default::default()
2674 };
2675
2676 save_config_to(&config_path, &original).unwrap();
2677 let loaded = load_config_file(&config_path).unwrap().unwrap();
2678 assert_eq!(loaded.supervisor, original.supervisor);
2679 let supervisor = loaded.supervisor.unwrap();
2680 assert!(supervisor.learnings);
2681 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 90);
2682 }
2683
2684 #[test]
2685 fn existing_v030_config_loads_without_supervisor() {
2686 let tmp = TempDir::new().unwrap();
2687 let path = tmp.path().join("config.toml");
2688 write_file(
2689 &path,
2690 "default_cli = \"claude\"\n\
2691 mouse = true\n\
2692 [broker]\n\
2693 enabled = true\n\
2694 [logging]\n\
2695 enabled = false\n",
2696 );
2697
2698 let config = load_config_file(&path).unwrap().unwrap();
2699 assert_eq!(config.default_cli.as_deref(), Some("claude"));
2700 assert!(config.broker.enabled);
2701 assert!(config.supervisor.is_none());
2702 }
2703
2704 #[test]
2705 fn generated_default_config_contains_commented_supervisor_section() {
2706 let output = generate_default_config();
2707 assert!(output.contains("[supervisor]"));
2708 assert!(output.contains("enabled"));
2709 assert!(output.contains("test_command"));
2710 assert!(output.contains("agent_approval"));
2711 }
2712
2713 #[test]
2716 fn dashboard_config_defaults_to_disabled() {
2717 let config = DashboardConfig::default();
2718 assert!(!config.show_message_log);
2719 }
2720
2721 #[test]
2722 fn parses_dashboard_section_with_show_message_log() {
2723 let tmp = TempDir::new().unwrap();
2724 let path = tmp.path().join("config.toml");
2725 write_file(&path, "[dashboard]\nshow_message_log = true\n");
2726
2727 let config = load_config_file(&path).unwrap().unwrap();
2728 let dashboard = config.dashboard.unwrap();
2729 assert!(dashboard.show_message_log);
2730 }
2731
2732 #[test]
2733 fn dashboard_is_none_when_section_absent() {
2734 let tmp = TempDir::new().unwrap();
2735 let path = tmp.path().join("config.toml");
2736 write_file(&path, "default_cli = \"claude\"\n");
2737
2738 let config = load_config_file(&path).unwrap().unwrap();
2739 assert!(config.dashboard.is_none());
2740 }
2741
2742 #[test]
2743 fn dashboard_merge_repo_wins() {
2744 let tmp = TempDir::new().unwrap();
2745 let global_path = tmp.path().join("global").join("config.toml");
2746 let repo_root = tmp.path().join("repo");
2747 fs::create_dir_all(&repo_root).unwrap();
2748
2749 write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
2750 write_file(
2751 &repo_config_path(&repo_root),
2752 "[dashboard]\nshow_message_log = true\n",
2753 );
2754
2755 let config = load_config_from(&global_path, &repo_root).unwrap();
2756 let dashboard = config.dashboard.unwrap();
2757 assert!(dashboard.show_message_log);
2758 }
2759
2760 #[test]
2761 fn dashboard_round_trip_through_save_and_load() {
2762 let tmp = TempDir::new().unwrap();
2763 let config_path = tmp.path().join("config.toml");
2764
2765 let original = PawConfig {
2766 dashboard: Some(DashboardConfig {
2767 show_message_log: true,
2768 ..Default::default()
2769 }),
2770 ..Default::default()
2771 };
2772
2773 save_config_to(&config_path, &original).unwrap();
2774 let loaded = load_config_file(&config_path).unwrap().unwrap();
2775 assert_eq!(loaded.dashboard, original.dashboard);
2776 assert!(loaded.dashboard.unwrap().show_message_log);
2777 }
2778
2779 #[test]
2782 fn broker_log_config_defaults() {
2783 let cfg = BrokerLogConfig::default();
2785 assert_eq!(cfg.max_messages, 500);
2786 assert!(cfg.default_visible);
2787 }
2788
2789 #[test]
2790 fn dashboard_config_default_includes_broker_log_defaults() {
2791 let cfg = DashboardConfig::default();
2795 assert_eq!(cfg.broker_log.max_messages, 500);
2796 assert!(cfg.broker_log.default_visible);
2797 }
2798
2799 #[test]
2800 fn parses_broker_log_section_with_explicit_overrides() {
2801 let tmp = TempDir::new().unwrap();
2803 let path = tmp.path().join("config.toml");
2804 write_file(
2805 &path,
2806 "[dashboard.broker_log]\nmax_messages = 100\ndefault_visible = false\n",
2807 );
2808
2809 let config = load_config_file(&path).unwrap().unwrap();
2810 let dashboard = config.dashboard.unwrap();
2811 assert_eq!(dashboard.broker_log.max_messages, 100);
2812 assert!(!dashboard.broker_log.default_visible);
2813 }
2814
2815 #[test]
2816 fn broker_log_partial_section_fills_remaining_defaults() {
2817 let tmp = TempDir::new().unwrap();
2821 let path = tmp.path().join("config.toml");
2822 write_file(&path, "[dashboard.broker_log]\nmax_messages = 42\n");
2823
2824 let config = load_config_file(&path).unwrap().unwrap();
2825 let broker_log = config.dashboard.unwrap().broker_log;
2826 assert_eq!(broker_log.max_messages, 42);
2827 assert!(
2828 broker_log.default_visible,
2829 "default_visible must fall back to true when omitted"
2830 );
2831 }
2832
2833 #[test]
2834 fn v050_dashboard_section_without_broker_log_still_parses() {
2835 let tmp = TempDir::new().unwrap();
2838 let path = tmp.path().join("config.toml");
2839 write_file(&path, "[dashboard]\nshow_message_log = true\n");
2840
2841 let config = load_config_file(&path).unwrap().unwrap();
2842 let dashboard = config.dashboard.unwrap();
2843 assert!(dashboard.show_message_log);
2844 assert_eq!(dashboard.broker_log, BrokerLogConfig::default());
2845 }
2846
2847 #[test]
2848 fn broker_log_round_trips_through_save_and_load() {
2849 let tmp = TempDir::new().unwrap();
2850 let config_path = tmp.path().join("config.toml");
2851
2852 let original = PawConfig {
2853 dashboard: Some(DashboardConfig {
2854 show_message_log: false,
2855 broker_log: BrokerLogConfig {
2856 max_messages: 250,
2857 default_visible: false,
2858 },
2859 }),
2860 ..Default::default()
2861 };
2862
2863 save_config_to(&config_path, &original).unwrap();
2864 let loaded = load_config_file(&config_path).unwrap().unwrap();
2865 assert_eq!(loaded.dashboard, original.dashboard);
2866 }
2867
2868 #[test]
2869 fn get_dashboard_returns_none_when_not_configured() {
2870 let config = PawConfig::default();
2871 assert!(config.get_dashboard().is_none());
2872 }
2873
2874 #[test]
2875 fn get_dashboard_returns_config_when_present() {
2876 let config = PawConfig {
2877 dashboard: Some(DashboardConfig {
2878 show_message_log: true,
2879 ..Default::default()
2880 }),
2881 ..Default::default()
2882 };
2883 let dashboard = config.get_dashboard().unwrap();
2884 assert!(dashboard.show_message_log);
2885 }
2886
2887 #[test]
2890 fn approval_flags_claude_full_auto() {
2891 assert_eq!(
2892 approval_flags("claude", &ApprovalLevel::FullAuto),
2893 "--dangerously-skip-permissions"
2894 );
2895 }
2896
2897 #[test]
2898 fn approval_flags_codex_auto() {
2899 assert_eq!(
2900 approval_flags("codex", &ApprovalLevel::Auto),
2901 "--approval-mode=auto-edit"
2902 );
2903 }
2904
2905 #[test]
2906 fn approval_flags_codex_full_auto() {
2907 assert_eq!(
2908 approval_flags("codex", &ApprovalLevel::FullAuto),
2909 "--approval-mode=full-auto"
2910 );
2911 }
2912
2913 #[test]
2914 fn approval_flags_unknown_cli_is_empty() {
2915 assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
2916 }
2917
2918 #[test]
2919 fn approval_flags_manual_is_empty() {
2920 assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
2921 assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
2922 }
2923
2924 #[test]
2925 fn approval_flags_is_deterministic() {
2926 let first = approval_flags("claude", &ApprovalLevel::FullAuto);
2927 let second = approval_flags("claude", &ApprovalLevel::FullAuto);
2928 assert_eq!(first, second);
2929 }
2930
2931 #[test]
2932 fn supervisor_merge_repo_wins() {
2933 let tmp = TempDir::new().unwrap();
2934 let global_path = tmp.path().join("global").join("config.toml");
2935 let repo_root = tmp.path().join("repo");
2936 fs::create_dir_all(&repo_root).unwrap();
2937
2938 write_file(
2939 &global_path,
2940 "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
2941 );
2942 write_file(
2943 &repo_config_path(&repo_root),
2944 "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
2945 );
2946
2947 let config = load_config_from(&global_path, &repo_root).unwrap();
2948 let supervisor = config.supervisor.unwrap();
2949 assert!(supervisor.enabled);
2950 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
2951 }
2952
2953 #[test]
2954 fn broker_config_round_trip() {
2955 let tmp = TempDir::new().unwrap();
2956 let config_path = tmp.path().join("config.toml");
2957
2958 let original = PawConfig {
2959 broker: BrokerConfig {
2960 enabled: true,
2961 port: 9200,
2962 bind: "127.0.0.1".to_string(),
2963 ..Default::default()
2964 },
2965 ..Default::default()
2966 };
2967
2968 save_config_to(&config_path, &original).unwrap();
2969 let loaded = load_config_file(&config_path).unwrap().unwrap();
2970 assert_eq!(loaded.broker.enabled, original.broker.enabled);
2971 assert_eq!(loaded.broker.port, original.broker.port);
2972 assert_eq!(loaded.broker.bind, original.broker.bind);
2973 }
2974
2975 #[test]
2978 fn auto_approve_defaults_match_spec() {
2979 let cfg = AutoApproveConfig::default();
2980 assert!(cfg.enabled, "enabled defaults to true");
2981 assert!(
2982 cfg.safe_commands.is_empty(),
2983 "safe_commands defaults to empty"
2984 );
2985 assert_eq!(cfg.stall_threshold_seconds, 30);
2986 assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
2987 }
2988
2989 #[test]
2990 fn auto_approve_section_absent_keeps_supervisor_simple() {
2991 let tmp = TempDir::new().unwrap();
2992 let path = tmp.path().join("config.toml");
2993 write_file(&path, "[supervisor]\nenabled = true\n");
2994 let config = load_config_file(&path).unwrap().unwrap();
2995 let supervisor = config.supervisor.unwrap();
2996 assert!(supervisor.auto_approve.is_none());
2997 }
2998
2999 #[test]
3000 fn auto_approve_section_parses_full_body() {
3001 let tmp = TempDir::new().unwrap();
3002 let path = tmp.path().join("config.toml");
3003 write_file(
3004 &path,
3005 "[supervisor]\n\
3006 enabled = true\n\
3007 [supervisor.auto_approve]\n\
3008 enabled = false\n\
3009 safe_commands = [\"just smoke\"]\n\
3010 stall_threshold_seconds = 60\n\
3011 approval_level = \"conservative\"\n",
3012 );
3013 let config = load_config_file(&path).unwrap().unwrap();
3014 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3015 assert!(!aa.enabled);
3016 assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
3017 assert_eq!(aa.stall_threshold_seconds, 60);
3018 assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
3019 }
3020
3021 #[test]
3022 fn auto_approve_enabled_defaults_to_true_when_omitted() {
3023 let tmp = TempDir::new().unwrap();
3024 let path = tmp.path().join("config.toml");
3025 write_file(
3026 &path,
3027 "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
3028 );
3029 let config = load_config_file(&path).unwrap().unwrap();
3030 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3031 assert!(aa.enabled, "enabled should default to true");
3032 }
3033
3034 #[test]
3035 fn auto_approve_off_preset_forces_disabled() {
3036 let cfg = AutoApproveConfig {
3037 enabled: true,
3038 approval_level: ApprovalLevelPreset::Off,
3039 ..AutoApproveConfig::default()
3040 };
3041 let resolved = cfg.resolved();
3042 assert!(!resolved.enabled, "Off preset must force enabled = false");
3043 }
3044
3045 #[test]
3048 fn watcher_ttl_defaults_to_sixty_when_absent() {
3049 let cfg = WatcherConfig::default();
3050 assert_eq!(cfg.republish_working_ttl_seconds(), 60);
3051 }
3052
3053 #[test]
3054 fn watcher_ttl_zero_disables() {
3055 let cfg = WatcherConfig {
3056 republish_working_ttl_seconds: Some(0),
3057 };
3058 assert_eq!(cfg.republish_working_ttl_seconds(), 0);
3059 }
3060
3061 #[test]
3062 fn watcher_ttl_below_floor_clamps_to_five() {
3063 let cfg = WatcherConfig {
3064 republish_working_ttl_seconds: Some(2),
3065 };
3066 assert_eq!(
3067 cfg.republish_working_ttl_seconds(),
3068 WatcherConfig::MIN_REPUBLISH_TTL_SECONDS
3069 );
3070 }
3071
3072 #[test]
3073 fn watcher_ttl_explicit_non_zero_is_preserved() {
3074 let cfg = WatcherConfig {
3075 republish_working_ttl_seconds: Some(120),
3076 };
3077 assert_eq!(cfg.republish_working_ttl_seconds(), 120);
3078 }
3079
3080 #[test]
3081 fn watcher_ttl_parses_from_broker_table() {
3082 let tmp = TempDir::new().unwrap();
3083 let path = tmp.path().join("config.toml");
3084 write_file(
3085 &path,
3086 "[broker]\nenabled = true\n[broker.watcher]\nrepublish_working_ttl_seconds = 0\n",
3087 );
3088 let config = load_config_file(&path).unwrap().unwrap();
3089 assert_eq!(config.broker.watcher.republish_working_ttl_seconds, Some(0));
3090 assert_eq!(config.broker.watcher.republish_working_ttl_seconds(), 0);
3091 }
3092
3093 #[test]
3094 fn approve_worktree_writes_defaults_to_true_when_absent() {
3095 let cfg = AutoApproveConfig::default();
3097 assert!(
3098 cfg.approve_worktree_writes(),
3099 "absent approve_worktree_writes must resolve to true"
3100 );
3101 }
3102
3103 #[test]
3104 fn approve_worktree_writes_explicit_false_resolves_false() {
3105 let cfg = AutoApproveConfig {
3107 approve_worktree_writes: Some(false),
3108 ..AutoApproveConfig::default()
3109 };
3110 assert!(!cfg.approve_worktree_writes());
3111 }
3112
3113 #[test]
3114 fn approve_worktree_writes_parses_from_toml() {
3115 let tmp = TempDir::new().unwrap();
3116 let path = tmp.path().join("config.toml");
3117 write_file(
3118 &path,
3119 "[supervisor]\nenabled = true\n[supervisor.auto_approve]\napprove_worktree_writes = false\n",
3120 );
3121 let config = load_config_file(&path).unwrap().unwrap();
3122 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3123 assert_eq!(aa.approve_worktree_writes, Some(false));
3124 assert!(!aa.approve_worktree_writes());
3125 }
3126
3127 #[test]
3128 fn auto_approve_threshold_floor_clamps() {
3129 let cfg = AutoApproveConfig {
3130 stall_threshold_seconds: 0,
3131 ..AutoApproveConfig::default()
3132 };
3133 let resolved = cfg.resolved();
3134 assert_eq!(
3135 resolved.stall_threshold_seconds,
3136 AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
3137 );
3138 }
3139
3140 #[test]
3141 fn auto_approve_safe_preset_keeps_defaults() {
3142 let cfg = AutoApproveConfig {
3143 approval_level: ApprovalLevelPreset::Safe,
3144 ..AutoApproveConfig::default()
3145 };
3146 let wl = cfg.effective_whitelist();
3147 assert!(wl.iter().any(|c| c == "cargo test"));
3148 assert!(wl.iter().any(|c| c == "git push"));
3149 assert!(wl.iter().any(|c| c.starts_with("curl")));
3150 }
3151
3152 #[test]
3153 fn auto_approve_conservative_drops_push_and_curl() {
3154 let cfg = AutoApproveConfig {
3155 approval_level: ApprovalLevelPreset::Conservative,
3156 ..AutoApproveConfig::default()
3157 };
3158 let wl = cfg.effective_whitelist();
3159 assert!(wl.iter().any(|c| c == "cargo test"));
3160 assert!(
3161 !wl.iter().any(|c| c.starts_with("git push")),
3162 "conservative drops git push"
3163 );
3164 assert!(
3165 !wl.iter().any(|c| c.starts_with("curl")),
3166 "conservative drops curl"
3167 );
3168 }
3169
3170 #[test]
3171 fn auto_approve_extras_are_unioned_with_defaults() {
3172 let cfg = AutoApproveConfig {
3173 safe_commands: vec!["just lint".to_string(), "just test".to_string()],
3174 ..AutoApproveConfig::default()
3175 };
3176 let wl = cfg.effective_whitelist();
3177 assert!(wl.iter().any(|c| c == "cargo fmt"));
3178 assert!(wl.iter().any(|c| c == "just lint"));
3179 assert!(wl.iter().any(|c| c == "just test"));
3180 }
3181
3182 #[test]
3183 fn auto_approve_empty_extras_keep_defaults() {
3184 let cfg = AutoApproveConfig::default();
3185 let wl = cfg.effective_whitelist();
3186 assert!(wl.iter().any(|c| c == "cargo test"));
3187 }
3188
3189 #[test]
3196 fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
3197 use crate::supervisor::auto_approve::is_safe_command;
3198
3199 let tmp = TempDir::new().unwrap();
3202 let extras_path = tmp.path().join("extras.toml");
3203 write_file(
3204 &extras_path,
3205 "[supervisor]\n\
3206 enabled = true\n\
3207 [supervisor.auto_approve]\n\
3208 safe_commands = [\"just smoke\"]\n",
3209 );
3210 let extras_config = load_config_file(&extras_path).unwrap().unwrap();
3211 let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
3212 let extras_whitelist = extras_aa.effective_whitelist();
3213 assert!(
3214 is_safe_command("just smoke -v", &extras_whitelist),
3215 "TOML extra `just smoke` must accept `just smoke -v`"
3216 );
3217 assert!(
3219 is_safe_command("cargo test", &extras_whitelist),
3220 "extras must not displace built-in defaults"
3221 );
3222
3223 let empty_path = tmp.path().join("empty.toml");
3226 write_file(
3227 &empty_path,
3228 "[supervisor]\n\
3229 enabled = true\n\
3230 [supervisor.auto_approve]\n\
3231 safe_commands = []\n",
3232 );
3233 let empty_config = load_config_file(&empty_path).unwrap().unwrap();
3234 let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
3235 let empty_whitelist = empty_aa.effective_whitelist();
3236 assert!(
3237 is_safe_command("cargo test", &empty_whitelist),
3238 "empty safe_commands must keep built-in defaults"
3239 );
3240 assert!(
3241 is_safe_command("cargo fmt --check", &empty_whitelist),
3242 "empty safe_commands must keep `cargo fmt` default"
3243 );
3244 assert!(
3246 !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
3247 "empty safe_commands must not whitelist arbitrary commands"
3248 );
3249 }
3250
3251 #[test]
3254 fn conflict_config_defaults_match_spec() {
3255 let cfg = ConflictConfig::default();
3256 assert_eq!(cfg.window_seconds, 120);
3257 assert!(cfg.warn_on_intent_overlap);
3258 assert!(cfg.escalate_on_violation);
3259 }
3260
3261 #[test]
3262 fn supervisor_with_no_conflict_section_loads_defaults() {
3263 let tmp = TempDir::new().unwrap();
3264 let path = tmp.path().join("config.toml");
3265 write_file(&path, "[supervisor]\nenabled = true\n");
3266 let supervisor = load_config_file(&path)
3267 .unwrap()
3268 .unwrap()
3269 .supervisor
3270 .unwrap();
3271 assert_eq!(supervisor.conflict.window_seconds, 120);
3272 assert!(supervisor.conflict.warn_on_intent_overlap);
3273 assert!(supervisor.conflict.escalate_on_violation);
3274 }
3275
3276 #[test]
3277 fn conflict_section_with_all_fields_overrides_defaults() {
3278 let tmp = TempDir::new().unwrap();
3279 let path = tmp.path().join("config.toml");
3280 write_file(
3281 &path,
3282 "[supervisor]\n\
3283 enabled = true\n\
3284 [supervisor.conflict]\n\
3285 window_seconds = 300\n\
3286 warn_on_intent_overlap = false\n\
3287 escalate_on_violation = false\n",
3288 );
3289 let conflict = load_config_file(&path)
3290 .unwrap()
3291 .unwrap()
3292 .supervisor
3293 .unwrap()
3294 .conflict;
3295 assert_eq!(conflict.window_seconds, 300);
3296 assert!(!conflict.warn_on_intent_overlap);
3297 assert!(!conflict.escalate_on_violation);
3298 }
3299
3300 #[test]
3301 fn conflict_section_with_partial_fields_keeps_other_defaults() {
3302 let tmp = TempDir::new().unwrap();
3303 let path = tmp.path().join("config.toml");
3304 write_file(
3305 &path,
3306 "[supervisor]\n[supervisor.conflict]\nwindow_seconds = 60\n",
3307 );
3308 let conflict = load_config_file(&path)
3309 .unwrap()
3310 .unwrap()
3311 .supervisor
3312 .unwrap()
3313 .conflict;
3314 assert_eq!(conflict.window_seconds, 60);
3315 assert!(conflict.warn_on_intent_overlap);
3316 assert!(conflict.escalate_on_violation);
3317 }
3318
3319 #[test]
3320 fn pre_v05_config_without_conflict_section_loads() {
3321 let tmp = TempDir::new().unwrap();
3322 let path = tmp.path().join("config.toml");
3323 write_file(
3325 &path,
3326 "default_cli = \"claude\"\n\
3327 [supervisor]\n\
3328 enabled = true\n\
3329 agent_approval = \"auto\"\n",
3330 );
3331 let config = load_config_file(&path).unwrap().unwrap();
3332 let supervisor = config.supervisor.unwrap();
3333 assert!(supervisor.enabled);
3334 assert_eq!(supervisor.conflict, ConflictConfig::default());
3336 }
3337
3338 #[test]
3339 fn conflict_config_round_trips_through_save_and_load() {
3340 let tmp = TempDir::new().unwrap();
3341 let config_path = tmp.path().join("config.toml");
3342 let original = PawConfig {
3343 supervisor: Some(SupervisorConfig {
3344 enabled: true,
3345 conflict: ConflictConfig {
3346 window_seconds: 90,
3347 warn_on_intent_overlap: false,
3348 escalate_on_violation: true,
3349 },
3350 ..Default::default()
3351 }),
3352 ..Default::default()
3353 };
3354 save_config_to(&config_path, &original).unwrap();
3355 let loaded = load_config_file(&config_path).unwrap().unwrap();
3356 assert_eq!(loaded.supervisor, original.supervisor);
3357 }
3358
3359 #[test]
3360 fn v030_config_loads_without_auto_approve() {
3361 let tmp = TempDir::new().unwrap();
3364 let path = tmp.path().join("config.toml");
3365 write_file(
3366 &path,
3367 "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
3368 );
3369 let config = load_config_file(&path).unwrap().unwrap();
3370 assert!(config.supervisor.is_none());
3371 assert!(config.broker.enabled);
3372 }
3373
3374 fn write_repo_config(repo_root: &Path, toml: &str) {
3380 write_file(&repo_config_path(repo_root), toml);
3381 }
3382
3383 fn missing_global(tmp: &TempDir) -> PathBuf {
3384 tmp.path().join("nonexistent-global").join("config.toml")
3385 }
3386
3387 #[test]
3389 fn governance_defaults_to_all_none_when_section_absent() {
3390 let tmp = TempDir::new().unwrap();
3391 let path = tmp.path().join("config.toml");
3392 write_file(&path, "default_cli = \"claude\"\n");
3393
3394 let config = load_config_file(&path).unwrap().unwrap();
3395 assert!(config.governance.adr.is_none());
3396 assert!(config.governance.test_strategy.is_none());
3397 assert!(config.governance.security.is_none());
3398 assert!(config.governance.dod.is_none());
3399 assert!(config.governance.constitution.is_none());
3400 }
3401
3402 #[test]
3404 fn governance_all_paths_populated() {
3405 let tmp = TempDir::new().unwrap();
3406 let path = tmp.path().join("config.toml");
3407 write_file(
3408 &path,
3409 "[governance]\n\
3410 adr = \"docs/adr\"\n\
3411 test_strategy = \"docs/test-strategy.md\"\n\
3412 security = \"docs/security-checklist.md\"\n\
3413 dod = \"docs/definition-of-done.md\"\n\
3414 constitution = \".specify/memory/constitution.md\"\n",
3415 );
3416
3417 let config = load_config_file(&path).unwrap().unwrap();
3418 assert_eq!(
3419 config.governance.adr.as_deref(),
3420 Some(Path::new("docs/adr"))
3421 );
3422 assert_eq!(
3423 config.governance.test_strategy.as_deref(),
3424 Some(Path::new("docs/test-strategy.md"))
3425 );
3426 assert_eq!(
3427 config.governance.security.as_deref(),
3428 Some(Path::new("docs/security-checklist.md"))
3429 );
3430 assert_eq!(
3431 config.governance.dod.as_deref(),
3432 Some(Path::new("docs/definition-of-done.md"))
3433 );
3434 assert_eq!(
3435 config.governance.constitution.as_deref(),
3436 Some(Path::new(".specify/memory/constitution.md"))
3437 );
3438 }
3439
3440 #[test]
3442 fn governance_partial_paths_only_some_fields_populated() {
3443 let tmp = TempDir::new().unwrap();
3444 let path = tmp.path().join("config.toml");
3445 write_file(
3446 &path,
3447 "[governance]\n\
3448 dod = \"docs/dod.md\"\n\
3449 security = \"docs/security.md\"\n",
3450 );
3451
3452 let config = load_config_file(&path).unwrap().unwrap();
3453 assert_eq!(
3454 config.governance.dod.as_deref(),
3455 Some(Path::new("docs/dod.md"))
3456 );
3457 assert_eq!(
3458 config.governance.security.as_deref(),
3459 Some(Path::new("docs/security.md"))
3460 );
3461 assert!(config.governance.adr.is_none());
3462 assert!(config.governance.test_strategy.is_none());
3463 assert!(config.governance.constitution.is_none());
3464 }
3465
3466 #[test]
3468 fn governance_absolute_path_preserved_as_is() {
3469 let tmp = TempDir::new().unwrap();
3470 let path = tmp.path().join("config.toml");
3471 write_file(&path, "[governance]\nadr = \"/absolute/path/to/adr\"\n");
3472
3473 let config = load_config_file(&path).unwrap().unwrap();
3474 assert_eq!(
3475 config.governance.adr,
3476 Some(PathBuf::from("/absolute/path/to/adr"))
3477 );
3478 }
3479
3480 #[test]
3482 fn governance_nonexistent_path_loads_cleanly() {
3483 let tmp = TempDir::new().unwrap();
3484 let path = tmp.path().join("config.toml");
3485 write_file(&path, "[governance]\ndod = \"docs/never-existed.md\"\n");
3486
3487 let config = load_config_file(&path).unwrap().unwrap();
3488 assert_eq!(
3489 config.governance.dod,
3490 Some(PathBuf::from("docs/never-existed.md"))
3491 );
3492 }
3493
3494 #[test]
3496 fn governance_round_trips_through_save_and_load() {
3497 let tmp = TempDir::new().unwrap();
3498 let config_path = tmp.path().join("config.toml");
3499
3500 let original = PawConfig {
3501 governance: GovernanceConfig {
3502 adr: Some(PathBuf::from("docs/adr")),
3503 test_strategy: Some(PathBuf::from("docs/test-strategy.md")),
3504 security: Some(PathBuf::from("docs/security.md")),
3505 dod: Some(PathBuf::from("docs/dod.md")),
3506 constitution: Some(PathBuf::from(".specify/memory/constitution.md")),
3507 },
3508 ..Default::default()
3509 };
3510
3511 save_config_to(&config_path, &original).unwrap();
3512 let loaded = load_config_file(&config_path).unwrap().unwrap();
3513 assert_eq!(loaded.governance, original.governance);
3514 }
3515
3516 #[test]
3518 fn governance_v04_config_without_section_loads_with_defaults() {
3519 let tmp = TempDir::new().unwrap();
3520 let path = tmp.path().join("config.toml");
3521 write_file(
3522 &path,
3523 "default_cli = \"claude\"\n\
3524 mouse = true\n\
3525 [broker]\n\
3526 enabled = true\n\
3527 [supervisor]\n\
3528 enabled = true\n\
3529 [specs]\n\
3530 dir = \"specs\"\n\
3531 type = \"openspec\"\n\
3532 [clis.foo]\n\
3533 command = \"/bin/foo\"\n",
3534 );
3535
3536 let config = load_config_file(&path).unwrap().unwrap();
3537 assert_eq!(config.governance, GovernanceConfig::default());
3538 assert!(config.governance.adr.is_none());
3539 assert!(config.governance.test_strategy.is_none());
3540 assert!(config.governance.security.is_none());
3541 assert!(config.governance.dod.is_none());
3542 assert!(config.governance.constitution.is_none());
3543 }
3544
3545 #[test]
3548 fn governance_default_has_only_five_path_fields() {
3549 let GovernanceConfig {
3553 adr,
3554 test_strategy,
3555 security,
3556 dod,
3557 constitution,
3558 } = GovernanceConfig::default();
3559 assert!(adr.is_none());
3560 assert!(test_strategy.is_none());
3561 assert!(security.is_none());
3562 assert!(dod.is_none());
3563 assert!(constitution.is_none());
3564 }
3565
3566 #[test]
3568 fn governance_auto_wires_constitution_when_speckit_detected() {
3569 let tmp = TempDir::new().unwrap();
3570 let repo_root = tmp.path().join("repo");
3571 let specify = repo_root.join(".specify");
3572 let specs = specify.join("specs");
3573 let memory = specify.join("memory");
3574 fs::create_dir_all(&specs).unwrap();
3575 fs::create_dir_all(&memory).unwrap();
3576 let constitution = memory.join("constitution.md");
3577 fs::write(&constitution, "# Constitution\n").unwrap();
3578
3579 write_repo_config(
3580 &repo_root,
3581 "[specs]\n\
3582 type = \"speckit\"\n\
3583 dir = \".specify/specs\"\n",
3584 );
3585
3586 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3587 assert_eq!(
3588 config.governance.constitution.as_deref(),
3589 Some(constitution.as_path())
3590 );
3591 }
3592
3593 #[test]
3595 fn governance_explicit_constitution_preserved_over_auto_wiring() {
3596 let tmp = TempDir::new().unwrap();
3597 let repo_root = tmp.path().join("repo");
3598 let specify = repo_root.join(".specify");
3599 let specs = specify.join("specs");
3600 let memory = specify.join("memory");
3601 fs::create_dir_all(&specs).unwrap();
3602 fs::create_dir_all(&memory).unwrap();
3603 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3604
3605 write_repo_config(
3606 &repo_root,
3607 "[specs]\n\
3608 type = \"speckit\"\n\
3609 dir = \".specify/specs\"\n\
3610 [governance]\n\
3611 constitution = \"docs/principles.md\"\n",
3612 );
3613
3614 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3615 assert_eq!(
3616 config.governance.constitution,
3617 Some(PathBuf::from("docs/principles.md"))
3618 );
3619 }
3620
3621 #[test]
3623 fn governance_auto_wiring_skipped_when_specs_type_is_openspec() {
3624 let tmp = TempDir::new().unwrap();
3625 let repo_root = tmp.path().join("repo");
3626 let specify = repo_root.join(".specify");
3627 let memory = specify.join("memory");
3628 fs::create_dir_all(&memory).unwrap();
3629 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3630 fs::create_dir_all(repo_root.join("specs")).unwrap();
3631
3632 write_repo_config(
3633 &repo_root,
3634 "[specs]\n\
3635 type = \"openspec\"\n\
3636 dir = \"specs\"\n",
3637 );
3638
3639 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3640 assert!(config.governance.constitution.is_none());
3641 }
3642
3643 #[test]
3645 fn governance_auto_wiring_skipped_when_specs_section_absent() {
3646 let tmp = TempDir::new().unwrap();
3647 let repo_root = tmp.path().join("repo");
3648 let memory = repo_root.join(".specify").join("memory");
3649 fs::create_dir_all(&memory).unwrap();
3650 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3651 fs::create_dir_all(repo_root.join(".git-paw")).unwrap();
3652
3653 write_repo_config(&repo_root, "default_cli = \"claude\"\n");
3654
3655 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3656 assert!(config.governance.constitution.is_none());
3657 }
3658
3659 #[test]
3661 fn governance_auto_wiring_skipped_when_constitution_md_absent() {
3662 let tmp = TempDir::new().unwrap();
3663 let repo_root = tmp.path().join("repo");
3664 let specs = repo_root.join(".specify").join("specs");
3665 fs::create_dir_all(&specs).unwrap();
3666 write_repo_config(
3669 &repo_root,
3670 "[specs]\n\
3671 type = \"speckit\"\n\
3672 dir = \".specify/specs\"\n",
3673 );
3674
3675 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3676 assert!(config.governance.constitution.is_none());
3677 }
3678
3679 #[test]
3681 fn governance_explicit_empty_string_constitution_suppresses_auto_wiring() {
3682 let tmp = TempDir::new().unwrap();
3683 let repo_root = tmp.path().join("repo");
3684 let specify = repo_root.join(".specify");
3685 let specs = specify.join("specs");
3686 let memory = specify.join("memory");
3687 fs::create_dir_all(&specs).unwrap();
3688 fs::create_dir_all(&memory).unwrap();
3689 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3690
3691 write_repo_config(
3692 &repo_root,
3693 "[specs]\n\
3694 type = \"speckit\"\n\
3695 dir = \".specify/specs\"\n\
3696 [governance]\n\
3697 constitution = \"\"\n",
3698 );
3699
3700 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3701 assert_eq!(config.governance.constitution, Some(PathBuf::from("")));
3702 }
3703
3704 #[test]
3706 fn governance_merge_fields_independently_across_global_and_repo() {
3707 let tmp = TempDir::new().unwrap();
3708 let global_path = tmp.path().join("global").join("config.toml");
3709 let repo_root = tmp.path().join("repo");
3710 fs::create_dir_all(&repo_root).unwrap();
3711
3712 write_file(&global_path, "[governance]\nadr = \"docs/adr\"\n");
3713 write_file(
3714 &repo_config_path(&repo_root),
3715 "[governance]\ndod = \"docs/dod.md\"\n",
3716 );
3717
3718 let config = load_config_from(&global_path, &repo_root).unwrap();
3719 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/adr")));
3720 assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
3721 }
3722
3723 #[test]
3725 fn governance_merge_repo_wins_per_field_when_both_set() {
3726 let tmp = TempDir::new().unwrap();
3727 let global_path = tmp.path().join("global").join("config.toml");
3728 let repo_root = tmp.path().join("repo");
3729 fs::create_dir_all(&repo_root).unwrap();
3730
3731 write_file(&global_path, "[governance]\nadr = \"docs/global-adr\"\n");
3732 write_file(
3733 &repo_config_path(&repo_root),
3734 "[governance]\nadr = \"docs/repo-adr\"\n",
3735 );
3736
3737 let config = load_config_from(&global_path, &repo_root).unwrap();
3738 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/repo-adr")));
3739 }
3740
3741 #[test]
3743 fn governance_load_repo_config_also_auto_wires_constitution() {
3744 let tmp = TempDir::new().unwrap();
3745 let repo_root = tmp.path().join("repo");
3746 let specify = repo_root.join(".specify");
3747 let specs = specify.join("specs");
3748 let memory = specify.join("memory");
3749 fs::create_dir_all(&specs).unwrap();
3750 fs::create_dir_all(&memory).unwrap();
3751 let constitution = memory.join("constitution.md");
3752 fs::write(&constitution, "# Constitution\n").unwrap();
3753
3754 write_repo_config(
3755 &repo_root,
3756 "[specs]\n\
3757 type = \"speckit\"\n\
3758 dir = \".specify/specs\"\n",
3759 );
3760
3761 let config = load_repo_config(&repo_root).unwrap();
3762 assert_eq!(
3763 config.governance.constitution.as_deref(),
3764 Some(constitution.as_path())
3765 );
3766 }
3767
3768 #[test]
3771 fn load_config_with_some_pins_global_to_override_path() {
3772 let tmp = TempDir::new().unwrap();
3773 let repo_root = tmp.path().join("repo");
3774 fs::create_dir_all(&repo_root).unwrap();
3775
3776 let global_a = tmp.path().join("global-A.toml");
3777 let global_b = tmp.path().join("global-B.toml");
3778 write_file(&global_a, "[clis.cli-A]\ncommand = \"/bin/a\"\n");
3779 write_file(&global_b, "[clis.cli-B]\ncommand = \"/bin/b\"\n");
3780
3781 let config = load_config(&repo_root, Some(&global_a)).unwrap();
3782 assert!(config.clis.contains_key("cli-A"));
3783 assert!(!config.clis.contains_key("cli-B"));
3784 }
3785
3786 #[test]
3787 fn load_config_with_some_nonexistent_returns_defaults() {
3788 let tmp = TempDir::new().unwrap();
3789 let repo_root = tmp.path().join("repo");
3790 fs::create_dir_all(&repo_root).unwrap();
3791 let missing = tmp.path().join("does-not-exist.toml");
3792
3793 let config = load_config(&repo_root, Some(&missing)).unwrap();
3794 assert_eq!(config, PawConfig::default());
3795 }
3796
3797 #[test]
3807 fn load_config_override_does_not_affect_repo_resolution() {
3808 let tmp = TempDir::new().unwrap();
3809 let repo_root = tmp.path().join("repo");
3810 fs::create_dir_all(&repo_root).unwrap();
3811 write_file(&repo_config_path(&repo_root), "default_cli = \"claude\"\n");
3812
3813 let global_path = tmp.path().join("global.toml");
3814 write_file(&global_path, "default_cli = \"gemini\"\n");
3815
3816 let config = load_config(&repo_root, Some(&global_path)).unwrap();
3817 assert_eq!(config.default_cli.as_deref(), Some("claude"));
3818 }
3819
3820 #[test]
3827 fn governance_config_rejects_gates_field() {
3828 let toml_input = "[governance]\ndod = \"docs/dod.md\"\n[governance.gates]\ndod = true\n";
3829 let cfg: PawConfig = toml::from_str(toml_input).expect("toml parse");
3830 let gov = cfg.governance;
3831 assert_eq!(gov.dod.as_deref(), Some(Path::new("docs/dod.md")));
3832
3833 let round_trip = toml::to_string(&gov).expect("serialise gov");
3834 assert!(
3835 !round_trip.contains("gates"),
3836 "GovernanceConfig must not round-trip a `gates` field; got: {round_trip}"
3837 );
3838 assert!(
3839 !round_trip.contains("[governance.gates]"),
3840 "GovernanceConfig must not round-trip a `[governance.gates]` section; got: {round_trip}"
3841 );
3842 }
3843
3844 #[test]
3852 fn border_affordances_defaults_to_true_when_layout_absent() {
3853 let cfg: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("toml parse");
3854 assert!(
3855 cfg.layout.is_none(),
3856 "no [layout] section should parse as None"
3857 );
3858 assert!(
3859 cfg.border_affordances_enabled(),
3860 "border affordances default to on when [layout] is absent"
3861 );
3862 }
3863
3864 #[test]
3867 fn border_affordances_defaults_to_true_when_field_unset() {
3868 let cfg: PawConfig = toml::from_str("[layout]\n").expect("toml parse");
3869 assert!(
3870 cfg.border_affordances_enabled(),
3871 "border affordances default to on when the field is unset"
3872 );
3873 }
3874
3875 #[test]
3877 fn border_affordances_explicit_false_resolves_off() {
3878 let cfg: PawConfig =
3879 toml::from_str("[layout]\nborder_affordances = false\n").expect("toml parse");
3880 assert_eq!(cfg.layout.as_ref().unwrap().border_affordances, Some(false));
3881 assert!(
3882 !cfg.border_affordances_enabled(),
3883 "explicit false must resolve to off"
3884 );
3885 }
3886
3887 #[test]
3889 fn border_affordances_explicit_true_resolves_on() {
3890 let cfg: PawConfig =
3891 toml::from_str("[layout]\nborder_affordances = true\n").expect("toml parse");
3892 assert!(cfg.border_affordances_enabled());
3893 }
3894
3895 #[test]
3898 fn v0_5_0_config_without_layout_parses() {
3899 let v0_5_0 = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
3900 let cfg: PawConfig = toml::from_str(v0_5_0).expect("v0.5.0 config must still parse");
3901 assert!(cfg.layout.is_none());
3902 assert!(cfg.border_affordances_enabled());
3903 }
3904
3905 #[test]
3907 fn layout_overlay_wins_in_merge() {
3908 let base: PawConfig =
3909 toml::from_str("[layout]\nborder_affordances = true\n").expect("base");
3910 let overlay: PawConfig =
3911 toml::from_str("[layout]\nborder_affordances = false\n").expect("overlay");
3912 let merged = base.merged_with(&overlay);
3913 assert!(
3914 !merged.border_affordances_enabled(),
3915 "overlay [layout] must win in the merge"
3916 );
3917 }
3918
3919 #[test]
3921 fn layout_base_preserved_when_overlay_absent() {
3922 let base: PawConfig =
3923 toml::from_str("[layout]\nborder_affordances = false\n").expect("base");
3924 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
3925 let merged = base.merged_with(&overlay);
3926 assert!(
3927 !merged.border_affordances_enabled(),
3928 "base [layout] must survive when the overlay has none"
3929 );
3930 }
3931
3932 #[test]
3935 fn role_gating_defaults_to_warn_when_section_absent() {
3936 let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
3939 assert!(config.opsx.is_none());
3940 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
3941 }
3942
3943 #[test]
3944 fn role_gating_section_present_but_field_absent_resolves_warn() {
3945 let config: PawConfig = toml::from_str("[opsx]\n").expect("parses");
3946 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
3947 }
3948
3949 #[test]
3950 fn role_gating_explicit_warn() {
3951 let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("parses");
3952 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
3953 }
3954
3955 #[test]
3956 fn role_gating_explicit_block() {
3957 let config: PawConfig =
3958 toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("parses");
3959 assert_eq!(config.role_gating_mode(), RoleGatingMode::Block);
3960 }
3961
3962 #[test]
3963 fn role_gating_explicit_off() {
3964 let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("parses");
3965 assert_eq!(config.role_gating_mode(), RoleGatingMode::Off);
3966 }
3967
3968 #[test]
3969 fn role_gating_invalid_value_is_a_parse_error() {
3970 let err = toml::from_str::<PawConfig>("[opsx]\nrole_gating = \"loud\"\n").unwrap_err();
3971 assert!(
3972 err.to_string().contains("role_gating") || err.to_string().contains("variant"),
3973 "got: {err}"
3974 );
3975 }
3976
3977 #[test]
3978 fn role_gating_mode_round_trips_through_toml() {
3979 let config = PawConfig {
3980 opsx: Some(OpsxConfig {
3981 role_gating: Some(RoleGatingMode::Block),
3982 }),
3983 ..Default::default()
3984 };
3985 let serialized = toml::to_string(&config).expect("serializes");
3986 assert!(
3987 serialized.contains("role_gating = \"block\""),
3988 "got: {serialized}"
3989 );
3990 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
3991 assert_eq!(reparsed.role_gating_mode(), RoleGatingMode::Block);
3992 }
3993
3994 #[test]
3995 fn opsx_section_merges_with_overlay_winning() {
3996 let base: PawConfig =
3997 toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("base parses");
3998 let overlay: PawConfig =
3999 toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("overlay parses");
4000 let merged = base.merged_with(&overlay);
4001 assert_eq!(merged.role_gating_mode(), RoleGatingMode::Block);
4002 }
4003
4004 #[test]
4005 fn opsx_section_base_preserved_when_overlay_absent() {
4006 let base: PawConfig =
4007 toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("base parses");
4008 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4009 let merged = base.merged_with(&overlay);
4010 assert_eq!(merged.role_gating_mode(), RoleGatingMode::Off);
4011 }
4012
4013 #[test]
4014 fn supervisor_auto_revert_defaults_false() {
4015 let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4016 let sup = config.supervisor.expect("supervisor present");
4017 assert!(!sup.auto_revert(), "auto_revert defaults to false");
4018 }
4019
4020 #[test]
4021 fn supervisor_auto_revert_explicit_true() {
4022 let config: PawConfig =
4023 toml::from_str("[supervisor]\nenabled = true\nauto_revert = true\n").expect("parses");
4024 let sup = config.supervisor.expect("supervisor present");
4025 assert!(sup.auto_revert());
4026 }
4027
4028 #[test]
4031 fn tell_config_defaults_when_table_absent() {
4032 let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4035 let sup = config.supervisor.expect("supervisor present");
4036 assert_eq!(sup.tell.mode, TellMode::Feedback);
4037 assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4038 assert!(sup.tell.is_default());
4039 }
4040
4041 #[test]
4042 fn tell_config_explicit_feedback_loads() {
4043 let config: PawConfig = toml::from_str(
4044 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"feedback\"\n",
4045 )
4046 .expect("parses");
4047 let sup = config.supervisor.expect("supervisor present");
4048 assert_eq!(sup.tell.mode, TellMode::Feedback);
4049 assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4051 }
4052
4053 #[test]
4054 fn tell_config_explicit_send_keys_loads() {
4055 let config: PawConfig = toml::from_str(
4056 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"send-keys\"\ninventory_max_age_seconds = 15\n",
4057 )
4058 .expect("parses");
4059 let sup = config.supervisor.expect("supervisor present");
4060 assert_eq!(sup.tell.mode, TellMode::SendKeys);
4061 assert_eq!(sup.tell.inventory_max_age_seconds, 15);
4062 assert!(!sup.tell.is_default());
4063 }
4064
4065 #[test]
4066 fn tell_config_rejects_unknown_mode() {
4067 let err = toml::from_str::<PawConfig>(
4068 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"shout\"\n",
4069 )
4070 .unwrap_err();
4071 assert!(
4072 err.to_string().contains("shout") || err.to_string().contains("mode"),
4073 "unknown mode should be a parse error; got {err}"
4074 );
4075 }
4076
4077 #[test]
4078 fn tell_config_all_default_table_round_trips_without_emitting_tell() {
4079 let sup = SupervisorConfig {
4082 enabled: true,
4083 ..SupervisorConfig::default()
4084 };
4085 let config = PawConfig {
4086 supervisor: Some(sup),
4087 ..PawConfig::default()
4088 };
4089 let serialized = toml::to_string_pretty(&config).expect("serializes");
4090 assert!(
4091 !serialized.contains("[supervisor.tell]"),
4092 "all-default tell table must be omitted; got:\n{serialized}"
4093 );
4094 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4095 assert_eq!(config, reparsed);
4096 }
4097}