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 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub readme: Option<PathBuf>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub docs: Option<PathBuf>,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
118pub struct McpConfig {
119 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub name: Option<String>,
127}
128
129#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
131pub struct SpecsConfig {
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub dir: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
137 pub spec_type: Option<String>,
138}
139
140#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "lowercase")]
148pub enum RoleGatingMode {
149 #[default]
152 Warn,
153 Block,
157 Off,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
167pub struct OpsxConfig {
168 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub role_gating: Option<RoleGatingMode>,
173}
174
175impl OpsxConfig {
176 #[must_use]
179 pub fn role_gating_mode(&self) -> RoleGatingMode {
180 self.role_gating.unwrap_or_default()
181 }
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
186pub struct LoggingConfig {
187 #[serde(default)]
189 pub enabled: bool,
190}
191
192#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
204#[serde(rename_all = "kebab-case")]
205pub enum ApprovalLevel {
206 Manual,
208 #[default]
210 Auto,
211 FullAuto,
213}
214
215#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
217pub struct DashboardConfig {
218 #[serde(default)]
224 pub show_message_log: bool,
225 #[serde(default)]
229 pub broker_log: BrokerLogConfig,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
238pub struct BrokerLogConfig {
239 #[serde(default = "BrokerLogConfig::default_max_messages")]
243 pub max_messages: usize,
244 #[serde(default = "BrokerLogConfig::default_visible")]
248 pub default_visible: bool,
249}
250
251impl Default for BrokerLogConfig {
252 fn default() -> Self {
253 Self {
254 max_messages: Self::default_max_messages(),
255 default_visible: Self::default_visible(),
256 }
257 }
258}
259
260impl BrokerLogConfig {
261 fn default_max_messages() -> usize {
262 500
263 }
264
265 fn default_visible() -> bool {
266 true
267 }
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
276pub struct SupervisorConfig {
277 #[serde(default)]
279 pub enabled: bool,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub cli: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub test_command: Option<String>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub lint_command: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub build_command: Option<String>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub doc_build_command: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub doc_tool_command: Option<String>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub spec_validate_command: Option<String>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub fmt_check_command: Option<String>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub security_audit_command: Option<String>,
356 #[serde(default)]
358 pub agent_approval: ApprovalLevel,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub auto_approve: Option<AutoApproveConfig>,
366 #[serde(default)]
374 pub conflict: ConflictConfig,
375 #[serde(default)]
382 pub learnings: bool,
383 #[serde(default)]
390 pub learnings_config: LearningsConfig,
391 #[serde(default)]
398 pub common_dev_allowlist: CommonDevAllowlistConfig,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub verify_on_commit_nudge: Option<bool>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub strict_branch_guard: Option<bool>,
424 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub auto_revert: Option<bool>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub manual_approvals_log: Option<bool>,
448 #[serde(default, skip_serializing_if = "TellConfig::is_default")]
456 pub tell: TellConfig,
457}
458
459#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
466#[serde(rename_all = "kebab-case")]
467pub enum TellMode {
468 #[default]
471 Feedback,
472 SendKeys,
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
485pub struct TellConfig {
486 #[serde(default)]
488 pub mode: TellMode,
489 #[serde(default = "TellConfig::default_inventory_max_age_seconds")]
492 pub inventory_max_age_seconds: u64,
493}
494
495impl Default for TellConfig {
496 fn default() -> Self {
497 Self {
498 mode: TellMode::default(),
499 inventory_max_age_seconds: Self::default_inventory_max_age_seconds(),
500 }
501 }
502}
503
504impl TellConfig {
505 fn default_inventory_max_age_seconds() -> u64 {
506 60
507 }
508
509 #[must_use]
515 pub fn is_default(&self) -> bool {
516 *self == Self::default()
517 }
518}
519
520impl SupervisorConfig {
521 #[must_use]
524 pub fn strict_branch_guard(&self) -> bool {
525 self.strict_branch_guard.unwrap_or(true)
526 }
527
528 #[must_use]
532 pub fn auto_revert(&self) -> bool {
533 self.auto_revert.unwrap_or(false)
534 }
535
536 #[must_use]
541 pub fn manual_approvals_log_enabled(&self) -> bool {
542 self.manual_approvals_log.unwrap_or(true)
543 }
544
545 #[must_use]
549 pub fn gate_commands(&self) -> crate::skills::GateCommands<'_> {
550 crate::skills::GateCommands {
551 test_command: self.test_command.as_deref(),
552 lint_command: self.lint_command.as_deref(),
553 build_command: self.build_command.as_deref(),
554 doc_build_command: self.doc_build_command.as_deref(),
555 spec_validate_command: self.spec_validate_command.as_deref(),
556 fmt_check_command: self.fmt_check_command.as_deref(),
557 security_audit_command: self.security_audit_command.as_deref(),
558 doc_tool_command: self.doc_tool_command.as_deref(),
559 }
560 }
561
562 #[must_use]
569 pub fn verify_on_commit_nudge_enabled(&self) -> bool {
570 self.verify_on_commit_nudge.unwrap_or(true)
571 }
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
583pub struct CommonDevAllowlistConfig {
584 #[serde(default = "CommonDevAllowlistConfig::default_enabled")]
590 pub enabled: bool,
591 #[serde(default)]
598 pub extra: Vec<String>,
599}
600
601impl Default for CommonDevAllowlistConfig {
602 fn default() -> Self {
603 Self {
604 enabled: Self::default_enabled(),
605 extra: Vec::new(),
606 }
607 }
608}
609
610impl CommonDevAllowlistConfig {
611 fn default_enabled() -> bool {
612 true
613 }
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
623pub struct LearningsConfig {
624 #[serde(default = "LearningsConfig::default_flush_interval_seconds")]
626 pub flush_interval_seconds: u64,
627 #[serde(default)]
635 pub broker_publish: BrokerPublish,
636}
637
638impl Default for LearningsConfig {
639 fn default() -> Self {
640 Self {
641 flush_interval_seconds: Self::default_flush_interval_seconds(),
642 broker_publish: BrokerPublish::default(),
643 }
644 }
645}
646
647impl LearningsConfig {
648 fn default_flush_interval_seconds() -> u64 {
649 60
650 }
651}
652
653#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
658#[serde(rename_all = "snake_case")]
659pub enum BrokerPublish {
660 #[default]
663 Auto,
664 ForceOff,
666}
667
668impl BrokerPublish {
669 #[must_use]
672 pub fn resolve(self, broker_enabled: bool) -> bool {
673 match self {
674 Self::Auto => broker_enabled,
675 Self::ForceOff => false,
676 }
677 }
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
688pub struct ConflictConfig {
689 #[serde(default = "ConflictConfig::default_window_seconds")]
692 pub window_seconds: u64,
693 #[serde(default = "ConflictConfig::default_true")]
699 pub warn_on_intent_overlap: bool,
700 #[serde(default = "ConflictConfig::default_true")]
705 pub escalate_on_violation: bool,
706}
707
708impl Default for ConflictConfig {
709 fn default() -> Self {
710 Self {
711 window_seconds: Self::default_window_seconds(),
712 warn_on_intent_overlap: true,
713 escalate_on_violation: true,
714 }
715 }
716}
717
718impl ConflictConfig {
719 fn default_window_seconds() -> u64 {
720 120
721 }
722
723 fn default_true() -> bool {
724 true
725 }
726}
727
728#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
740#[serde(rename_all = "kebab-case")]
741pub enum ApprovalLevelPreset {
742 Off,
744 Conservative,
746 #[default]
748 Safe,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
762pub struct AutoApproveConfig {
763 #[serde(default = "AutoApproveConfig::default_enabled")]
765 pub enabled: bool,
766 #[serde(default)]
770 pub safe_commands: Vec<String>,
771 #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
774 pub stall_threshold_seconds: u64,
775 #[serde(default)]
782 pub approval_level: ApprovalLevelPreset,
783 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub approve_worktree_writes: Option<bool>,
794}
795
796impl Default for AutoApproveConfig {
797 fn default() -> Self {
798 Self {
799 enabled: Self::default_enabled(),
800 safe_commands: Vec::new(),
801 stall_threshold_seconds: Self::default_stall_threshold_seconds(),
802 approval_level: ApprovalLevelPreset::Safe,
803 approve_worktree_writes: None,
804 }
805 }
806}
807
808impl AutoApproveConfig {
809 pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
812
813 fn default_enabled() -> bool {
814 true
815 }
816
817 fn default_stall_threshold_seconds() -> u64 {
818 30
819 }
820
821 #[must_use]
828 pub fn resolved(&self) -> Self {
829 let mut out = self.clone();
830 if out.approval_level == ApprovalLevelPreset::Off {
831 out.enabled = false;
832 }
833 if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
834 eprintln!(
835 "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
836 out.stall_threshold_seconds,
837 Self::MIN_STALL_THRESHOLD_SECONDS
838 );
839 out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
840 }
841 out
842 }
843
844 #[must_use]
851 pub fn approve_worktree_writes(&self) -> bool {
852 self.approve_worktree_writes.unwrap_or(true)
853 }
854
855 #[must_use]
862 pub fn effective_whitelist(&self) -> Vec<String> {
863 let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
864 .iter()
865 .map(|s| (*s).to_string())
866 .collect();
867 for extra in &self.safe_commands {
868 if !out.iter().any(|e| e == extra) {
869 out.push(extra.clone());
870 }
871 }
872 if self.approval_level == ApprovalLevelPreset::Conservative {
873 out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
874 }
875 out
876 }
877}
878
879#[must_use]
899pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
900 match (cli, level) {
901 ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
902 ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
903 ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
904 _ => "",
905 }
906}
907
908#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
916pub struct WatcherConfig {
917 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub republish_working_ttl_seconds: Option<u64>,
928}
929
930impl WatcherConfig {
931 pub const DEFAULT_REPUBLISH_TTL_SECONDS: u64 = 60;
933 pub const MIN_REPUBLISH_TTL_SECONDS: u64 = 5;
935
936 #[must_use]
944 pub fn republish_working_ttl_seconds(&self) -> u64 {
945 match self.republish_working_ttl_seconds {
946 None => Self::DEFAULT_REPUBLISH_TTL_SECONDS,
947 Some(0) => 0,
948 Some(n) if n < Self::MIN_REPUBLISH_TTL_SECONDS => {
949 eprintln!(
950 "warning: [broker.watcher] republish_working_ttl_seconds = {n} clamped to {}s minimum",
951 Self::MIN_REPUBLISH_TTL_SECONDS
952 );
953 Self::MIN_REPUBLISH_TTL_SECONDS
954 }
955 Some(n) => n,
956 }
957 }
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
962pub struct BrokerConfig {
963 #[serde(default)]
965 pub enabled: bool,
966 #[serde(default = "BrokerConfig::default_port")]
968 pub port: u16,
969 #[serde(default = "BrokerConfig::default_bind")]
971 pub bind: String,
972 #[serde(default)]
974 pub watcher: WatcherConfig,
975}
976
977impl Default for BrokerConfig {
978 fn default() -> Self {
979 Self {
980 enabled: false,
981 port: 9119,
982 bind: "127.0.0.1".to_string(),
983 watcher: WatcherConfig::default(),
984 }
985 }
986}
987
988impl BrokerConfig {
989 pub fn url(&self) -> String {
991 format!("http://{}:{}", self.bind, self.port)
992 }
993
994 fn default_port() -> u16 {
995 9119
996 }
997
998 fn default_bind() -> String {
999 "127.0.0.1".to_string()
1000 }
1001}
1002
1003#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1008pub struct LayoutConfig {
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1017 pub border_affordances: Option<bool>,
1018}
1019
1020impl LayoutConfig {
1021 #[must_use]
1023 pub fn border_affordances_enabled(&self) -> bool {
1024 self.border_affordances.unwrap_or(true)
1025 }
1026}
1027
1028#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1032pub struct PawConfig {
1033 #[serde(default, skip_serializing_if = "Option::is_none")]
1035 pub default_cli: Option<String>,
1036
1037 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub default_spec_cli: Option<String>,
1040
1041 #[serde(default, skip_serializing_if = "Option::is_none")]
1043 pub branch_prefix: Option<String>,
1044
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub mouse: Option<bool>,
1048
1049 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1051 pub clis: HashMap<String, CustomCli>,
1052
1053 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1055 pub presets: HashMap<String, Preset>,
1056
1057 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub specs: Option<SpecsConfig>,
1060
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1063 pub logging: Option<LoggingConfig>,
1064
1065 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub dashboard: Option<DashboardConfig>,
1068
1069 #[serde(default)]
1071 pub broker: BrokerConfig,
1072
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub supervisor: Option<SupervisorConfig>,
1076
1077 #[serde(default)]
1083 pub governance: GovernanceConfig,
1084
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1091 pub layout: Option<LayoutConfig>,
1092
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub opsx: Option<OpsxConfig>,
1100
1101 #[serde(default)]
1108 pub mcp: McpConfig,
1109}
1110
1111impl PawConfig {
1112 #[must_use]
1117 pub fn merged_with(&self, overlay: &Self) -> Self {
1118 let mut clis = self.clis.clone();
1119 for (k, v) in &overlay.clis {
1120 clis.insert(k.clone(), v.clone());
1121 }
1122
1123 let mut presets = self.presets.clone();
1124 for (k, v) in &overlay.presets {
1125 presets.insert(k.clone(), v.clone());
1126 }
1127
1128 Self {
1129 default_cli: overlay
1130 .default_cli
1131 .clone()
1132 .or_else(|| self.default_cli.clone()),
1133 default_spec_cli: overlay
1134 .default_spec_cli
1135 .clone()
1136 .or_else(|| self.default_spec_cli.clone()),
1137 branch_prefix: overlay
1138 .branch_prefix
1139 .clone()
1140 .or_else(|| self.branch_prefix.clone()),
1141 mouse: overlay.mouse.or(self.mouse),
1142 clis,
1143 presets,
1144 specs: overlay.specs.clone().or_else(|| self.specs.clone()),
1145 logging: overlay.logging.clone().or_else(|| self.logging.clone()),
1146 dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
1147 broker: if overlay.broker == BrokerConfig::default() {
1148 self.broker.clone()
1149 } else {
1150 overlay.broker.clone()
1151 },
1152 supervisor: overlay
1153 .supervisor
1154 .clone()
1155 .or_else(|| self.supervisor.clone()),
1156 governance: GovernanceConfig {
1157 adr: overlay
1158 .governance
1159 .adr
1160 .clone()
1161 .or_else(|| self.governance.adr.clone()),
1162 test_strategy: overlay
1163 .governance
1164 .test_strategy
1165 .clone()
1166 .or_else(|| self.governance.test_strategy.clone()),
1167 security: overlay
1168 .governance
1169 .security
1170 .clone()
1171 .or_else(|| self.governance.security.clone()),
1172 dod: overlay
1173 .governance
1174 .dod
1175 .clone()
1176 .or_else(|| self.governance.dod.clone()),
1177 constitution: overlay
1178 .governance
1179 .constitution
1180 .clone()
1181 .or_else(|| self.governance.constitution.clone()),
1182 readme: overlay
1183 .governance
1184 .readme
1185 .clone()
1186 .or_else(|| self.governance.readme.clone()),
1187 docs: overlay
1188 .governance
1189 .docs
1190 .clone()
1191 .or_else(|| self.governance.docs.clone()),
1192 },
1193 layout: overlay.layout.clone().or_else(|| self.layout.clone()),
1194 opsx: overlay.opsx.clone().or_else(|| self.opsx.clone()),
1195 mcp: McpConfig {
1196 name: overlay.mcp.name.clone().or_else(|| self.mcp.name.clone()),
1197 },
1198 }
1199 }
1200
1201 #[must_use]
1205 pub fn role_gating_mode(&self) -> RoleGatingMode {
1206 self.opsx
1207 .as_ref()
1208 .map(OpsxConfig::role_gating_mode)
1209 .unwrap_or_default()
1210 }
1211
1212 #[must_use]
1216 pub fn border_affordances_enabled(&self) -> bool {
1217 self.layout
1218 .as_ref()
1219 .is_none_or(LayoutConfig::border_affordances_enabled)
1220 }
1221
1222 #[must_use]
1228 pub fn mcp_server_name(&self) -> String {
1229 self.mcp
1230 .name
1231 .clone()
1232 .unwrap_or_else(|| "git-paw".to_string())
1233 }
1234
1235 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
1237 self.presets.get(name)
1238 }
1239
1240 pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
1242 self.dashboard.as_ref()
1243 }
1244}
1245
1246pub fn global_config_path() -> Result<PathBuf, PawError> {
1248 crate::dirs::config_dir()
1249 .map(|d| d.join("git-paw").join("config.toml"))
1250 .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
1251}
1252
1253pub fn repo_config_path(repo_root: &Path) -> PathBuf {
1255 repo_root.join(".git-paw").join("config.toml")
1256}
1257
1258fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
1260 match fs::read_to_string(path) {
1261 Ok(contents) => {
1262 let config: PawConfig = toml::from_str(&contents)
1263 .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
1264 Ok(Some(config))
1265 }
1266 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1267 Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
1268 }
1269}
1270
1271pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
1279 let mut config = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1280 auto_wire_governance(&mut config, repo_root);
1281 Ok(config)
1282}
1283
1284fn auto_wire_governance(config: &mut PawConfig, repo_root: &Path) {
1302 if config.governance.constitution.is_some() {
1303 return;
1304 }
1305 let Some(specs_cfg) = config.specs.as_ref() else {
1306 return;
1307 };
1308 let Some(spec_type) = specs_cfg.spec_type.as_deref() else {
1309 return;
1310 };
1311 if spec_type != "speckit" {
1312 return;
1313 }
1314 let dir = specs_cfg.dir.as_deref().unwrap_or("specs");
1315 let specs_dir = repo_root.join(dir);
1316 if let Some(detected) = crate::specs::speckit::detect_constitution(&specs_dir) {
1317 config.governance.constitution = Some(detected);
1318 }
1319}
1320
1321pub fn load_config(
1347 repo_root: &Path,
1348 user_config_path: Option<&Path>,
1349) -> Result<PawConfig, PawError> {
1350 let global_path = match user_config_path {
1351 Some(p) => p.to_path_buf(),
1352 None => global_config_path()?,
1353 };
1354 load_config_from(&global_path, repo_root)
1355}
1356
1357pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
1362 let global = load_config_file(global_path)?.unwrap_or_default();
1363 let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1364 let mut merged = global.merged_with(&repo);
1365 auto_wire_governance(&mut merged, repo_root);
1366 Ok(merged)
1367}
1368
1369pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
1371 save_config_to(&repo_config_path(repo_root), config)
1372}
1373
1374fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
1376 let dir = path
1377 .parent()
1378 .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
1379 fs::create_dir_all(dir)
1380 .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
1381
1382 let contents =
1383 toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
1384
1385 let tmp = path.with_extension("toml.tmp");
1387 fs::write(&tmp, &contents)
1388 .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
1389 fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
1390
1391 Ok(())
1392}
1393
1394pub fn add_custom_cli(
1398 name: &str,
1399 command: &str,
1400 display_name: Option<&str>,
1401) -> Result<(), PawError> {
1402 add_custom_cli_to(&global_config_path()?, name, command, display_name)
1403}
1404
1405pub fn add_custom_cli_to(
1409 config_path: &Path,
1410 name: &str,
1411 command: &str,
1412 display_name: Option<&str>,
1413) -> Result<(), PawError> {
1414 let resolved_command = if Path::new(command).is_absolute() {
1415 command.to_string()
1416 } else {
1417 which::which(command)
1418 .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
1419 .to_string_lossy()
1420 .into_owned()
1421 };
1422
1423 let mut config = load_config_file(config_path)?.unwrap_or_default();
1424
1425 config.clis.insert(
1426 name.to_string(),
1427 CustomCli {
1428 command: resolved_command,
1429 display_name: display_name.map(String::from),
1430 submit_delay_ms: None,
1431 settings_path: None,
1432 },
1433 );
1434
1435 save_config_to(config_path, &config)
1436}
1437
1438#[allow(clippy::too_many_lines)] pub fn generate_default_config() -> String {
1442 r#"# git-paw configuration
1443# See https://github.com/bearicorn/git-paw for documentation.
1444
1445# Pre-select a CLI in the interactive picker (user can still change).
1446# Omit to show the full picker with no default.
1447# default_cli = ""
1448
1449# Enable tmux mouse mode for sessions (default: true).
1450# mouse = true
1451
1452# Bypass the CLI picker entirely for --from-specs mode.
1453# Omit to prompt or use per-spec paw_cli fields.
1454# default_spec_cli = ""
1455
1456# Prefix for spec-derived branch names (default: "spec/" ).
1457# branch_prefix = "spec/"
1458
1459# Dashboard message log configuration.
1460# [dashboard]
1461# show_message_log = false
1462
1463# Spec scanning configuration.
1464# [specs]
1465# dir = "specs"
1466#
1467# OpenSpec format (directory-based, default):
1468# type = "openspec"
1469#
1470# Markdown format (frontmatter-based):
1471# type = "markdown"
1472# Each .md file uses YAML frontmatter fields:
1473# paw_status — "pending" | "done" | "in-progress" (required)
1474# paw_branch — branch name suffix (optional, falls back to filename)
1475# paw_cli — CLI override for this spec (optional)
1476
1477# Session logging configuration.
1478# [logging]
1479# enabled = false
1480
1481# HTTP broker for agent coordination (requires --broker flag on start).
1482# [broker]
1483# enabled = true
1484# port = 9119
1485# bind = "127.0.0.1"
1486
1487# Supervisor mode — git-paw acts as a coordinating layer in front of the
1488# agent CLI, enforcing approval policy and running configured gate
1489# commands during the five-gate verification workflow.
1490#
1491# Gate command templates feed the supervisor skill's five gates: gate 1
1492# Testing (fmt_check / lint / build / test), gate 3 Spec audit
1493# (spec_validate), gate 4 Doc audit (doc_build), gate 5 Security audit
1494# (security_audit). When a key is omitted, the matching placeholder
1495# renders as `(not configured)` in the supervisor skill and the agent
1496# skips that tooling step (the gate's manual review still applies).
1497# `{{CHANGE_ID}}` inside spec_validate_command is substituted by the
1498# supervisor agent at verification time with the change name.
1499# [supervisor]
1500# enabled = true
1501# cli = "claude"
1502# test_command = "just check" # or: "cargo test", "npm test", "pytest"
1503# lint_command = "cargo clippy -- -D warnings" # or: "npm run lint", "ruff check .", "golangci-lint run"
1504# build_command = "cargo build" # or: "npm run build", "mvn package", "go build ./..."
1505# fmt_check_command = "cargo fmt --check" # or: "prettier --check .", "gofmt -l ."
1506# doc_build_command = "mdbook build docs/" # or: "sphinx-build", "mkdocs build"
1507# doc_tool_command = "cargo doc --no-deps" # or: "sphinx-build -W docs docs/_build", "javadoc", "npx typedoc"
1508# spec_validate_command = "openspec validate {{CHANGE_ID}} --strict" # OpenSpec only
1509# security_audit_command = "cargo audit" # or: "npm audit", "bandit -r ."
1510# agent_approval = "auto" # one of: "manual", "auto", "full-auto"
1511# verify_on_commit_nudge = true # broker nudges the supervisor to verify each commit promptly (default true)
1512#
1513# Routing through the supervisor (the /tell and /agents commands). The user
1514# types in the supervisor pane and the supervisor routes the prompt to the
1515# named agent. `mode` selects the default delivery channel:
1516# "feedback" (default) — queue an agent.feedback; the agent picks it up on
1517# its next inbox poll. Safe for mixed-mode sessions.
1518# "send-keys" — inject the prompt directly into the target pane;
1519# used only when the target is in accept-edits mode,
1520# otherwise /tell falls back to feedback.
1521# `inventory_max_age_seconds` is how stale the cached /agents inventory may be
1522# before /tell or /agents re-polls the broker (default 60).
1523# [supervisor.tell]
1524# mode = "feedback"
1525# inventory_max_age_seconds = 60
1526#
1527# Conflict detector tuning. Active only when supervisor mode is enabled.
1528# [supervisor.conflict]
1529# window_seconds = 120 # escalate unresolved in-flight conflicts after this many seconds
1530# warn_on_intent_overlap = true # emit feedback when two agent.intent declarations overlap
1531# escalate_on_violation = true # also publish agent.question to supervisor on ownership violations
1532
1533# Common dev-command allowlist. When supervisor mode starts a session,
1534# git-paw seeds .claude/settings.json::allowed_bash_prefixes with a
1535# curated preset (cargo, git, just, mdbook, openspec, find, grep, sed -n)
1536# so agents do not hit a permission prompt for each variant. Opt out by
1537# setting enabled = false; extend with project-specific prefixes via extra.
1538# [supervisor.common_dev_allowlist]
1539# enabled = true
1540# extra = ["pnpm test", "deno fmt"]
1541
1542# opsx (OpenSpec) role gating. When the session's spec engine is OpenSpec,
1543# git-paw's post-commit guard detects archive activity (`/opsx:archive` /
1544# `openspec archive`) by a non-supervisor agent and reacts per this mode:
1545# "warn" (default) — feedback to the offending agent + a permission_pattern
1546# learning the user sees in learnings.
1547# "block" — warn behaviour PLUS a feedback to the supervisor
1548# requesting it revert the offending commit.
1549# "off" — guard disabled entirely.
1550# The guard is inert under non-OpenSpec engines (speckit, markdown).
1551# [opsx]
1552# role_gating = "warn"
1553
1554# Custom CLI definitions.
1555# [clis.my-agent]
1556# command = "/usr/local/bin/my-agent"
1557# display_name = "My Agent"
1558
1559# Named presets for quick launches.
1560# [presets.my-preset]
1561# branches = ["feat/api", "fix/db"]
1562# cli = ""
1563"#
1564 .to_string()
1565}
1566
1567pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
1571 remove_custom_cli_from(&global_config_path()?, name)
1572}
1573
1574pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
1578 let mut config = load_config_file(config_path)?.unwrap_or_default();
1579
1580 if config.clis.remove(name).is_none() {
1581 return Err(PawError::CliNotFound(name.to_string()));
1582 }
1583
1584 save_config_to(config_path, &config)
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589 use super::*;
1590 use tempfile::TempDir;
1591
1592 fn write_file(path: &Path, content: &str) {
1593 if let Some(parent) = path.parent() {
1594 fs::create_dir_all(parent).unwrap();
1595 }
1596 fs::write(path, content).unwrap();
1597 }
1598
1599 #[test]
1602 fn parses_config_with_all_fields() {
1603 let tmp = TempDir::new().unwrap();
1604 let path = tmp.path().join("config.toml");
1605 write_file(
1606 &path,
1607 r#"
1608default_cli = "claude"
1609mouse = false
1610default_spec_cli = "gemini"
1611branch_prefix = "spec/"
1612
1613[clis.my-agent]
1614command = "/usr/local/bin/my-agent"
1615display_name = "My Agent"
1616
1617[clis.local-llm]
1618command = "ollama-code"
1619
1620[presets.backend]
1621branches = ["feature/api", "fix/db"]
1622cli = "claude"
1623
1624[specs]
1625dir = "my-specs"
1626type = "openspec"
1627
1628[logging]
1629enabled = true
1630"#,
1631 );
1632
1633 let config = load_config_file(&path).unwrap().unwrap();
1634 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1635 assert_eq!(config.mouse, Some(false));
1636 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1637 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1638 assert_eq!(config.clis.len(), 2);
1639 assert_eq!(
1640 config.clis["my-agent"].display_name.as_deref(),
1641 Some("My Agent")
1642 );
1643 assert_eq!(config.clis["local-llm"].command, "ollama-code");
1644 assert_eq!(config.presets["backend"].cli, "claude");
1645 assert_eq!(
1646 config.presets["backend"].branches,
1647 vec!["feature/api", "fix/db"]
1648 );
1649 let specs = config.specs.unwrap();
1650 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1651 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1652 let logging = config.logging.unwrap();
1653 assert!(logging.enabled);
1654 }
1655
1656 #[test]
1657 fn all_fields_are_optional() {
1658 let tmp = TempDir::new().unwrap();
1659 let path = tmp.path().join("config.toml");
1660 write_file(&path, "default_cli = \"gemini\"\n");
1661
1662 let config = load_config_file(&path).unwrap().unwrap();
1663 assert_eq!(config.default_cli.as_deref(), Some("gemini"));
1664 assert_eq!(config.mouse, None);
1665 assert!(config.clis.is_empty());
1666 assert!(config.presets.is_empty());
1667 }
1668
1669 #[test]
1670 fn returns_defaults_when_no_files_exist() {
1671 let tmp = TempDir::new().unwrap();
1672 let global_path = tmp.path().join("nonexistent").join("config.toml");
1673 let repo_root = tmp.path().join("repo");
1674 fs::create_dir_all(&repo_root).unwrap();
1675
1676 let config = load_config_from(&global_path, &repo_root).unwrap();
1677 assert_eq!(config.default_cli, None);
1678 assert_eq!(config.mouse, None);
1679 assert!(config.clis.is_empty());
1680 assert!(config.presets.is_empty());
1681 }
1682
1683 #[test]
1684 fn reports_error_for_invalid_toml() {
1685 let tmp = TempDir::new().unwrap();
1686 let path = tmp.path().join("bad.toml");
1687 write_file(&path, "this is not [valid toml");
1688
1689 let err = load_config_file(&path).unwrap_err();
1690 assert!(err.to_string().contains("bad.toml"));
1691 }
1692
1693 #[test]
1696 fn repo_config_overrides_global_scalars() {
1697 let tmp = TempDir::new().unwrap();
1698 let global_path = tmp.path().join("global").join("config.toml");
1699 let repo_root = tmp.path().join("repo");
1700 fs::create_dir_all(&repo_root).unwrap();
1701
1702 write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
1703 write_file(
1704 &repo_config_path(&repo_root),
1705 "default_cli = \"gemini\"\n", );
1707
1708 let config = load_config_from(&global_path, &repo_root).unwrap();
1709 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
1712
1713 #[test]
1714 fn repo_config_merges_cli_maps() {
1715 let tmp = TempDir::new().unwrap();
1716 let global_path = tmp.path().join("global").join("config.toml");
1717 let repo_root = tmp.path().join("repo");
1718 fs::create_dir_all(&repo_root).unwrap();
1719
1720 write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
1721 write_file(
1722 &repo_config_path(&repo_root),
1723 "[clis.agent-b]\ncommand = \"/bin/b\"\n",
1724 );
1725
1726 let config = load_config_from(&global_path, &repo_root).unwrap();
1727 assert_eq!(config.clis.len(), 2);
1728 assert!(config.clis.contains_key("agent-a"));
1729 assert!(config.clis.contains_key("agent-b"));
1730 }
1731
1732 #[test]
1733 fn repo_cli_overrides_global_cli_with_same_name() {
1734 let tmp = TempDir::new().unwrap();
1735 let global_path = tmp.path().join("global").join("config.toml");
1736 let repo_root = tmp.path().join("repo");
1737 fs::create_dir_all(&repo_root).unwrap();
1738
1739 write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
1740 write_file(
1741 &repo_config_path(&repo_root),
1742 "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
1743 );
1744
1745 let config = load_config_from(&global_path, &repo_root).unwrap();
1746 assert_eq!(config.clis["my-agent"].command, "/new/path");
1747 assert_eq!(
1748 config.clis["my-agent"].display_name.as_deref(),
1749 Some("Overridden")
1750 );
1751 }
1752
1753 #[test]
1754 fn load_config_from_reads_global_file_when_no_repo() {
1755 let tmp = TempDir::new().unwrap();
1756 let global_path = tmp.path().join("global").join("config.toml");
1757 let repo_root = tmp.path().join("repo");
1758 fs::create_dir_all(&repo_root).unwrap();
1759
1760 write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
1761 let config = load_config_from(&global_path, &repo_root).unwrap();
1764 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1765 assert_eq!(config.mouse, Some(false));
1766 }
1767
1768 #[test]
1769 fn load_config_from_reads_repo_file_when_no_global() {
1770 let tmp = TempDir::new().unwrap();
1771 let global_path = tmp.path().join("nonexistent").join("config.toml");
1772 let repo_root = tmp.path().join("repo");
1773 fs::create_dir_all(&repo_root).unwrap();
1774
1775 write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
1776
1777 let config = load_config_from(&global_path, &repo_root).unwrap();
1778 assert_eq!(config.default_cli.as_deref(), Some("codex"));
1779 }
1780
1781 #[test]
1784 fn preset_accessible_by_name() {
1785 let tmp = TempDir::new().unwrap();
1786 let global_path = tmp.path().join("global").join("config.toml");
1787 let repo_root = tmp.path().join("repo");
1788 fs::create_dir_all(&repo_root).unwrap();
1789
1790 write_file(
1791 &repo_config_path(&repo_root),
1792 "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
1793 );
1794
1795 let config = load_config_from(&global_path, &repo_root).unwrap();
1796 let preset = config.get_preset("backend").unwrap();
1797 assert_eq!(preset.cli, "claude");
1798 assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
1799 }
1800
1801 #[test]
1802 fn preset_returns_none_when_not_in_config() {
1803 let tmp = TempDir::new().unwrap();
1804 let global_path = tmp.path().join("config.toml");
1805 write_file(&global_path, "default_cli = \"claude\"\n");
1806
1807 let config = load_config_file(&global_path).unwrap().unwrap();
1808 assert!(config.get_preset("nonexistent").is_none());
1809 }
1810
1811 #[test]
1814 fn add_cli_writes_to_config_file() {
1815 let tmp = TempDir::new().unwrap();
1816 let config_path = tmp.path().join("git-paw").join("config.toml");
1817
1818 add_custom_cli_to(
1820 &config_path,
1821 "my-agent",
1822 "/usr/local/bin/my-agent",
1823 Some("My Agent"),
1824 )
1825 .unwrap();
1826
1827 let config = load_config_file(&config_path).unwrap().unwrap();
1829 assert_eq!(config.clis.len(), 1);
1830 assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
1831 assert_eq!(
1832 config.clis["my-agent"].display_name.as_deref(),
1833 Some("My Agent")
1834 );
1835 }
1836
1837 #[test]
1838 fn add_cli_preserves_existing_entries() {
1839 let tmp = TempDir::new().unwrap();
1840 let config_path = tmp.path().join("git-paw").join("config.toml");
1841
1842 add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
1843 add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
1844
1845 let config = load_config_file(&config_path).unwrap().unwrap();
1846 assert_eq!(config.clis.len(), 2);
1847 assert!(config.clis.contains_key("first"));
1848 assert!(config.clis.contains_key("second"));
1849 }
1850
1851 #[test]
1852 fn add_cli_errors_when_command_not_on_path() {
1853 let tmp = TempDir::new().unwrap();
1854 let config_path = tmp.path().join("config.toml");
1855
1856 let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
1857 .unwrap_err();
1858 assert!(err.to_string().contains("not found on PATH"));
1859 }
1860
1861 #[test]
1864 fn remove_cli_deletes_entry_from_config_file() {
1865 let tmp = TempDir::new().unwrap();
1866 let config_path = tmp.path().join("git-paw").join("config.toml");
1867
1868 add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
1870 add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
1871
1872 remove_custom_cli_from(&config_path, "remove-me").unwrap();
1874
1875 let config = load_config_file(&config_path).unwrap().unwrap();
1877 assert_eq!(config.clis.len(), 1);
1878 assert!(config.clis.contains_key("keep-me"));
1879 assert!(!config.clis.contains_key("remove-me"));
1880 }
1881
1882 #[test]
1883 fn remove_nonexistent_cli_returns_cli_not_found_error() {
1884 let tmp = TempDir::new().unwrap();
1885 let config_path = tmp.path().join("config.toml");
1886 write_file(&config_path, "");
1888
1889 let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
1890 match err {
1891 PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
1892 other => panic!("expected CliNotFound, got: {other}"),
1893 }
1894 }
1895
1896 #[test]
1897 fn remove_cli_from_empty_config_returns_error() {
1898 let tmp = TempDir::new().unwrap();
1899 let config_path = tmp.path().join("config.toml");
1900 let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
1903 match err {
1904 PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
1905 other => panic!("expected CliNotFound, got: {other}"),
1906 }
1907 }
1908
1909 #[test]
1914 fn parses_default_spec_cli_when_present() {
1915 let tmp = TempDir::new().unwrap();
1916 let path = tmp.path().join("config.toml");
1917 write_file(&path, "default_spec_cli = \"claude\"\n");
1918
1919 let config = load_config_file(&path).unwrap().unwrap();
1920 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1921 }
1922
1923 #[test]
1924 fn default_spec_cli_defaults_to_none() {
1925 let tmp = TempDir::new().unwrap();
1926 let path = tmp.path().join("config.toml");
1927 write_file(&path, "default_cli = \"claude\"\n");
1928
1929 let config = load_config_file(&path).unwrap().unwrap();
1930 assert_eq!(config.default_spec_cli, None);
1931 }
1932
1933 #[test]
1934 fn repo_overrides_global_default_spec_cli() {
1935 let tmp = TempDir::new().unwrap();
1936 let global_path = tmp.path().join("global").join("config.toml");
1937 let repo_root = tmp.path().join("repo");
1938 fs::create_dir_all(&repo_root).unwrap();
1939
1940 write_file(&global_path, "default_spec_cli = \"claude\"\n");
1941 write_file(
1942 &repo_config_path(&repo_root),
1943 "default_spec_cli = \"gemini\"\n",
1944 );
1945
1946 let config = load_config_from(&global_path, &repo_root).unwrap();
1947 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1948 }
1949
1950 #[test]
1951 fn global_default_spec_cli_preserved_when_repo_absent() {
1952 let tmp = TempDir::new().unwrap();
1953 let global_path = tmp.path().join("global").join("config.toml");
1954 let repo_root = tmp.path().join("repo");
1955 fs::create_dir_all(&repo_root).unwrap();
1956
1957 write_file(&global_path, "default_spec_cli = \"claude\"\n");
1958
1959 let config = load_config_from(&global_path, &repo_root).unwrap();
1960 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1961 }
1962
1963 #[test]
1966 fn config_survives_save_and_load() {
1967 let tmp = TempDir::new().unwrap();
1968 let config_path = tmp.path().join("config.toml");
1969
1970 let original = PawConfig {
1971 default_cli: Some("claude".into()),
1972 default_spec_cli: None,
1973 branch_prefix: None,
1974 mouse: Some(true),
1975 clis: HashMap::from([(
1976 "test".into(),
1977 CustomCli {
1978 command: "/bin/test".into(),
1979 display_name: Some("Test CLI".into()),
1980 submit_delay_ms: None,
1981 settings_path: None,
1982 },
1983 )]),
1984 presets: HashMap::from([(
1985 "dev".into(),
1986 Preset {
1987 branches: vec!["main".into()],
1988 cli: "claude".into(),
1989 },
1990 )]),
1991 specs: None,
1992 logging: None,
1993 dashboard: None,
1994 broker: BrokerConfig::default(),
1995 supervisor: None,
1996 governance: GovernanceConfig::default(),
1997 layout: None,
1998 opsx: None,
1999 mcp: McpConfig::default(),
2000 };
2001
2002 save_config_to(&config_path, &original).unwrap();
2003 let loaded = load_config_file(&config_path).unwrap().unwrap();
2004 assert_eq!(original, loaded);
2005 }
2006
2007 #[test]
2010 fn parses_specs_section_with_populated_fields() {
2011 let tmp = TempDir::new().unwrap();
2012 let path = tmp.path().join("config.toml");
2013 write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
2014
2015 let config = load_config_file(&path).unwrap().unwrap();
2016 let specs = config.specs.unwrap();
2017 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
2018 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
2019 }
2020
2021 #[test]
2024 fn parses_logging_section_with_enabled() {
2025 let tmp = TempDir::new().unwrap();
2026 let path = tmp.path().join("config.toml");
2027 write_file(&path, "[logging]\nenabled = true\n");
2028
2029 let config = load_config_file(&path).unwrap().unwrap();
2030 let logging = config.logging.unwrap();
2031 assert!(logging.enabled);
2032 }
2033
2034 #[test]
2037 fn round_trip_with_specs_and_logging() {
2038 let tmp = TempDir::new().unwrap();
2039 let config_path = tmp.path().join("config.toml");
2040
2041 let original = PawConfig {
2042 specs: Some(SpecsConfig {
2043 dir: Some("specs".into()),
2044 spec_type: Some("openspec".into()),
2045 }),
2046 logging: Some(LoggingConfig { enabled: true }),
2047 ..Default::default()
2048 };
2049
2050 save_config_to(&config_path, &original).unwrap();
2051 let loaded = load_config_file(&config_path).unwrap().unwrap();
2052 assert_eq!(original, loaded);
2053 assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
2054 assert!(loaded.logging.unwrap().enabled);
2055 }
2056
2057 #[test]
2060 fn generated_default_config_is_valid_toml() {
2061 let raw = generate_default_config();
2062 let stripped: String = raw
2063 .lines()
2064 .filter(|line| !line.trim_start().starts_with('#'))
2065 .collect::<Vec<&str>>()
2066 .join("\n");
2067
2068 let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
2069 assert!(
2070 parsed.is_ok(),
2071 "generated config with comments stripped should be valid TOML, got: {:?}",
2072 parsed.unwrap_err()
2073 );
2074 }
2075
2076 #[test]
2079 fn branch_prefix_repo_overrides_global() {
2080 let tmp = TempDir::new().unwrap();
2081 let global_path = tmp.path().join("global").join("config.toml");
2082 let repo_root = tmp.path().join("repo");
2083 fs::create_dir_all(&repo_root).unwrap();
2084
2085 write_file(&global_path, "branch_prefix = \"feat/\"\n");
2086 write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
2087
2088 let config = load_config_from(&global_path, &repo_root).unwrap();
2089 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
2090 }
2091
2092 #[test]
2093 fn generated_default_config_contains_commented_examples() {
2094 let output = generate_default_config();
2095 assert!(
2096 output.contains("default_spec_cli"),
2097 "should contain default_spec_cli"
2098 );
2099 assert!(
2100 output.contains("branch_prefix"),
2101 "should contain branch_prefix"
2102 );
2103 assert!(output.contains("[specs]"), "should contain [specs]");
2104 assert!(output.contains("[logging]"), "should contain [logging]");
2105 assert!(output.contains("[broker]"), "should contain [broker]");
2106 }
2107
2108 #[test]
2111 fn broker_config_defaults() {
2112 let config = BrokerConfig::default();
2113 assert!(!config.enabled);
2114 assert_eq!(config.port, 9119);
2115 assert_eq!(config.bind, "127.0.0.1");
2116 }
2117
2118 #[test]
2119 fn broker_config_url() {
2120 let config = BrokerConfig::default();
2121 assert_eq!(config.url(), "http://127.0.0.1:9119");
2122
2123 let custom = BrokerConfig {
2124 enabled: true,
2125 port: 8080,
2126 bind: "0.0.0.0".to_string(),
2127 ..Default::default()
2128 };
2129 assert_eq!(custom.url(), "http://0.0.0.0:8080");
2130 }
2131
2132 #[test]
2133 fn empty_config_gets_broker_defaults() {
2134 let tmp = TempDir::new().unwrap();
2135 let path = tmp.path().join("config.toml");
2136 write_file(&path, "");
2137
2138 let config = load_config_file(&path).unwrap().unwrap();
2139 assert!(!config.broker.enabled);
2140 assert_eq!(config.broker.port, 9119);
2141 assert_eq!(config.broker.bind, "127.0.0.1");
2142 }
2143
2144 #[test]
2145 fn parses_full_broker_section() {
2146 let tmp = TempDir::new().unwrap();
2147 let path = tmp.path().join("config.toml");
2148 write_file(
2149 &path,
2150 "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
2151 );
2152
2153 let config = load_config_file(&path).unwrap().unwrap();
2154 assert!(config.broker.enabled);
2155 assert_eq!(config.broker.port, 8080);
2156 assert_eq!(config.broker.bind, "0.0.0.0");
2157 }
2158
2159 #[test]
2160 fn parses_partial_broker_section() {
2161 let tmp = TempDir::new().unwrap();
2162 let path = tmp.path().join("config.toml");
2163 write_file(&path, "[broker]\nenabled = true\n");
2164
2165 let config = load_config_file(&path).unwrap().unwrap();
2166 assert!(config.broker.enabled);
2167 assert_eq!(config.broker.port, 9119);
2168 assert_eq!(config.broker.bind, "127.0.0.1");
2169 }
2170
2171 #[test]
2174 fn supervisor_is_none_when_section_absent() {
2175 let tmp = TempDir::new().unwrap();
2176 let path = tmp.path().join("config.toml");
2177 write_file(&path, "default_cli = \"claude\"\n");
2178
2179 let config = load_config_file(&path).unwrap().unwrap();
2180 assert!(config.supervisor.is_none());
2181 }
2182
2183 #[test]
2184 fn parses_full_supervisor_section() {
2185 let tmp = TempDir::new().unwrap();
2186 let path = tmp.path().join("config.toml");
2187 write_file(
2188 &path,
2189 "[supervisor]\n\
2190 enabled = true\n\
2191 cli = \"claude\"\n\
2192 test_command = \"just check\"\n\
2193 agent_approval = \"full-auto\"\n",
2194 );
2195
2196 let config = load_config_file(&path).unwrap().unwrap();
2197 let supervisor = config.supervisor.unwrap();
2198 assert!(supervisor.enabled);
2199 assert_eq!(supervisor.cli.as_deref(), Some("claude"));
2200 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2201 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
2202 }
2203
2204 #[test]
2205 fn parses_partial_supervisor_section() {
2206 let tmp = TempDir::new().unwrap();
2207 let path = tmp.path().join("config.toml");
2208 write_file(&path, "[supervisor]\nenabled = true\n");
2209
2210 let config = load_config_file(&path).unwrap().unwrap();
2211 let supervisor = config.supervisor.unwrap();
2212 assert!(supervisor.enabled);
2213 assert_eq!(supervisor.cli, None);
2214 assert_eq!(supervisor.test_command, None);
2215 assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
2216 }
2217
2218 #[test]
2221 fn verify_on_commit_nudge_defaults_true_when_absent() {
2222 let tmp = TempDir::new().unwrap();
2223 let path = tmp.path().join("config.toml");
2224 write_file(&path, "[supervisor]\nenabled = true\n");
2225
2226 let config = load_config_file(&path).unwrap().unwrap();
2227 let supervisor = config.supervisor.unwrap();
2228 assert_eq!(
2229 supervisor.verify_on_commit_nudge, None,
2230 "an omitted field must deserialise as None"
2231 );
2232 assert!(
2233 supervisor.verify_on_commit_nudge_enabled(),
2234 "an unset verify_on_commit_nudge must resolve to true (default on)"
2235 );
2236 }
2237
2238 #[test]
2239 fn verify_on_commit_nudge_explicit_false_disables() {
2240 let tmp = TempDir::new().unwrap();
2241 let path = tmp.path().join("config.toml");
2242 write_file(
2243 &path,
2244 "[supervisor]\nenabled = true\nverify_on_commit_nudge = false\n",
2245 );
2246
2247 let config = load_config_file(&path).unwrap().unwrap();
2248 let supervisor = config.supervisor.unwrap();
2249 assert_eq!(supervisor.verify_on_commit_nudge, Some(false));
2250 assert!(
2251 !supervisor.verify_on_commit_nudge_enabled(),
2252 "an explicit `false` must disable the nudge"
2253 );
2254 }
2255
2256 #[test]
2257 fn verify_on_commit_nudge_explicit_true_enables() {
2258 let tmp = TempDir::new().unwrap();
2259 let path = tmp.path().join("config.toml");
2260 write_file(
2261 &path,
2262 "[supervisor]\nenabled = true\nverify_on_commit_nudge = true\n",
2263 );
2264
2265 let config = load_config_file(&path).unwrap().unwrap();
2266 let supervisor = config.supervisor.unwrap();
2267 assert_eq!(supervisor.verify_on_commit_nudge, Some(true));
2268 assert!(supervisor.verify_on_commit_nudge_enabled());
2269 }
2270
2271 #[test]
2272 fn rejects_invalid_approval_level() {
2273 let tmp = TempDir::new().unwrap();
2274 let path = tmp.path().join("config.toml");
2275 write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
2276
2277 let err = load_config_file(&path).unwrap_err();
2278 assert!(
2279 err.to_string().contains("yolo"),
2280 "error should mention invalid value, got: {err}"
2281 );
2282 }
2283
2284 #[test]
2285 fn supervisor_round_trips_through_save_and_load() {
2286 let tmp = TempDir::new().unwrap();
2287 let config_path = tmp.path().join("config.toml");
2288
2289 let original = PawConfig {
2290 supervisor: Some(SupervisorConfig {
2291 enabled: true,
2292 cli: Some("claude".into()),
2293 test_command: Some("just check".into()),
2294 lint_command: None,
2295 build_command: None,
2296 doc_build_command: None,
2297 doc_tool_command: None,
2298 spec_validate_command: None,
2299 fmt_check_command: None,
2300 security_audit_command: None,
2301 agent_approval: ApprovalLevel::FullAuto,
2302 auto_approve: None,
2303 conflict: ConflictConfig::default(),
2304 learnings: false,
2305 learnings_config: LearningsConfig::default(),
2306 common_dev_allowlist: CommonDevAllowlistConfig::default(),
2307 verify_on_commit_nudge: None,
2308 strict_branch_guard: None,
2309 auto_revert: None,
2310 manual_approvals_log: None,
2311 tell: TellConfig::default(),
2312 }),
2313 ..Default::default()
2314 };
2315
2316 save_config_to(&config_path, &original).unwrap();
2317 let loaded = load_config_file(&config_path).unwrap().unwrap();
2318 assert_eq!(loaded.supervisor, original.supervisor);
2319 }
2320
2321 #[test]
2324 fn manual_approvals_log_defaults_to_true_when_absent() {
2325 let tmp = TempDir::new().unwrap();
2327 let path = tmp.path().join("config.toml");
2328 write_file(&path, "[supervisor]\nenabled = true\n");
2329 let cfg = load_config_file(&path).unwrap().unwrap();
2330 let sup = cfg.supervisor.unwrap();
2331 assert_eq!(sup.manual_approvals_log, None);
2332 assert!(
2333 sup.manual_approvals_log_enabled(),
2334 "absent field must resolve to true"
2335 );
2336 }
2337
2338 #[test]
2339 fn manual_approvals_log_explicit_false_opts_out() {
2340 let tmp = TempDir::new().unwrap();
2341 let path = tmp.path().join("config.toml");
2342 write_file(
2343 &path,
2344 "[supervisor]\nenabled = true\nmanual_approvals_log = false\n",
2345 );
2346 let cfg = load_config_file(&path).unwrap().unwrap();
2347 let sup = cfg.supervisor.unwrap();
2348 assert_eq!(sup.manual_approvals_log, Some(false));
2349 assert!(!sup.manual_approvals_log_enabled());
2350 }
2351
2352 #[test]
2353 fn pre_v050_config_parses_with_manual_approvals_log_absent() {
2354 let tmp = TempDir::new().unwrap();
2357 let path = tmp.path().join("config.toml");
2358 write_file(
2359 &path,
2360 "[supervisor]\nenabled = true\ncli = \"claude\"\nlearnings = true\n",
2361 );
2362 let cfg = load_config_file(&path).unwrap().unwrap();
2363 let sup = cfg.supervisor.unwrap();
2364 assert_eq!(sup.manual_approvals_log, None);
2365 assert!(sup.manual_approvals_log_enabled());
2366 }
2367
2368 #[test]
2371 fn strict_branch_guard_defaults_to_true_and_honours_opt_out() {
2372 let on = TempDir::new().unwrap();
2374 let on_path = on.path().join("config.toml");
2375 write_file(&on_path, "[supervisor]\nenabled = true\n");
2376 let cfg = load_config_file(&on_path).unwrap().unwrap();
2377 let sup = cfg.supervisor.unwrap();
2378 assert_eq!(sup.strict_branch_guard, None);
2379 assert!(sup.strict_branch_guard(), "default must resolve to true");
2380
2381 let off = TempDir::new().unwrap();
2383 let off_path = off.path().join("config.toml");
2384 write_file(
2385 &off_path,
2386 "[supervisor]\nenabled = true\nstrict_branch_guard = false\n",
2387 );
2388 let cfg = load_config_file(&off_path).unwrap().unwrap();
2389 let sup = cfg.supervisor.unwrap();
2390 assert_eq!(sup.strict_branch_guard, Some(false));
2391 assert!(!sup.strict_branch_guard());
2392 }
2393
2394 #[test]
2395 fn gate_command_fields_default_to_none() {
2396 let tmp = TempDir::new().unwrap();
2397 let path = tmp.path().join("config.toml");
2398 write_file(&path, "[supervisor]\nenabled = true\n");
2399
2400 let config = load_config_file(&path).unwrap().unwrap();
2401 let supervisor = config.supervisor.unwrap();
2402 assert_eq!(supervisor.test_command, None);
2403 assert_eq!(supervisor.lint_command, None);
2404 assert_eq!(supervisor.build_command, None);
2405 assert_eq!(supervisor.doc_build_command, None);
2406 assert_eq!(supervisor.doc_tool_command, None);
2407 assert_eq!(supervisor.spec_validate_command, None);
2408 assert_eq!(supervisor.fmt_check_command, None);
2409 assert_eq!(supervisor.security_audit_command, None);
2410 }
2411
2412 #[test]
2413 fn gate_command_fields_round_trip() {
2414 let tmp = TempDir::new().unwrap();
2415 let config_path = tmp.path().join("config.toml");
2416
2417 let original = PawConfig {
2418 supervisor: Some(SupervisorConfig {
2419 enabled: true,
2420 cli: Some("claude".into()),
2421 test_command: Some("just check".into()),
2422 lint_command: Some("cargo clippy -- -D warnings".into()),
2423 build_command: Some("cargo build".into()),
2424 doc_build_command: Some("mdbook build docs/".into()),
2425 doc_tool_command: Some("cargo doc --no-deps".into()),
2426 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict".into()),
2427 fmt_check_command: Some("cargo fmt --check".into()),
2428 security_audit_command: Some("cargo audit".into()),
2429 ..Default::default()
2430 }),
2431 ..Default::default()
2432 };
2433
2434 save_config_to(&config_path, &original).unwrap();
2435 let loaded = load_config_file(&config_path).unwrap().unwrap();
2436 assert_eq!(loaded.supervisor, original.supervisor);
2437 }
2438
2439 #[test]
2440 fn gate_command_fields_omit_from_toml_when_none() {
2441 let supervisor = SupervisorConfig {
2442 enabled: true,
2443 test_command: None,
2444 lint_command: None,
2445 build_command: None,
2446 doc_build_command: None,
2447 doc_tool_command: None,
2448 spec_validate_command: None,
2449 fmt_check_command: None,
2450 security_audit_command: None,
2451 ..Default::default()
2452 };
2453 let serialized = toml::to_string_pretty(&supervisor).unwrap();
2454 for key in [
2455 "test_command",
2456 "lint_command",
2457 "build_command",
2458 "doc_build_command",
2459 "doc_tool_command",
2460 "spec_validate_command",
2461 "fmt_check_command",
2462 "security_audit_command",
2463 ] {
2464 assert!(
2465 !serialized.contains(key),
2466 "TOML serialised with None gate fields should omit `{key}`; got:\n{serialized}",
2467 );
2468 }
2469 }
2470
2471 #[test]
2474 fn doc_tool_command_default_none() {
2475 let tmp = TempDir::new().unwrap();
2476 let path = tmp.path().join("config.toml");
2477 write_file(&path, "[supervisor]\nenabled = true\n");
2478
2479 let config = load_config_file(&path).unwrap().unwrap();
2480 let supervisor = config.supervisor.unwrap();
2481 assert_eq!(supervisor.doc_tool_command, None);
2482 }
2483
2484 #[test]
2485 fn doc_tool_command_explicit_value_preserved() {
2486 let tmp = TempDir::new().unwrap();
2487 let path = tmp.path().join("config.toml");
2488 write_file(
2489 &path,
2490 "[supervisor]\n\
2491 enabled = true\n\
2492 doc_tool_command = \"sphinx-build -W docs docs/_build\"\n",
2493 );
2494
2495 let config = load_config_file(&path).unwrap().unwrap();
2496 let supervisor = config.supervisor.unwrap();
2497 assert_eq!(
2498 supervisor.doc_tool_command.as_deref(),
2499 Some("sphinx-build -W docs docs/_build"),
2500 "explicit doc_tool_command value (including all whitespace) must be preserved verbatim",
2501 );
2502 }
2503
2504 #[test]
2505 fn doc_tool_command_v0_5_config_parses_without_field() {
2506 let tmp = TempDir::new().unwrap();
2509 let path = tmp.path().join("config.toml");
2510 write_file(
2511 &path,
2512 "[supervisor]\n\
2513 enabled = true\n\
2514 test_command = \"just check\"\n\
2515 lint_command = \"cargo clippy -- -D warnings\"\n\
2516 build_command = \"cargo build\"\n\
2517 doc_build_command = \"mdbook build docs/\"\n",
2518 );
2519
2520 let config = load_config_file(&path).unwrap().unwrap();
2521 let supervisor = config.supervisor.unwrap();
2522 assert_eq!(supervisor.doc_tool_command, None);
2523 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2524 }
2525
2526 #[test]
2527 fn doc_tool_command_flows_into_gate_commands() {
2528 let supervisor = SupervisorConfig {
2529 doc_tool_command: Some("javadoc -d docs/api src/**/*.java".into()),
2530 ..Default::default()
2531 };
2532 let gates = supervisor.gate_commands();
2533 assert_eq!(
2534 gates.doc_tool_command,
2535 Some("javadoc -d docs/api src/**/*.java"),
2536 );
2537 }
2538
2539 #[test]
2542 fn supervisor_common_dev_allowlist_defaults_when_section_absent() {
2543 let tmp = TempDir::new().unwrap();
2544 let path = tmp.path().join("config.toml");
2545 write_file(&path, "[supervisor]\nenabled = true\n");
2546
2547 let config = load_config_file(&path).unwrap().unwrap();
2548 let supervisor = config.supervisor.unwrap();
2549 assert!(supervisor.common_dev_allowlist.enabled);
2550 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2551 }
2552
2553 #[test]
2554 fn supervisor_common_dev_allowlist_disabled_opt_out() {
2555 let tmp = TempDir::new().unwrap();
2556 let path = tmp.path().join("config.toml");
2557 write_file(
2558 &path,
2559 "[supervisor]\nenabled = true\n\
2560 [supervisor.common_dev_allowlist]\nenabled = false\n",
2561 );
2562
2563 let config = load_config_file(&path).unwrap().unwrap();
2564 let supervisor = config.supervisor.unwrap();
2565 assert!(!supervisor.common_dev_allowlist.enabled);
2566 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2568 }
2569
2570 #[test]
2571 fn supervisor_common_dev_allowlist_extra_parsed() {
2572 let tmp = TempDir::new().unwrap();
2573 let path = tmp.path().join("config.toml");
2574 write_file(
2575 &path,
2576 "[supervisor]\nenabled = true\n\
2577 [supervisor.common_dev_allowlist]\nextra = [\"pnpm test\", \"deno fmt\"]\n",
2578 );
2579
2580 let config = load_config_file(&path).unwrap().unwrap();
2581 let supervisor = config.supervisor.unwrap();
2582 assert_eq!(
2583 supervisor.common_dev_allowlist.extra,
2584 vec!["pnpm test".to_string(), "deno fmt".to_string()],
2585 );
2586 assert!(supervisor.common_dev_allowlist.enabled);
2588 }
2589
2590 #[test]
2591 fn supervisor_common_dev_allowlist_round_trips_through_save_and_load() {
2592 let tmp = TempDir::new().unwrap();
2593 let config_path = tmp.path().join("config.toml");
2594
2595 let original = PawConfig {
2596 supervisor: Some(SupervisorConfig {
2597 enabled: true,
2598 common_dev_allowlist: CommonDevAllowlistConfig {
2599 enabled: false,
2600 extra: vec!["pnpm test".into(), "uv pip install".into()],
2601 },
2602 ..Default::default()
2603 }),
2604 ..Default::default()
2605 };
2606
2607 save_config_to(&config_path, &original).unwrap();
2608 let loaded = load_config_file(&config_path).unwrap().unwrap();
2609 assert_eq!(loaded.supervisor, original.supervisor);
2610 }
2611
2612 #[test]
2613 fn existing_pre_v05_config_loads_with_default_common_dev_allowlist() {
2614 let tmp = TempDir::new().unwrap();
2617 let path = tmp.path().join("config.toml");
2618 write_file(
2619 &path,
2620 "[supervisor]\n\
2621 enabled = true\n\
2622 cli = \"claude\"\n\
2623 test_command = \"just check\"\n\
2624 agent_approval = \"auto\"\n\
2625 [supervisor.conflict]\n\
2626 window_seconds = 60\n",
2627 );
2628
2629 let config = load_config_file(&path).unwrap().unwrap();
2630 let supervisor = config.supervisor.unwrap();
2631 assert!(supervisor.common_dev_allowlist.enabled);
2632 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2633 }
2634
2635 #[test]
2636 fn generated_default_config_template_contains_common_dev_allowlist_section() {
2637 let template = generate_default_config();
2638 assert!(
2639 template.contains("[supervisor.common_dev_allowlist]"),
2640 "default template should document the new sub-table",
2641 );
2642 assert!(
2643 template.contains("enabled = true"),
2644 "template should show the enabled default",
2645 );
2646 assert!(
2647 template.contains("extra ="),
2648 "template should illustrate the extra field",
2649 );
2650 }
2651
2652 #[test]
2655 fn learnings_defaults_to_false_when_supervisor_section_absent_field() {
2656 let tmp = TempDir::new().unwrap();
2658 let path = tmp.path().join("config.toml");
2659 write_file(&path, "[supervisor]\nenabled = true\n");
2660
2661 let config = load_config_file(&path).unwrap().unwrap();
2662 let supervisor = config.supervisor.unwrap();
2663 assert!(!supervisor.learnings);
2664 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2665 }
2666
2667 #[test]
2668 fn learnings_true_loads() {
2669 let tmp = TempDir::new().unwrap();
2670 let path = tmp.path().join("config.toml");
2671 write_file(&path, "[supervisor]\nenabled = true\nlearnings = true\n");
2672
2673 let config = load_config_file(&path).unwrap().unwrap();
2674 let supervisor = config.supervisor.unwrap();
2675 assert!(supervisor.learnings);
2676 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2678 }
2679
2680 #[test]
2681 fn learnings_config_custom_flush_interval_is_honoured() {
2682 let tmp = TempDir::new().unwrap();
2683 let path = tmp.path().join("config.toml");
2684 write_file(
2685 &path,
2686 "[supervisor]\n\
2687 enabled = true\n\
2688 learnings = true\n\
2689 [supervisor.learnings_config]\n\
2690 flush_interval_seconds = 30\n",
2691 );
2692
2693 let config = load_config_file(&path).unwrap().unwrap();
2694 let supervisor = config.supervisor.unwrap();
2695 assert!(supervisor.learnings);
2696 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 30);
2697 }
2698
2699 #[test]
2700 fn learnings_config_defaults_when_table_absent() {
2701 let cfg = LearningsConfig::default();
2703 assert_eq!(cfg.flush_interval_seconds, 60);
2704 }
2705
2706 #[test]
2707 fn pre_v050_config_loads_with_learnings_false() {
2708 let tmp = TempDir::new().unwrap();
2712 let path = tmp.path().join("config.toml");
2713 write_file(
2714 &path,
2715 "default_cli = \"claude\"\n\
2716 [supervisor]\n\
2717 enabled = true\n\
2718 agent_approval = \"auto\"\n",
2719 );
2720
2721 let config = load_config_file(&path).unwrap().unwrap();
2722 let supervisor = config.supervisor.unwrap();
2723 assert!(!supervisor.learnings);
2724 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2725 }
2726
2727 #[test]
2728 fn learnings_round_trips_through_save_and_load() {
2729 let tmp = TempDir::new().unwrap();
2730 let config_path = tmp.path().join("config.toml");
2731
2732 let original = PawConfig {
2733 supervisor: Some(SupervisorConfig {
2734 enabled: true,
2735 learnings: true,
2736 learnings_config: LearningsConfig {
2737 flush_interval_seconds: 90,
2738 broker_publish: BrokerPublish::ForceOff,
2739 },
2740 ..Default::default()
2741 }),
2742 ..Default::default()
2743 };
2744
2745 save_config_to(&config_path, &original).unwrap();
2746 let loaded = load_config_file(&config_path).unwrap().unwrap();
2747 assert_eq!(loaded.supervisor, original.supervisor);
2748 let supervisor = loaded.supervisor.unwrap();
2749 assert!(supervisor.learnings);
2750 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 90);
2751 }
2752
2753 #[test]
2754 fn existing_v030_config_loads_without_supervisor() {
2755 let tmp = TempDir::new().unwrap();
2756 let path = tmp.path().join("config.toml");
2757 write_file(
2758 &path,
2759 "default_cli = \"claude\"\n\
2760 mouse = true\n\
2761 [broker]\n\
2762 enabled = true\n\
2763 [logging]\n\
2764 enabled = false\n",
2765 );
2766
2767 let config = load_config_file(&path).unwrap().unwrap();
2768 assert_eq!(config.default_cli.as_deref(), Some("claude"));
2769 assert!(config.broker.enabled);
2770 assert!(config.supervisor.is_none());
2771 }
2772
2773 #[test]
2774 fn generated_default_config_contains_commented_supervisor_section() {
2775 let output = generate_default_config();
2776 assert!(output.contains("[supervisor]"));
2777 assert!(output.contains("enabled"));
2778 assert!(output.contains("test_command"));
2779 assert!(output.contains("agent_approval"));
2780 }
2781
2782 #[test]
2785 fn dashboard_config_defaults_to_disabled() {
2786 let config = DashboardConfig::default();
2787 assert!(!config.show_message_log);
2788 }
2789
2790 #[test]
2791 fn parses_dashboard_section_with_show_message_log() {
2792 let tmp = TempDir::new().unwrap();
2793 let path = tmp.path().join("config.toml");
2794 write_file(&path, "[dashboard]\nshow_message_log = true\n");
2795
2796 let config = load_config_file(&path).unwrap().unwrap();
2797 let dashboard = config.dashboard.unwrap();
2798 assert!(dashboard.show_message_log);
2799 }
2800
2801 #[test]
2802 fn dashboard_is_none_when_section_absent() {
2803 let tmp = TempDir::new().unwrap();
2804 let path = tmp.path().join("config.toml");
2805 write_file(&path, "default_cli = \"claude\"\n");
2806
2807 let config = load_config_file(&path).unwrap().unwrap();
2808 assert!(config.dashboard.is_none());
2809 }
2810
2811 #[test]
2812 fn dashboard_merge_repo_wins() {
2813 let tmp = TempDir::new().unwrap();
2814 let global_path = tmp.path().join("global").join("config.toml");
2815 let repo_root = tmp.path().join("repo");
2816 fs::create_dir_all(&repo_root).unwrap();
2817
2818 write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
2819 write_file(
2820 &repo_config_path(&repo_root),
2821 "[dashboard]\nshow_message_log = true\n",
2822 );
2823
2824 let config = load_config_from(&global_path, &repo_root).unwrap();
2825 let dashboard = config.dashboard.unwrap();
2826 assert!(dashboard.show_message_log);
2827 }
2828
2829 #[test]
2830 fn dashboard_round_trip_through_save_and_load() {
2831 let tmp = TempDir::new().unwrap();
2832 let config_path = tmp.path().join("config.toml");
2833
2834 let original = PawConfig {
2835 dashboard: Some(DashboardConfig {
2836 show_message_log: true,
2837 ..Default::default()
2838 }),
2839 ..Default::default()
2840 };
2841
2842 save_config_to(&config_path, &original).unwrap();
2843 let loaded = load_config_file(&config_path).unwrap().unwrap();
2844 assert_eq!(loaded.dashboard, original.dashboard);
2845 assert!(loaded.dashboard.unwrap().show_message_log);
2846 }
2847
2848 #[test]
2851 fn broker_log_config_defaults() {
2852 let cfg = BrokerLogConfig::default();
2854 assert_eq!(cfg.max_messages, 500);
2855 assert!(cfg.default_visible);
2856 }
2857
2858 #[test]
2859 fn dashboard_config_default_includes_broker_log_defaults() {
2860 let cfg = DashboardConfig::default();
2864 assert_eq!(cfg.broker_log.max_messages, 500);
2865 assert!(cfg.broker_log.default_visible);
2866 }
2867
2868 #[test]
2869 fn parses_broker_log_section_with_explicit_overrides() {
2870 let tmp = TempDir::new().unwrap();
2872 let path = tmp.path().join("config.toml");
2873 write_file(
2874 &path,
2875 "[dashboard.broker_log]\nmax_messages = 100\ndefault_visible = false\n",
2876 );
2877
2878 let config = load_config_file(&path).unwrap().unwrap();
2879 let dashboard = config.dashboard.unwrap();
2880 assert_eq!(dashboard.broker_log.max_messages, 100);
2881 assert!(!dashboard.broker_log.default_visible);
2882 }
2883
2884 #[test]
2885 fn broker_log_partial_section_fills_remaining_defaults() {
2886 let tmp = TempDir::new().unwrap();
2890 let path = tmp.path().join("config.toml");
2891 write_file(&path, "[dashboard.broker_log]\nmax_messages = 42\n");
2892
2893 let config = load_config_file(&path).unwrap().unwrap();
2894 let broker_log = config.dashboard.unwrap().broker_log;
2895 assert_eq!(broker_log.max_messages, 42);
2896 assert!(
2897 broker_log.default_visible,
2898 "default_visible must fall back to true when omitted"
2899 );
2900 }
2901
2902 #[test]
2903 fn v050_dashboard_section_without_broker_log_still_parses() {
2904 let tmp = TempDir::new().unwrap();
2907 let path = tmp.path().join("config.toml");
2908 write_file(&path, "[dashboard]\nshow_message_log = true\n");
2909
2910 let config = load_config_file(&path).unwrap().unwrap();
2911 let dashboard = config.dashboard.unwrap();
2912 assert!(dashboard.show_message_log);
2913 assert_eq!(dashboard.broker_log, BrokerLogConfig::default());
2914 }
2915
2916 #[test]
2917 fn broker_log_round_trips_through_save_and_load() {
2918 let tmp = TempDir::new().unwrap();
2919 let config_path = tmp.path().join("config.toml");
2920
2921 let original = PawConfig {
2922 dashboard: Some(DashboardConfig {
2923 show_message_log: false,
2924 broker_log: BrokerLogConfig {
2925 max_messages: 250,
2926 default_visible: false,
2927 },
2928 }),
2929 ..Default::default()
2930 };
2931
2932 save_config_to(&config_path, &original).unwrap();
2933 let loaded = load_config_file(&config_path).unwrap().unwrap();
2934 assert_eq!(loaded.dashboard, original.dashboard);
2935 }
2936
2937 #[test]
2938 fn get_dashboard_returns_none_when_not_configured() {
2939 let config = PawConfig::default();
2940 assert!(config.get_dashboard().is_none());
2941 }
2942
2943 #[test]
2944 fn get_dashboard_returns_config_when_present() {
2945 let config = PawConfig {
2946 dashboard: Some(DashboardConfig {
2947 show_message_log: true,
2948 ..Default::default()
2949 }),
2950 ..Default::default()
2951 };
2952 let dashboard = config.get_dashboard().unwrap();
2953 assert!(dashboard.show_message_log);
2954 }
2955
2956 #[test]
2959 fn approval_flags_claude_full_auto() {
2960 assert_eq!(
2961 approval_flags("claude", &ApprovalLevel::FullAuto),
2962 "--dangerously-skip-permissions"
2963 );
2964 }
2965
2966 #[test]
2967 fn approval_flags_codex_auto() {
2968 assert_eq!(
2969 approval_flags("codex", &ApprovalLevel::Auto),
2970 "--approval-mode=auto-edit"
2971 );
2972 }
2973
2974 #[test]
2975 fn approval_flags_codex_full_auto() {
2976 assert_eq!(
2977 approval_flags("codex", &ApprovalLevel::FullAuto),
2978 "--approval-mode=full-auto"
2979 );
2980 }
2981
2982 #[test]
2983 fn approval_flags_unknown_cli_is_empty() {
2984 assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
2985 }
2986
2987 #[test]
2988 fn approval_flags_manual_is_empty() {
2989 assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
2990 assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
2991 }
2992
2993 #[test]
2994 fn approval_flags_is_deterministic() {
2995 let first = approval_flags("claude", &ApprovalLevel::FullAuto);
2996 let second = approval_flags("claude", &ApprovalLevel::FullAuto);
2997 assert_eq!(first, second);
2998 }
2999
3000 #[test]
3001 fn supervisor_merge_repo_wins() {
3002 let tmp = TempDir::new().unwrap();
3003 let global_path = tmp.path().join("global").join("config.toml");
3004 let repo_root = tmp.path().join("repo");
3005 fs::create_dir_all(&repo_root).unwrap();
3006
3007 write_file(
3008 &global_path,
3009 "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
3010 );
3011 write_file(
3012 &repo_config_path(&repo_root),
3013 "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
3014 );
3015
3016 let config = load_config_from(&global_path, &repo_root).unwrap();
3017 let supervisor = config.supervisor.unwrap();
3018 assert!(supervisor.enabled);
3019 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
3020 }
3021
3022 #[test]
3023 fn broker_config_round_trip() {
3024 let tmp = TempDir::new().unwrap();
3025 let config_path = tmp.path().join("config.toml");
3026
3027 let original = PawConfig {
3028 broker: BrokerConfig {
3029 enabled: true,
3030 port: 9200,
3031 bind: "127.0.0.1".to_string(),
3032 ..Default::default()
3033 },
3034 ..Default::default()
3035 };
3036
3037 save_config_to(&config_path, &original).unwrap();
3038 let loaded = load_config_file(&config_path).unwrap().unwrap();
3039 assert_eq!(loaded.broker.enabled, original.broker.enabled);
3040 assert_eq!(loaded.broker.port, original.broker.port);
3041 assert_eq!(loaded.broker.bind, original.broker.bind);
3042 }
3043
3044 #[test]
3047 fn auto_approve_defaults_match_spec() {
3048 let cfg = AutoApproveConfig::default();
3049 assert!(cfg.enabled, "enabled defaults to true");
3050 assert!(
3051 cfg.safe_commands.is_empty(),
3052 "safe_commands defaults to empty"
3053 );
3054 assert_eq!(cfg.stall_threshold_seconds, 30);
3055 assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
3056 }
3057
3058 #[test]
3059 fn auto_approve_section_absent_keeps_supervisor_simple() {
3060 let tmp = TempDir::new().unwrap();
3061 let path = tmp.path().join("config.toml");
3062 write_file(&path, "[supervisor]\nenabled = true\n");
3063 let config = load_config_file(&path).unwrap().unwrap();
3064 let supervisor = config.supervisor.unwrap();
3065 assert!(supervisor.auto_approve.is_none());
3066 }
3067
3068 #[test]
3069 fn auto_approve_section_parses_full_body() {
3070 let tmp = TempDir::new().unwrap();
3071 let path = tmp.path().join("config.toml");
3072 write_file(
3073 &path,
3074 "[supervisor]\n\
3075 enabled = true\n\
3076 [supervisor.auto_approve]\n\
3077 enabled = false\n\
3078 safe_commands = [\"just smoke\"]\n\
3079 stall_threshold_seconds = 60\n\
3080 approval_level = \"conservative\"\n",
3081 );
3082 let config = load_config_file(&path).unwrap().unwrap();
3083 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3084 assert!(!aa.enabled);
3085 assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
3086 assert_eq!(aa.stall_threshold_seconds, 60);
3087 assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
3088 }
3089
3090 #[test]
3091 fn auto_approve_enabled_defaults_to_true_when_omitted() {
3092 let tmp = TempDir::new().unwrap();
3093 let path = tmp.path().join("config.toml");
3094 write_file(
3095 &path,
3096 "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
3097 );
3098 let config = load_config_file(&path).unwrap().unwrap();
3099 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3100 assert!(aa.enabled, "enabled should default to true");
3101 }
3102
3103 #[test]
3104 fn auto_approve_off_preset_forces_disabled() {
3105 let cfg = AutoApproveConfig {
3106 enabled: true,
3107 approval_level: ApprovalLevelPreset::Off,
3108 ..AutoApproveConfig::default()
3109 };
3110 let resolved = cfg.resolved();
3111 assert!(!resolved.enabled, "Off preset must force enabled = false");
3112 }
3113
3114 #[test]
3117 fn watcher_ttl_defaults_to_sixty_when_absent() {
3118 let cfg = WatcherConfig::default();
3119 assert_eq!(cfg.republish_working_ttl_seconds(), 60);
3120 }
3121
3122 #[test]
3123 fn watcher_ttl_zero_disables() {
3124 let cfg = WatcherConfig {
3125 republish_working_ttl_seconds: Some(0),
3126 };
3127 assert_eq!(cfg.republish_working_ttl_seconds(), 0);
3128 }
3129
3130 #[test]
3131 fn watcher_ttl_below_floor_clamps_to_five() {
3132 let cfg = WatcherConfig {
3133 republish_working_ttl_seconds: Some(2),
3134 };
3135 assert_eq!(
3136 cfg.republish_working_ttl_seconds(),
3137 WatcherConfig::MIN_REPUBLISH_TTL_SECONDS
3138 );
3139 }
3140
3141 #[test]
3142 fn watcher_ttl_explicit_non_zero_is_preserved() {
3143 let cfg = WatcherConfig {
3144 republish_working_ttl_seconds: Some(120),
3145 };
3146 assert_eq!(cfg.republish_working_ttl_seconds(), 120);
3147 }
3148
3149 #[test]
3150 fn watcher_ttl_parses_from_broker_table() {
3151 let tmp = TempDir::new().unwrap();
3152 let path = tmp.path().join("config.toml");
3153 write_file(
3154 &path,
3155 "[broker]\nenabled = true\n[broker.watcher]\nrepublish_working_ttl_seconds = 0\n",
3156 );
3157 let config = load_config_file(&path).unwrap().unwrap();
3158 assert_eq!(config.broker.watcher.republish_working_ttl_seconds, Some(0));
3159 assert_eq!(config.broker.watcher.republish_working_ttl_seconds(), 0);
3160 }
3161
3162 #[test]
3163 fn approve_worktree_writes_defaults_to_true_when_absent() {
3164 let cfg = AutoApproveConfig::default();
3166 assert!(
3167 cfg.approve_worktree_writes(),
3168 "absent approve_worktree_writes must resolve to true"
3169 );
3170 }
3171
3172 #[test]
3173 fn approve_worktree_writes_explicit_false_resolves_false() {
3174 let cfg = AutoApproveConfig {
3176 approve_worktree_writes: Some(false),
3177 ..AutoApproveConfig::default()
3178 };
3179 assert!(!cfg.approve_worktree_writes());
3180 }
3181
3182 #[test]
3183 fn approve_worktree_writes_parses_from_toml() {
3184 let tmp = TempDir::new().unwrap();
3185 let path = tmp.path().join("config.toml");
3186 write_file(
3187 &path,
3188 "[supervisor]\nenabled = true\n[supervisor.auto_approve]\napprove_worktree_writes = false\n",
3189 );
3190 let config = load_config_file(&path).unwrap().unwrap();
3191 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3192 assert_eq!(aa.approve_worktree_writes, Some(false));
3193 assert!(!aa.approve_worktree_writes());
3194 }
3195
3196 #[test]
3197 fn auto_approve_threshold_floor_clamps() {
3198 let cfg = AutoApproveConfig {
3199 stall_threshold_seconds: 0,
3200 ..AutoApproveConfig::default()
3201 };
3202 let resolved = cfg.resolved();
3203 assert_eq!(
3204 resolved.stall_threshold_seconds,
3205 AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
3206 );
3207 }
3208
3209 #[test]
3210 fn auto_approve_safe_preset_keeps_defaults() {
3211 let cfg = AutoApproveConfig {
3212 approval_level: ApprovalLevelPreset::Safe,
3213 ..AutoApproveConfig::default()
3214 };
3215 let wl = cfg.effective_whitelist();
3216 assert!(wl.iter().any(|c| c == "cargo test"));
3217 assert!(wl.iter().any(|c| c == "git push"));
3218 assert!(wl.iter().any(|c| c.starts_with("curl")));
3219 }
3220
3221 #[test]
3222 fn auto_approve_conservative_drops_push_and_curl() {
3223 let cfg = AutoApproveConfig {
3224 approval_level: ApprovalLevelPreset::Conservative,
3225 ..AutoApproveConfig::default()
3226 };
3227 let wl = cfg.effective_whitelist();
3228 assert!(wl.iter().any(|c| c == "cargo test"));
3229 assert!(
3230 !wl.iter().any(|c| c.starts_with("git push")),
3231 "conservative drops git push"
3232 );
3233 assert!(
3234 !wl.iter().any(|c| c.starts_with("curl")),
3235 "conservative drops curl"
3236 );
3237 }
3238
3239 #[test]
3240 fn auto_approve_extras_are_unioned_with_defaults() {
3241 let cfg = AutoApproveConfig {
3242 safe_commands: vec!["just lint".to_string(), "just test".to_string()],
3243 ..AutoApproveConfig::default()
3244 };
3245 let wl = cfg.effective_whitelist();
3246 assert!(wl.iter().any(|c| c == "cargo fmt"));
3247 assert!(wl.iter().any(|c| c == "just lint"));
3248 assert!(wl.iter().any(|c| c == "just test"));
3249 }
3250
3251 #[test]
3252 fn auto_approve_empty_extras_keep_defaults() {
3253 let cfg = AutoApproveConfig::default();
3254 let wl = cfg.effective_whitelist();
3255 assert!(wl.iter().any(|c| c == "cargo test"));
3256 }
3257
3258 #[test]
3265 fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
3266 use crate::supervisor::auto_approve::is_safe_command;
3267
3268 let tmp = TempDir::new().unwrap();
3271 let extras_path = tmp.path().join("extras.toml");
3272 write_file(
3273 &extras_path,
3274 "[supervisor]\n\
3275 enabled = true\n\
3276 [supervisor.auto_approve]\n\
3277 safe_commands = [\"just smoke\"]\n",
3278 );
3279 let extras_config = load_config_file(&extras_path).unwrap().unwrap();
3280 let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
3281 let extras_whitelist = extras_aa.effective_whitelist();
3282 assert!(
3283 is_safe_command("just smoke -v", &extras_whitelist),
3284 "TOML extra `just smoke` must accept `just smoke -v`"
3285 );
3286 assert!(
3288 is_safe_command("cargo test", &extras_whitelist),
3289 "extras must not displace built-in defaults"
3290 );
3291
3292 let empty_path = tmp.path().join("empty.toml");
3295 write_file(
3296 &empty_path,
3297 "[supervisor]\n\
3298 enabled = true\n\
3299 [supervisor.auto_approve]\n\
3300 safe_commands = []\n",
3301 );
3302 let empty_config = load_config_file(&empty_path).unwrap().unwrap();
3303 let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
3304 let empty_whitelist = empty_aa.effective_whitelist();
3305 assert!(
3306 is_safe_command("cargo test", &empty_whitelist),
3307 "empty safe_commands must keep built-in defaults"
3308 );
3309 assert!(
3310 is_safe_command("cargo fmt --check", &empty_whitelist),
3311 "empty safe_commands must keep `cargo fmt` default"
3312 );
3313 assert!(
3315 !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
3316 "empty safe_commands must not whitelist arbitrary commands"
3317 );
3318 }
3319
3320 #[test]
3323 fn conflict_config_defaults_match_spec() {
3324 let cfg = ConflictConfig::default();
3325 assert_eq!(cfg.window_seconds, 120);
3326 assert!(cfg.warn_on_intent_overlap);
3327 assert!(cfg.escalate_on_violation);
3328 }
3329
3330 #[test]
3331 fn supervisor_with_no_conflict_section_loads_defaults() {
3332 let tmp = TempDir::new().unwrap();
3333 let path = tmp.path().join("config.toml");
3334 write_file(&path, "[supervisor]\nenabled = true\n");
3335 let supervisor = load_config_file(&path)
3336 .unwrap()
3337 .unwrap()
3338 .supervisor
3339 .unwrap();
3340 assert_eq!(supervisor.conflict.window_seconds, 120);
3341 assert!(supervisor.conflict.warn_on_intent_overlap);
3342 assert!(supervisor.conflict.escalate_on_violation);
3343 }
3344
3345 #[test]
3346 fn conflict_section_with_all_fields_overrides_defaults() {
3347 let tmp = TempDir::new().unwrap();
3348 let path = tmp.path().join("config.toml");
3349 write_file(
3350 &path,
3351 "[supervisor]\n\
3352 enabled = true\n\
3353 [supervisor.conflict]\n\
3354 window_seconds = 300\n\
3355 warn_on_intent_overlap = false\n\
3356 escalate_on_violation = false\n",
3357 );
3358 let conflict = load_config_file(&path)
3359 .unwrap()
3360 .unwrap()
3361 .supervisor
3362 .unwrap()
3363 .conflict;
3364 assert_eq!(conflict.window_seconds, 300);
3365 assert!(!conflict.warn_on_intent_overlap);
3366 assert!(!conflict.escalate_on_violation);
3367 }
3368
3369 #[test]
3370 fn conflict_section_with_partial_fields_keeps_other_defaults() {
3371 let tmp = TempDir::new().unwrap();
3372 let path = tmp.path().join("config.toml");
3373 write_file(
3374 &path,
3375 "[supervisor]\n[supervisor.conflict]\nwindow_seconds = 60\n",
3376 );
3377 let conflict = load_config_file(&path)
3378 .unwrap()
3379 .unwrap()
3380 .supervisor
3381 .unwrap()
3382 .conflict;
3383 assert_eq!(conflict.window_seconds, 60);
3384 assert!(conflict.warn_on_intent_overlap);
3385 assert!(conflict.escalate_on_violation);
3386 }
3387
3388 #[test]
3389 fn pre_v05_config_without_conflict_section_loads() {
3390 let tmp = TempDir::new().unwrap();
3391 let path = tmp.path().join("config.toml");
3392 write_file(
3394 &path,
3395 "default_cli = \"claude\"\n\
3396 [supervisor]\n\
3397 enabled = true\n\
3398 agent_approval = \"auto\"\n",
3399 );
3400 let config = load_config_file(&path).unwrap().unwrap();
3401 let supervisor = config.supervisor.unwrap();
3402 assert!(supervisor.enabled);
3403 assert_eq!(supervisor.conflict, ConflictConfig::default());
3405 }
3406
3407 #[test]
3408 fn conflict_config_round_trips_through_save_and_load() {
3409 let tmp = TempDir::new().unwrap();
3410 let config_path = tmp.path().join("config.toml");
3411 let original = PawConfig {
3412 supervisor: Some(SupervisorConfig {
3413 enabled: true,
3414 conflict: ConflictConfig {
3415 window_seconds: 90,
3416 warn_on_intent_overlap: false,
3417 escalate_on_violation: true,
3418 },
3419 ..Default::default()
3420 }),
3421 ..Default::default()
3422 };
3423 save_config_to(&config_path, &original).unwrap();
3424 let loaded = load_config_file(&config_path).unwrap().unwrap();
3425 assert_eq!(loaded.supervisor, original.supervisor);
3426 }
3427
3428 #[test]
3429 fn v030_config_loads_without_auto_approve() {
3430 let tmp = TempDir::new().unwrap();
3433 let path = tmp.path().join("config.toml");
3434 write_file(
3435 &path,
3436 "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
3437 );
3438 let config = load_config_file(&path).unwrap().unwrap();
3439 assert!(config.supervisor.is_none());
3440 assert!(config.broker.enabled);
3441 }
3442
3443 fn write_repo_config(repo_root: &Path, toml: &str) {
3449 write_file(&repo_config_path(repo_root), toml);
3450 }
3451
3452 fn missing_global(tmp: &TempDir) -> PathBuf {
3453 tmp.path().join("nonexistent-global").join("config.toml")
3454 }
3455
3456 #[test]
3458 fn governance_defaults_to_all_none_when_section_absent() {
3459 let tmp = TempDir::new().unwrap();
3460 let path = tmp.path().join("config.toml");
3461 write_file(&path, "default_cli = \"claude\"\n");
3462
3463 let config = load_config_file(&path).unwrap().unwrap();
3464 assert!(config.governance.adr.is_none());
3465 assert!(config.governance.test_strategy.is_none());
3466 assert!(config.governance.security.is_none());
3467 assert!(config.governance.dod.is_none());
3468 assert!(config.governance.constitution.is_none());
3469 }
3470
3471 #[test]
3473 fn governance_all_paths_populated() {
3474 let tmp = TempDir::new().unwrap();
3475 let path = tmp.path().join("config.toml");
3476 write_file(
3477 &path,
3478 "[governance]\n\
3479 adr = \"docs/adr\"\n\
3480 test_strategy = \"docs/test-strategy.md\"\n\
3481 security = \"docs/security-checklist.md\"\n\
3482 dod = \"docs/definition-of-done.md\"\n\
3483 constitution = \".specify/memory/constitution.md\"\n",
3484 );
3485
3486 let config = load_config_file(&path).unwrap().unwrap();
3487 assert_eq!(
3488 config.governance.adr.as_deref(),
3489 Some(Path::new("docs/adr"))
3490 );
3491 assert_eq!(
3492 config.governance.test_strategy.as_deref(),
3493 Some(Path::new("docs/test-strategy.md"))
3494 );
3495 assert_eq!(
3496 config.governance.security.as_deref(),
3497 Some(Path::new("docs/security-checklist.md"))
3498 );
3499 assert_eq!(
3500 config.governance.dod.as_deref(),
3501 Some(Path::new("docs/definition-of-done.md"))
3502 );
3503 assert_eq!(
3504 config.governance.constitution.as_deref(),
3505 Some(Path::new(".specify/memory/constitution.md"))
3506 );
3507 }
3508
3509 #[test]
3511 fn governance_partial_paths_only_some_fields_populated() {
3512 let tmp = TempDir::new().unwrap();
3513 let path = tmp.path().join("config.toml");
3514 write_file(
3515 &path,
3516 "[governance]\n\
3517 dod = \"docs/dod.md\"\n\
3518 security = \"docs/security.md\"\n",
3519 );
3520
3521 let config = load_config_file(&path).unwrap().unwrap();
3522 assert_eq!(
3523 config.governance.dod.as_deref(),
3524 Some(Path::new("docs/dod.md"))
3525 );
3526 assert_eq!(
3527 config.governance.security.as_deref(),
3528 Some(Path::new("docs/security.md"))
3529 );
3530 assert!(config.governance.adr.is_none());
3531 assert!(config.governance.test_strategy.is_none());
3532 assert!(config.governance.constitution.is_none());
3533 }
3534
3535 #[test]
3537 fn governance_absolute_path_preserved_as_is() {
3538 let tmp = TempDir::new().unwrap();
3539 let path = tmp.path().join("config.toml");
3540 write_file(&path, "[governance]\nadr = \"/absolute/path/to/adr\"\n");
3541
3542 let config = load_config_file(&path).unwrap().unwrap();
3543 assert_eq!(
3544 config.governance.adr,
3545 Some(PathBuf::from("/absolute/path/to/adr"))
3546 );
3547 }
3548
3549 #[test]
3551 fn governance_nonexistent_path_loads_cleanly() {
3552 let tmp = TempDir::new().unwrap();
3553 let path = tmp.path().join("config.toml");
3554 write_file(&path, "[governance]\ndod = \"docs/never-existed.md\"\n");
3555
3556 let config = load_config_file(&path).unwrap().unwrap();
3557 assert_eq!(
3558 config.governance.dod,
3559 Some(PathBuf::from("docs/never-existed.md"))
3560 );
3561 }
3562
3563 #[test]
3565 fn governance_round_trips_through_save_and_load() {
3566 let tmp = TempDir::new().unwrap();
3567 let config_path = tmp.path().join("config.toml");
3568
3569 let original = PawConfig {
3570 governance: GovernanceConfig {
3571 adr: Some(PathBuf::from("docs/adr")),
3572 test_strategy: Some(PathBuf::from("docs/test-strategy.md")),
3573 security: Some(PathBuf::from("docs/security.md")),
3574 dod: Some(PathBuf::from("docs/dod.md")),
3575 constitution: Some(PathBuf::from(".specify/memory/constitution.md")),
3576 readme: Some(PathBuf::from("README.md")),
3577 docs: Some(PathBuf::from("docs/src")),
3578 },
3579 ..Default::default()
3580 };
3581
3582 save_config_to(&config_path, &original).unwrap();
3583 let loaded = load_config_file(&config_path).unwrap().unwrap();
3584 assert_eq!(loaded.governance, original.governance);
3585 }
3586
3587 #[test]
3589 fn governance_v04_config_without_section_loads_with_defaults() {
3590 let tmp = TempDir::new().unwrap();
3591 let path = tmp.path().join("config.toml");
3592 write_file(
3593 &path,
3594 "default_cli = \"claude\"\n\
3595 mouse = true\n\
3596 [broker]\n\
3597 enabled = true\n\
3598 [supervisor]\n\
3599 enabled = true\n\
3600 [specs]\n\
3601 dir = \"specs\"\n\
3602 type = \"openspec\"\n\
3603 [clis.foo]\n\
3604 command = \"/bin/foo\"\n",
3605 );
3606
3607 let config = load_config_file(&path).unwrap().unwrap();
3608 assert_eq!(config.governance, GovernanceConfig::default());
3609 assert!(config.governance.adr.is_none());
3610 assert!(config.governance.test_strategy.is_none());
3611 assert!(config.governance.security.is_none());
3612 assert!(config.governance.dod.is_none());
3613 assert!(config.governance.constitution.is_none());
3614 assert!(config.governance.readme.is_none());
3615 assert!(config.governance.docs.is_none());
3616 }
3617
3618 #[test]
3621 fn governance_default_has_only_path_fields() {
3622 let GovernanceConfig {
3626 adr,
3627 test_strategy,
3628 security,
3629 dod,
3630 constitution,
3631 readme,
3632 docs,
3633 } = GovernanceConfig::default();
3634 assert!(adr.is_none());
3635 assert!(test_strategy.is_none());
3636 assert!(security.is_none());
3637 assert!(dod.is_none());
3638 assert!(constitution.is_none());
3639 assert!(readme.is_none());
3640 assert!(docs.is_none());
3641 }
3642
3643 #[test]
3645 fn governance_parses_readme_and_docs_fields() {
3646 let tmp = TempDir::new().unwrap();
3647 let path = tmp.path().join("config.toml");
3648 write_file(
3649 &path,
3650 "[governance]\n\
3651 readme = \"README.md\"\n\
3652 docs = \"docs/src\"\n",
3653 );
3654 let config = load_config_file(&path).unwrap().unwrap();
3655 assert_eq!(config.governance.readme, Some(PathBuf::from("README.md")));
3656 assert_eq!(config.governance.docs, Some(PathBuf::from("docs/src")));
3657 }
3658
3659 #[test]
3661 fn governance_readme_and_docs_default_to_none_when_omitted() {
3662 let tmp = TempDir::new().unwrap();
3663 let path = tmp.path().join("config.toml");
3664 write_file(&path, "[governance]\ndod = \"docs/dod.md\"\n");
3665 let config = load_config_file(&path).unwrap().unwrap();
3666 assert!(config.governance.readme.is_none());
3667 assert!(config.governance.docs.is_none());
3668 assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
3669 }
3670
3671 #[test]
3673 fn governance_readme_and_docs_round_trip() {
3674 let original = GovernanceConfig {
3675 readme: Some(PathBuf::from("README.md")),
3676 docs: Some(PathBuf::from("docs/src")),
3677 ..Default::default()
3678 };
3679 let toml_str = toml::to_string(&original).unwrap();
3680 let reparsed: GovernanceConfig = toml::from_str(&toml_str).unwrap();
3681 assert_eq!(reparsed.readme, original.readme);
3682 assert_eq!(reparsed.docs, original.docs);
3683 }
3684
3685 #[test]
3687 fn governance_auto_wires_constitution_when_speckit_detected() {
3688 let tmp = TempDir::new().unwrap();
3689 let repo_root = tmp.path().join("repo");
3690 let specify = repo_root.join(".specify");
3691 let specs = specify.join("specs");
3692 let memory = specify.join("memory");
3693 fs::create_dir_all(&specs).unwrap();
3694 fs::create_dir_all(&memory).unwrap();
3695 let constitution = memory.join("constitution.md");
3696 fs::write(&constitution, "# Constitution\n").unwrap();
3697
3698 write_repo_config(
3699 &repo_root,
3700 "[specs]\n\
3701 type = \"speckit\"\n\
3702 dir = \".specify/specs\"\n",
3703 );
3704
3705 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3706 assert_eq!(
3707 config.governance.constitution.as_deref(),
3708 Some(constitution.as_path())
3709 );
3710 }
3711
3712 #[test]
3714 fn governance_explicit_constitution_preserved_over_auto_wiring() {
3715 let tmp = TempDir::new().unwrap();
3716 let repo_root = tmp.path().join("repo");
3717 let specify = repo_root.join(".specify");
3718 let specs = specify.join("specs");
3719 let memory = specify.join("memory");
3720 fs::create_dir_all(&specs).unwrap();
3721 fs::create_dir_all(&memory).unwrap();
3722 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3723
3724 write_repo_config(
3725 &repo_root,
3726 "[specs]\n\
3727 type = \"speckit\"\n\
3728 dir = \".specify/specs\"\n\
3729 [governance]\n\
3730 constitution = \"docs/principles.md\"\n",
3731 );
3732
3733 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3734 assert_eq!(
3735 config.governance.constitution,
3736 Some(PathBuf::from("docs/principles.md"))
3737 );
3738 }
3739
3740 #[test]
3742 fn governance_auto_wiring_skipped_when_specs_type_is_openspec() {
3743 let tmp = TempDir::new().unwrap();
3744 let repo_root = tmp.path().join("repo");
3745 let specify = repo_root.join(".specify");
3746 let memory = specify.join("memory");
3747 fs::create_dir_all(&memory).unwrap();
3748 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3749 fs::create_dir_all(repo_root.join("specs")).unwrap();
3750
3751 write_repo_config(
3752 &repo_root,
3753 "[specs]\n\
3754 type = \"openspec\"\n\
3755 dir = \"specs\"\n",
3756 );
3757
3758 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3759 assert!(config.governance.constitution.is_none());
3760 }
3761
3762 #[test]
3764 fn governance_auto_wiring_skipped_when_specs_section_absent() {
3765 let tmp = TempDir::new().unwrap();
3766 let repo_root = tmp.path().join("repo");
3767 let memory = repo_root.join(".specify").join("memory");
3768 fs::create_dir_all(&memory).unwrap();
3769 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3770 fs::create_dir_all(repo_root.join(".git-paw")).unwrap();
3771
3772 write_repo_config(&repo_root, "default_cli = \"claude\"\n");
3773
3774 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3775 assert!(config.governance.constitution.is_none());
3776 }
3777
3778 #[test]
3780 fn governance_auto_wiring_skipped_when_constitution_md_absent() {
3781 let tmp = TempDir::new().unwrap();
3782 let repo_root = tmp.path().join("repo");
3783 let specs = repo_root.join(".specify").join("specs");
3784 fs::create_dir_all(&specs).unwrap();
3785 write_repo_config(
3788 &repo_root,
3789 "[specs]\n\
3790 type = \"speckit\"\n\
3791 dir = \".specify/specs\"\n",
3792 );
3793
3794 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3795 assert!(config.governance.constitution.is_none());
3796 }
3797
3798 #[test]
3800 fn governance_explicit_empty_string_constitution_suppresses_auto_wiring() {
3801 let tmp = TempDir::new().unwrap();
3802 let repo_root = tmp.path().join("repo");
3803 let specify = repo_root.join(".specify");
3804 let specs = specify.join("specs");
3805 let memory = specify.join("memory");
3806 fs::create_dir_all(&specs).unwrap();
3807 fs::create_dir_all(&memory).unwrap();
3808 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3809
3810 write_repo_config(
3811 &repo_root,
3812 "[specs]\n\
3813 type = \"speckit\"\n\
3814 dir = \".specify/specs\"\n\
3815 [governance]\n\
3816 constitution = \"\"\n",
3817 );
3818
3819 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3820 assert_eq!(config.governance.constitution, Some(PathBuf::from("")));
3821 }
3822
3823 #[test]
3825 fn governance_merge_fields_independently_across_global_and_repo() {
3826 let tmp = TempDir::new().unwrap();
3827 let global_path = tmp.path().join("global").join("config.toml");
3828 let repo_root = tmp.path().join("repo");
3829 fs::create_dir_all(&repo_root).unwrap();
3830
3831 write_file(&global_path, "[governance]\nadr = \"docs/adr\"\n");
3832 write_file(
3833 &repo_config_path(&repo_root),
3834 "[governance]\ndod = \"docs/dod.md\"\n",
3835 );
3836
3837 let config = load_config_from(&global_path, &repo_root).unwrap();
3838 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/adr")));
3839 assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
3840 }
3841
3842 #[test]
3844 fn governance_merge_repo_wins_per_field_when_both_set() {
3845 let tmp = TempDir::new().unwrap();
3846 let global_path = tmp.path().join("global").join("config.toml");
3847 let repo_root = tmp.path().join("repo");
3848 fs::create_dir_all(&repo_root).unwrap();
3849
3850 write_file(&global_path, "[governance]\nadr = \"docs/global-adr\"\n");
3851 write_file(
3852 &repo_config_path(&repo_root),
3853 "[governance]\nadr = \"docs/repo-adr\"\n",
3854 );
3855
3856 let config = load_config_from(&global_path, &repo_root).unwrap();
3857 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/repo-adr")));
3858 }
3859
3860 #[test]
3862 fn governance_load_repo_config_also_auto_wires_constitution() {
3863 let tmp = TempDir::new().unwrap();
3864 let repo_root = tmp.path().join("repo");
3865 let specify = repo_root.join(".specify");
3866 let specs = specify.join("specs");
3867 let memory = specify.join("memory");
3868 fs::create_dir_all(&specs).unwrap();
3869 fs::create_dir_all(&memory).unwrap();
3870 let constitution = memory.join("constitution.md");
3871 fs::write(&constitution, "# Constitution\n").unwrap();
3872
3873 write_repo_config(
3874 &repo_root,
3875 "[specs]\n\
3876 type = \"speckit\"\n\
3877 dir = \".specify/specs\"\n",
3878 );
3879
3880 let config = load_repo_config(&repo_root).unwrap();
3881 assert_eq!(
3882 config.governance.constitution.as_deref(),
3883 Some(constitution.as_path())
3884 );
3885 }
3886
3887 #[test]
3890 fn load_config_with_some_pins_global_to_override_path() {
3891 let tmp = TempDir::new().unwrap();
3892 let repo_root = tmp.path().join("repo");
3893 fs::create_dir_all(&repo_root).unwrap();
3894
3895 let global_a = tmp.path().join("global-A.toml");
3896 let global_b = tmp.path().join("global-B.toml");
3897 write_file(&global_a, "[clis.cli-A]\ncommand = \"/bin/a\"\n");
3898 write_file(&global_b, "[clis.cli-B]\ncommand = \"/bin/b\"\n");
3899
3900 let config = load_config(&repo_root, Some(&global_a)).unwrap();
3901 assert!(config.clis.contains_key("cli-A"));
3902 assert!(!config.clis.contains_key("cli-B"));
3903 }
3904
3905 #[test]
3906 fn load_config_with_some_nonexistent_returns_defaults() {
3907 let tmp = TempDir::new().unwrap();
3908 let repo_root = tmp.path().join("repo");
3909 fs::create_dir_all(&repo_root).unwrap();
3910 let missing = tmp.path().join("does-not-exist.toml");
3911
3912 let config = load_config(&repo_root, Some(&missing)).unwrap();
3913 assert_eq!(config, PawConfig::default());
3914 }
3915
3916 #[test]
3926 fn load_config_override_does_not_affect_repo_resolution() {
3927 let tmp = TempDir::new().unwrap();
3928 let repo_root = tmp.path().join("repo");
3929 fs::create_dir_all(&repo_root).unwrap();
3930 write_file(&repo_config_path(&repo_root), "default_cli = \"claude\"\n");
3931
3932 let global_path = tmp.path().join("global.toml");
3933 write_file(&global_path, "default_cli = \"gemini\"\n");
3934
3935 let config = load_config(&repo_root, Some(&global_path)).unwrap();
3936 assert_eq!(config.default_cli.as_deref(), Some("claude"));
3937 }
3938
3939 #[test]
3946 fn governance_config_rejects_gates_field() {
3947 let toml_input = "[governance]\ndod = \"docs/dod.md\"\n[governance.gates]\ndod = true\n";
3948 let cfg: PawConfig = toml::from_str(toml_input).expect("toml parse");
3949 let gov = cfg.governance;
3950 assert_eq!(gov.dod.as_deref(), Some(Path::new("docs/dod.md")));
3951
3952 let round_trip = toml::to_string(&gov).expect("serialise gov");
3953 assert!(
3954 !round_trip.contains("gates"),
3955 "GovernanceConfig must not round-trip a `gates` field; got: {round_trip}"
3956 );
3957 assert!(
3958 !round_trip.contains("[governance.gates]"),
3959 "GovernanceConfig must not round-trip a `[governance.gates]` section; got: {round_trip}"
3960 );
3961 }
3962
3963 #[test]
3971 fn border_affordances_defaults_to_true_when_layout_absent() {
3972 let cfg: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("toml parse");
3973 assert!(
3974 cfg.layout.is_none(),
3975 "no [layout] section should parse as None"
3976 );
3977 assert!(
3978 cfg.border_affordances_enabled(),
3979 "border affordances default to on when [layout] is absent"
3980 );
3981 }
3982
3983 #[test]
3986 fn border_affordances_defaults_to_true_when_field_unset() {
3987 let cfg: PawConfig = toml::from_str("[layout]\n").expect("toml parse");
3988 assert!(
3989 cfg.border_affordances_enabled(),
3990 "border affordances default to on when the field is unset"
3991 );
3992 }
3993
3994 #[test]
3996 fn border_affordances_explicit_false_resolves_off() {
3997 let cfg: PawConfig =
3998 toml::from_str("[layout]\nborder_affordances = false\n").expect("toml parse");
3999 assert_eq!(cfg.layout.as_ref().unwrap().border_affordances, Some(false));
4000 assert!(
4001 !cfg.border_affordances_enabled(),
4002 "explicit false must resolve to off"
4003 );
4004 }
4005
4006 #[test]
4008 fn border_affordances_explicit_true_resolves_on() {
4009 let cfg: PawConfig =
4010 toml::from_str("[layout]\nborder_affordances = true\n").expect("toml parse");
4011 assert!(cfg.border_affordances_enabled());
4012 }
4013
4014 #[test]
4017 fn v0_5_0_config_without_layout_parses() {
4018 let v0_5_0 = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
4019 let cfg: PawConfig = toml::from_str(v0_5_0).expect("v0.5.0 config must still parse");
4020 assert!(cfg.layout.is_none());
4021 assert!(cfg.border_affordances_enabled());
4022 }
4023
4024 #[test]
4026 fn layout_overlay_wins_in_merge() {
4027 let base: PawConfig =
4028 toml::from_str("[layout]\nborder_affordances = true\n").expect("base");
4029 let overlay: PawConfig =
4030 toml::from_str("[layout]\nborder_affordances = false\n").expect("overlay");
4031 let merged = base.merged_with(&overlay);
4032 assert!(
4033 !merged.border_affordances_enabled(),
4034 "overlay [layout] must win in the merge"
4035 );
4036 }
4037
4038 #[test]
4040 fn layout_base_preserved_when_overlay_absent() {
4041 let base: PawConfig =
4042 toml::from_str("[layout]\nborder_affordances = false\n").expect("base");
4043 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4044 let merged = base.merged_with(&overlay);
4045 assert!(
4046 !merged.border_affordances_enabled(),
4047 "base [layout] must survive when the overlay has none"
4048 );
4049 }
4050
4051 #[test]
4054 fn role_gating_defaults_to_warn_when_section_absent() {
4055 let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
4058 assert!(config.opsx.is_none());
4059 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4060 }
4061
4062 #[test]
4063 fn role_gating_section_present_but_field_absent_resolves_warn() {
4064 let config: PawConfig = toml::from_str("[opsx]\n").expect("parses");
4065 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4066 }
4067
4068 #[test]
4069 fn role_gating_explicit_warn() {
4070 let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("parses");
4071 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4072 }
4073
4074 #[test]
4075 fn role_gating_explicit_block() {
4076 let config: PawConfig =
4077 toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("parses");
4078 assert_eq!(config.role_gating_mode(), RoleGatingMode::Block);
4079 }
4080
4081 #[test]
4082 fn role_gating_explicit_off() {
4083 let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("parses");
4084 assert_eq!(config.role_gating_mode(), RoleGatingMode::Off);
4085 }
4086
4087 #[test]
4088 fn role_gating_invalid_value_is_a_parse_error() {
4089 let err = toml::from_str::<PawConfig>("[opsx]\nrole_gating = \"loud\"\n").unwrap_err();
4090 assert!(
4091 err.to_string().contains("role_gating") || err.to_string().contains("variant"),
4092 "got: {err}"
4093 );
4094 }
4095
4096 #[test]
4097 fn role_gating_mode_round_trips_through_toml() {
4098 let config = PawConfig {
4099 opsx: Some(OpsxConfig {
4100 role_gating: Some(RoleGatingMode::Block),
4101 }),
4102 ..Default::default()
4103 };
4104 let serialized = toml::to_string(&config).expect("serializes");
4105 assert!(
4106 serialized.contains("role_gating = \"block\""),
4107 "got: {serialized}"
4108 );
4109 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4110 assert_eq!(reparsed.role_gating_mode(), RoleGatingMode::Block);
4111 }
4112
4113 #[test]
4114 fn opsx_section_merges_with_overlay_winning() {
4115 let base: PawConfig =
4116 toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("base parses");
4117 let overlay: PawConfig =
4118 toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("overlay parses");
4119 let merged = base.merged_with(&overlay);
4120 assert_eq!(merged.role_gating_mode(), RoleGatingMode::Block);
4121 }
4122
4123 #[test]
4124 fn opsx_section_base_preserved_when_overlay_absent() {
4125 let base: PawConfig =
4126 toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("base parses");
4127 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4128 let merged = base.merged_with(&overlay);
4129 assert_eq!(merged.role_gating_mode(), RoleGatingMode::Off);
4130 }
4131
4132 #[test]
4133 fn supervisor_auto_revert_defaults_false() {
4134 let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4135 let sup = config.supervisor.expect("supervisor present");
4136 assert!(!sup.auto_revert(), "auto_revert defaults to false");
4137 }
4138
4139 #[test]
4140 fn supervisor_auto_revert_explicit_true() {
4141 let config: PawConfig =
4142 toml::from_str("[supervisor]\nenabled = true\nauto_revert = true\n").expect("parses");
4143 let sup = config.supervisor.expect("supervisor present");
4144 assert!(sup.auto_revert());
4145 }
4146
4147 #[test]
4150 fn tell_config_defaults_when_table_absent() {
4151 let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4154 let sup = config.supervisor.expect("supervisor present");
4155 assert_eq!(sup.tell.mode, TellMode::Feedback);
4156 assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4157 assert!(sup.tell.is_default());
4158 }
4159
4160 #[test]
4161 fn tell_config_explicit_feedback_loads() {
4162 let config: PawConfig = toml::from_str(
4163 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"feedback\"\n",
4164 )
4165 .expect("parses");
4166 let sup = config.supervisor.expect("supervisor present");
4167 assert_eq!(sup.tell.mode, TellMode::Feedback);
4168 assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4170 }
4171
4172 #[test]
4173 fn tell_config_explicit_send_keys_loads() {
4174 let config: PawConfig = toml::from_str(
4175 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"send-keys\"\ninventory_max_age_seconds = 15\n",
4176 )
4177 .expect("parses");
4178 let sup = config.supervisor.expect("supervisor present");
4179 assert_eq!(sup.tell.mode, TellMode::SendKeys);
4180 assert_eq!(sup.tell.inventory_max_age_seconds, 15);
4181 assert!(!sup.tell.is_default());
4182 }
4183
4184 #[test]
4185 fn tell_config_rejects_unknown_mode() {
4186 let err = toml::from_str::<PawConfig>(
4187 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"shout\"\n",
4188 )
4189 .unwrap_err();
4190 assert!(
4191 err.to_string().contains("shout") || err.to_string().contains("mode"),
4192 "unknown mode should be a parse error; got {err}"
4193 );
4194 }
4195
4196 #[test]
4197 fn tell_config_all_default_table_round_trips_without_emitting_tell() {
4198 let sup = SupervisorConfig {
4201 enabled: true,
4202 ..SupervisorConfig::default()
4203 };
4204 let config = PawConfig {
4205 supervisor: Some(sup),
4206 ..PawConfig::default()
4207 };
4208 let serialized = toml::to_string_pretty(&config).expect("serializes");
4209 assert!(
4210 !serialized.contains("[supervisor.tell]"),
4211 "all-default tell table must be omitted; got:\n{serialized}"
4212 );
4213 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4214 assert_eq!(config, reparsed);
4215 }
4216
4217 #[test]
4221 fn mcp_name_parses_to_some() {
4222 let config: PawConfig = toml::from_str("[mcp]\nname = \"my-project\"\n").expect("parses");
4223 assert_eq!(config.mcp.name, Some("my-project".to_string()));
4224 assert_eq!(config.mcp_server_name(), "my-project");
4225 }
4226
4227 #[test]
4230 fn mcp_section_absent_defaults_to_none() {
4231 let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
4232 assert_eq!(config.mcp, McpConfig::default());
4233 assert!(config.mcp.name.is_none());
4234 assert_eq!(config.mcp_server_name(), "git-paw");
4235 }
4236
4237 #[test]
4240 fn pre_existing_config_without_mcp_loads() {
4241 let prior = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
4242 let config: PawConfig = toml::from_str(prior).expect("prior config must still parse");
4243 assert_eq!(config.mcp, McpConfig::default());
4244 }
4245
4246 #[test]
4249 fn mcp_config_round_trips_through_toml() {
4250 let config = PawConfig {
4251 mcp: McpConfig {
4252 name: Some("my-project".to_string()),
4253 },
4254 ..PawConfig::default()
4255 };
4256 let serialized = toml::to_string(&config).expect("serializes");
4257 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4258 assert_eq!(reparsed.mcp, config.mcp);
4259 }
4260
4261 #[test]
4264 fn mcp_default_omits_name_on_serialize() {
4265 let config = PawConfig::default();
4266 let serialized = toml::to_string_pretty(&config).expect("serializes");
4267 assert!(
4268 !serialized.contains("name ="),
4269 "default [mcp] must not emit a name; got:\n{serialized}"
4270 );
4271 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4272 assert_eq!(config, reparsed);
4273 }
4274
4275 #[test]
4277 fn mcp_overlay_name_wins_in_merge() {
4278 let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
4279 let overlay: PawConfig = toml::from_str("[mcp]\nname = \"repo-name\"\n").expect("overlay");
4280 let merged = base.merged_with(&overlay);
4281 assert_eq!(merged.mcp.name, Some("repo-name".to_string()));
4282 }
4283
4284 #[test]
4286 fn mcp_base_name_preserved_when_overlay_absent() {
4287 let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
4288 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4289 let merged = base.merged_with(&overlay);
4290 assert_eq!(merged.mcp.name, Some("global-name".to_string()));
4291 }
4292}