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 #[serde(default = "BrokerLogConfig::default_height_lines")]
254 pub height_lines: u16,
255}
256
257impl Default for BrokerLogConfig {
258 fn default() -> Self {
259 Self {
260 max_messages: Self::default_max_messages(),
261 default_visible: Self::default_visible(),
262 height_lines: Self::default_height_lines(),
263 }
264 }
265}
266
267impl BrokerLogConfig {
268 fn default_max_messages() -> usize {
269 500
270 }
271
272 fn default_visible() -> bool {
273 true
274 }
275
276 fn default_height_lines() -> u16 {
279 20
280 }
281}
282
283#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
289pub struct SupervisorConfig {
290 #[serde(default)]
292 pub enabled: bool,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub cli: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub test_command: Option<String>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub lint_command: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub build_command: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub doc_build_command: Option<String>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub doc_tool_command: Option<String>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub spec_validate_command: Option<String>,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub fmt_check_command: Option<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub security_audit_command: Option<String>,
369 #[serde(default)]
371 pub agent_approval: ApprovalLevel,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub auto_approve: Option<AutoApproveConfig>,
379 #[serde(default)]
387 pub conflict: ConflictConfig,
388 #[serde(default)]
395 pub learnings: bool,
396 #[serde(default)]
403 pub learnings_config: LearningsConfig,
404 #[serde(default)]
411 pub common_dev_allowlist: CommonDevAllowlistConfig,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub verify_on_commit_nudge: Option<bool>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub strict_branch_guard: Option<bool>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub auto_revert: Option<bool>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub manual_approvals_log: Option<bool>,
461 #[serde(default, skip_serializing_if = "TellConfig::is_default")]
469 pub tell: TellConfig,
470}
471
472#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
479#[serde(rename_all = "kebab-case")]
480pub enum TellMode {
481 #[default]
484 Feedback,
485 SendKeys,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498pub struct TellConfig {
499 #[serde(default)]
501 pub mode: TellMode,
502 #[serde(default = "TellConfig::default_inventory_max_age_seconds")]
505 pub inventory_max_age_seconds: u64,
506}
507
508impl Default for TellConfig {
509 fn default() -> Self {
510 Self {
511 mode: TellMode::default(),
512 inventory_max_age_seconds: Self::default_inventory_max_age_seconds(),
513 }
514 }
515}
516
517impl TellConfig {
518 fn default_inventory_max_age_seconds() -> u64 {
519 60
520 }
521
522 #[must_use]
528 pub fn is_default(&self) -> bool {
529 *self == Self::default()
530 }
531}
532
533impl SupervisorConfig {
534 #[must_use]
537 pub fn strict_branch_guard(&self) -> bool {
538 self.strict_branch_guard.unwrap_or(true)
539 }
540
541 #[must_use]
545 pub fn auto_revert(&self) -> bool {
546 self.auto_revert.unwrap_or(false)
547 }
548
549 #[must_use]
554 pub fn manual_approvals_log_enabled(&self) -> bool {
555 self.manual_approvals_log.unwrap_or(true)
556 }
557
558 #[must_use]
562 pub fn gate_commands(&self) -> crate::skills::GateCommands<'_> {
563 crate::skills::GateCommands {
564 test_command: self.test_command.as_deref(),
565 lint_command: self.lint_command.as_deref(),
566 build_command: self.build_command.as_deref(),
567 doc_build_command: self.doc_build_command.as_deref(),
568 spec_validate_command: self.spec_validate_command.as_deref(),
569 fmt_check_command: self.fmt_check_command.as_deref(),
570 security_audit_command: self.security_audit_command.as_deref(),
571 doc_tool_command: self.doc_tool_command.as_deref(),
572 }
573 }
574
575 #[must_use]
582 pub fn verify_on_commit_nudge_enabled(&self) -> bool {
583 self.verify_on_commit_nudge.unwrap_or(true)
584 }
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
598pub struct CommonDevAllowlistConfig {
599 #[serde(default = "CommonDevAllowlistConfig::default_enabled")]
605 pub enabled: bool,
606 #[serde(default)]
615 pub stacks: Vec<String>,
616 #[serde(default)]
623 pub extra: Vec<String>,
624}
625
626impl Default for CommonDevAllowlistConfig {
627 fn default() -> Self {
628 Self {
629 enabled: Self::default_enabled(),
630 stacks: Vec::new(),
631 extra: Vec::new(),
632 }
633 }
634}
635
636impl CommonDevAllowlistConfig {
637 fn default_enabled() -> bool {
638 true
639 }
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649pub struct LearningsConfig {
650 #[serde(default = "LearningsConfig::default_flush_interval_seconds")]
652 pub flush_interval_seconds: u64,
653 #[serde(default)]
661 pub broker_publish: BrokerPublish,
662}
663
664impl Default for LearningsConfig {
665 fn default() -> Self {
666 Self {
667 flush_interval_seconds: Self::default_flush_interval_seconds(),
668 broker_publish: BrokerPublish::default(),
669 }
670 }
671}
672
673impl LearningsConfig {
674 fn default_flush_interval_seconds() -> u64 {
675 60
676 }
677}
678
679#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
684#[serde(rename_all = "snake_case")]
685pub enum BrokerPublish {
686 #[default]
689 Auto,
690 ForceOff,
692}
693
694impl BrokerPublish {
695 #[must_use]
698 pub fn resolve(self, broker_enabled: bool) -> bool {
699 match self {
700 Self::Auto => broker_enabled,
701 Self::ForceOff => false,
702 }
703 }
704}
705
706#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
714pub struct ConflictConfig {
715 #[serde(default = "ConflictConfig::default_window_seconds")]
718 pub window_seconds: u64,
719 #[serde(default = "ConflictConfig::default_true")]
725 pub warn_on_intent_overlap: bool,
726 #[serde(default = "ConflictConfig::default_true")]
731 pub escalate_on_violation: bool,
732}
733
734impl Default for ConflictConfig {
735 fn default() -> Self {
736 Self {
737 window_seconds: Self::default_window_seconds(),
738 warn_on_intent_overlap: true,
739 escalate_on_violation: true,
740 }
741 }
742}
743
744impl ConflictConfig {
745 fn default_window_seconds() -> u64 {
746 120
747 }
748
749 fn default_true() -> bool {
750 true
751 }
752}
753
754#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
766#[serde(rename_all = "kebab-case")]
767pub enum ApprovalLevelPreset {
768 Off,
770 Conservative,
772 #[default]
774 Safe,
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
788pub struct AutoApproveConfig {
789 #[serde(default = "AutoApproveConfig::default_enabled")]
791 pub enabled: bool,
792 #[serde(default)]
796 pub safe_commands: Vec<String>,
797 #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
800 pub stall_threshold_seconds: u64,
801 #[serde(default)]
808 pub approval_level: ApprovalLevelPreset,
809 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub approve_worktree_writes: Option<bool>,
820}
821
822impl Default for AutoApproveConfig {
823 fn default() -> Self {
824 Self {
825 enabled: Self::default_enabled(),
826 safe_commands: Vec::new(),
827 stall_threshold_seconds: Self::default_stall_threshold_seconds(),
828 approval_level: ApprovalLevelPreset::Safe,
829 approve_worktree_writes: None,
830 }
831 }
832}
833
834impl AutoApproveConfig {
835 pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
838
839 fn default_enabled() -> bool {
840 true
841 }
842
843 fn default_stall_threshold_seconds() -> u64 {
844 30
845 }
846
847 #[must_use]
854 pub fn resolved(&self) -> Self {
855 let mut out = self.clone();
856 if out.approval_level == ApprovalLevelPreset::Off {
857 out.enabled = false;
858 }
859 if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
860 eprintln!(
861 "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
862 out.stall_threshold_seconds,
863 Self::MIN_STALL_THRESHOLD_SECONDS
864 );
865 out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
866 }
867 out
868 }
869
870 #[must_use]
877 pub fn approve_worktree_writes(&self) -> bool {
878 self.approve_worktree_writes.unwrap_or(true)
879 }
880
881 #[must_use]
888 pub fn effective_whitelist(&self) -> Vec<String> {
889 let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
890 .iter()
891 .map(|s| (*s).to_string())
892 .collect();
893 for extra in &self.safe_commands {
894 if !out.iter().any(|e| e == extra) {
895 out.push(extra.clone());
896 }
897 }
898 if self.approval_level == ApprovalLevelPreset::Conservative {
899 out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
900 }
901 out
902 }
903}
904
905#[must_use]
925pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
926 match (cli, level) {
927 ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
928 ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
929 ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
930 _ => "",
931 }
932}
933
934#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
942pub struct WatcherConfig {
943 #[serde(default, skip_serializing_if = "Option::is_none")]
953 pub republish_working_ttl_seconds: Option<u64>,
954}
955
956impl WatcherConfig {
957 pub const DEFAULT_REPUBLISH_TTL_SECONDS: u64 = 60;
959 pub const MIN_REPUBLISH_TTL_SECONDS: u64 = 5;
961
962 #[must_use]
970 pub fn republish_working_ttl_seconds(&self) -> u64 {
971 match self.republish_working_ttl_seconds {
972 None => Self::DEFAULT_REPUBLISH_TTL_SECONDS,
973 Some(0) => 0,
974 Some(n) if n < Self::MIN_REPUBLISH_TTL_SECONDS => {
975 eprintln!(
976 "warning: [broker.watcher] republish_working_ttl_seconds = {n} clamped to {}s minimum",
977 Self::MIN_REPUBLISH_TTL_SECONDS
978 );
979 Self::MIN_REPUBLISH_TTL_SECONDS
980 }
981 Some(n) => n,
982 }
983 }
984}
985
986#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
988pub struct BrokerConfig {
989 #[serde(default)]
991 pub enabled: bool,
992 #[serde(default = "BrokerConfig::default_port")]
994 pub port: u16,
995 #[serde(default = "BrokerConfig::default_bind")]
997 pub bind: String,
998 #[serde(default)]
1000 pub watcher: WatcherConfig,
1001}
1002
1003impl Default for BrokerConfig {
1004 fn default() -> Self {
1005 Self {
1006 enabled: false,
1007 port: 9119,
1008 bind: "127.0.0.1".to_string(),
1009 watcher: WatcherConfig::default(),
1010 }
1011 }
1012}
1013
1014impl BrokerConfig {
1015 pub fn url(&self) -> String {
1017 format!("http://{}:{}", self.bind, self.port)
1018 }
1019
1020 fn default_port() -> u16 {
1021 9119
1022 }
1023
1024 fn default_bind() -> String {
1025 "127.0.0.1".to_string()
1026 }
1027}
1028
1029#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1034pub struct LayoutConfig {
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1043 pub border_affordances: Option<bool>,
1044}
1045
1046impl LayoutConfig {
1047 #[must_use]
1049 pub fn border_affordances_enabled(&self) -> bool {
1050 self.border_affordances.unwrap_or(true)
1051 }
1052}
1053
1054#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1069#[serde(rename_all = "lowercase")]
1070pub enum WorktreePlacement {
1071 #[default]
1075 Sibling,
1076 Child,
1079}
1080
1081#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1085pub struct PawConfig {
1086 #[serde(default, skip_serializing_if = "Option::is_none")]
1088 pub default_cli: Option<String>,
1089
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1092 pub default_spec_cli: Option<String>,
1093
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub branch_prefix: Option<String>,
1097
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub mouse: Option<bool>,
1101
1102 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1104 pub clis: HashMap<String, CustomCli>,
1105
1106 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
1108 pub presets: HashMap<String, Preset>,
1109
1110 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub specs: Option<SpecsConfig>,
1113
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub logging: Option<LoggingConfig>,
1117
1118 #[serde(default, skip_serializing_if = "Option::is_none")]
1120 pub dashboard: Option<DashboardConfig>,
1121
1122 #[serde(default)]
1124 pub broker: BrokerConfig,
1125
1126 #[serde(default, skip_serializing_if = "Option::is_none")]
1128 pub supervisor: Option<SupervisorConfig>,
1129
1130 #[serde(default)]
1136 pub governance: GovernanceConfig,
1137
1138 #[serde(default, skip_serializing_if = "Option::is_none")]
1144 pub layout: Option<LayoutConfig>,
1145
1146 #[serde(default, skip_serializing_if = "Option::is_none")]
1152 pub opsx: Option<OpsxConfig>,
1153
1154 #[serde(default)]
1161 pub mcp: McpConfig,
1162
1163 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub worktree_placement: Option<WorktreePlacement>,
1174}
1175
1176impl PawConfig {
1177 #[must_use]
1182 pub fn merged_with(&self, overlay: &Self) -> Self {
1183 let mut clis = self.clis.clone();
1184 for (k, v) in &overlay.clis {
1185 clis.insert(k.clone(), v.clone());
1186 }
1187
1188 let mut presets = self.presets.clone();
1189 for (k, v) in &overlay.presets {
1190 presets.insert(k.clone(), v.clone());
1191 }
1192
1193 Self {
1194 default_cli: overlay
1195 .default_cli
1196 .clone()
1197 .or_else(|| self.default_cli.clone()),
1198 default_spec_cli: overlay
1199 .default_spec_cli
1200 .clone()
1201 .or_else(|| self.default_spec_cli.clone()),
1202 branch_prefix: overlay
1203 .branch_prefix
1204 .clone()
1205 .or_else(|| self.branch_prefix.clone()),
1206 mouse: overlay.mouse.or(self.mouse),
1207 clis,
1208 presets,
1209 specs: overlay.specs.clone().or_else(|| self.specs.clone()),
1210 logging: overlay.logging.clone().or_else(|| self.logging.clone()),
1211 dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
1212 broker: if overlay.broker == BrokerConfig::default() {
1213 self.broker.clone()
1214 } else {
1215 overlay.broker.clone()
1216 },
1217 supervisor: overlay
1218 .supervisor
1219 .clone()
1220 .or_else(|| self.supervisor.clone()),
1221 governance: GovernanceConfig {
1222 adr: overlay
1223 .governance
1224 .adr
1225 .clone()
1226 .or_else(|| self.governance.adr.clone()),
1227 test_strategy: overlay
1228 .governance
1229 .test_strategy
1230 .clone()
1231 .or_else(|| self.governance.test_strategy.clone()),
1232 security: overlay
1233 .governance
1234 .security
1235 .clone()
1236 .or_else(|| self.governance.security.clone()),
1237 dod: overlay
1238 .governance
1239 .dod
1240 .clone()
1241 .or_else(|| self.governance.dod.clone()),
1242 constitution: overlay
1243 .governance
1244 .constitution
1245 .clone()
1246 .or_else(|| self.governance.constitution.clone()),
1247 readme: overlay
1248 .governance
1249 .readme
1250 .clone()
1251 .or_else(|| self.governance.readme.clone()),
1252 docs: overlay
1253 .governance
1254 .docs
1255 .clone()
1256 .or_else(|| self.governance.docs.clone()),
1257 },
1258 layout: overlay.layout.clone().or_else(|| self.layout.clone()),
1259 opsx: overlay.opsx.clone().or_else(|| self.opsx.clone()),
1260 mcp: McpConfig {
1261 name: overlay.mcp.name.clone().or_else(|| self.mcp.name.clone()),
1262 },
1263 worktree_placement: overlay.worktree_placement.or(self.worktree_placement),
1264 }
1265 }
1266
1267 #[must_use]
1270 pub fn worktree_placement(&self) -> WorktreePlacement {
1271 self.worktree_placement.unwrap_or_default()
1272 }
1273
1274 #[must_use]
1278 pub fn role_gating_mode(&self) -> RoleGatingMode {
1279 self.opsx
1280 .as_ref()
1281 .map(OpsxConfig::role_gating_mode)
1282 .unwrap_or_default()
1283 }
1284
1285 #[must_use]
1289 pub fn border_affordances_enabled(&self) -> bool {
1290 self.layout
1291 .as_ref()
1292 .is_none_or(LayoutConfig::border_affordances_enabled)
1293 }
1294
1295 #[must_use]
1301 pub fn mcp_server_name(&self) -> String {
1302 self.mcp
1303 .name
1304 .clone()
1305 .unwrap_or_else(|| "git-paw".to_string())
1306 }
1307
1308 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
1310 self.presets.get(name)
1311 }
1312
1313 pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
1315 self.dashboard.as_ref()
1316 }
1317}
1318
1319pub fn global_config_path() -> Result<PathBuf, PawError> {
1321 crate::dirs::config_dir()
1322 .map(|d| d.join("git-paw").join("config.toml"))
1323 .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
1324}
1325
1326pub fn repo_config_path(repo_root: &Path) -> PathBuf {
1328 repo_root.join(".git-paw").join("config.toml")
1329}
1330
1331fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
1333 match fs::read_to_string(path) {
1334 Ok(contents) => {
1335 let config: PawConfig = toml::from_str(&contents)
1336 .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
1337 Ok(Some(config))
1338 }
1339 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1340 Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
1341 }
1342}
1343
1344pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
1352 let mut config = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1353 auto_wire_governance(&mut config, repo_root);
1354 Ok(config)
1355}
1356
1357fn auto_wire_governance(config: &mut PawConfig, repo_root: &Path) {
1375 if config.governance.constitution.is_some() {
1376 return;
1377 }
1378 let Some(specs_cfg) = config.specs.as_ref() else {
1379 return;
1380 };
1381 let Some(spec_type) = specs_cfg.spec_type.as_deref() else {
1382 return;
1383 };
1384 if spec_type != "speckit" {
1385 return;
1386 }
1387 let dir = specs_cfg.dir.as_deref().unwrap_or("specs");
1388 let specs_dir = repo_root.join(dir);
1389 if let Some(detected) = crate::specs::speckit::detect_constitution(&specs_dir) {
1390 config.governance.constitution = Some(detected);
1391 }
1392}
1393
1394pub fn load_config(
1420 repo_root: &Path,
1421 user_config_path: Option<&Path>,
1422) -> Result<PawConfig, PawError> {
1423 let global_path = match user_config_path {
1424 Some(p) => p.to_path_buf(),
1425 None => global_config_path()?,
1426 };
1427 load_config_from(&global_path, repo_root)
1428}
1429
1430pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
1435 let global = load_config_file(global_path)?.unwrap_or_default();
1436 let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
1437 let mut merged = global.merged_with(&repo);
1438 auto_wire_governance(&mut merged, repo_root);
1439 Ok(merged)
1440}
1441
1442pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
1444 save_config_to(&repo_config_path(repo_root), config)
1445}
1446
1447fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
1449 let dir = path
1450 .parent()
1451 .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
1452 fs::create_dir_all(dir)
1453 .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
1454
1455 let contents =
1456 toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
1457
1458 let tmp = path.with_extension("toml.tmp");
1460 fs::write(&tmp, &contents)
1461 .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
1462 fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
1463
1464 Ok(())
1465}
1466
1467pub fn add_custom_cli(
1471 name: &str,
1472 command: &str,
1473 display_name: Option<&str>,
1474) -> Result<(), PawError> {
1475 add_custom_cli_to(&global_config_path()?, name, command, display_name)
1476}
1477
1478pub fn add_custom_cli_to(
1482 config_path: &Path,
1483 name: &str,
1484 command: &str,
1485 display_name: Option<&str>,
1486) -> Result<(), PawError> {
1487 let resolved_command = if Path::new(command).is_absolute() {
1488 command.to_string()
1489 } else {
1490 which::which(command)
1491 .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
1492 .to_string_lossy()
1493 .into_owned()
1494 };
1495
1496 let mut config = load_config_file(config_path)?.unwrap_or_default();
1497
1498 config.clis.insert(
1499 name.to_string(),
1500 CustomCli {
1501 command: resolved_command,
1502 display_name: display_name.map(String::from),
1503 submit_delay_ms: None,
1504 settings_path: None,
1505 },
1506 );
1507
1508 save_config_to(config_path, &config)
1509}
1510
1511#[allow(clippy::too_many_lines)] pub fn generate_default_config() -> String {
1515 r#"# git-paw configuration
1516# See https://github.com/bearicorn/git-paw for documentation.
1517
1518# Pre-select a CLI in the interactive picker (user can still change).
1519# Omit to show the full picker with no default.
1520# default_cli = ""
1521
1522# Enable tmux mouse mode for sessions (default: true).
1523# mouse = true
1524
1525# Bypass the CLI picker entirely for --from-specs mode.
1526# Omit to prompt or use per-spec paw_cli fields.
1527# default_spec_cli = ""
1528
1529# Prefix for spec-derived branch names (default: "spec/" ).
1530# branch_prefix = "spec/"
1531
1532# Where agent worktrees are created relative to the repository.
1533# "child" — inside the repo at .git-paw/worktrees/<branch-slug> (contained
1534# layout; enables a project-scoped permission grant). New repos
1535# default to this. Requires .git-paw/worktrees/ in .gitignore
1536# (git paw init seeds it).
1537# "sibling" — beside the repo at ../<project>-<branch-slug> (v0.7.0 layout).
1538# Omit the field to default to "sibling".
1539worktree_placement = "child"
1540
1541# Dashboard message log configuration.
1542# [dashboard]
1543# show_message_log = false
1544
1545# Spec scanning configuration.
1546# [specs]
1547# dir = "specs"
1548#
1549# OpenSpec format (directory-based, default):
1550# type = "openspec"
1551#
1552# Markdown format (frontmatter-based):
1553# type = "markdown"
1554# Each .md file uses YAML frontmatter fields:
1555# paw_status — "pending" | "done" | "in-progress" (required)
1556# paw_branch — branch name suffix (optional, falls back to filename)
1557# paw_cli — CLI override for this spec (optional)
1558
1559# Session logging configuration.
1560# [logging]
1561# enabled = false
1562
1563# HTTP broker for agent coordination (requires --broker flag on start).
1564# [broker]
1565# enabled = true
1566# port = 9119
1567# bind = "127.0.0.1"
1568
1569# Supervisor mode — git-paw acts as a coordinating layer in front of the
1570# agent CLI, enforcing approval policy and running configured gate
1571# commands during the five-gate verification workflow.
1572#
1573# Gate command templates feed the supervisor skill's five gates: gate 1
1574# Testing (fmt_check / lint / build / test), gate 3 Spec audit
1575# (spec_validate), gate 4 Doc audit (doc_build), gate 5 Security audit
1576# (security_audit). When a key is omitted, the matching placeholder
1577# renders as `(not configured)` in the supervisor skill and the agent
1578# skips that tooling step (the gate's manual review still applies).
1579# `{{CHANGE_ID}}` inside spec_validate_command is substituted by the
1580# supervisor agent at verification time with the change name.
1581# [supervisor]
1582# enabled = true
1583# cli = "claude"
1584# test_command = "just check" # or: "cargo test", "npm test", "pytest"
1585# lint_command = "cargo clippy -- -D warnings" # or: "npm run lint", "ruff check .", "golangci-lint run"
1586# build_command = "cargo build" # or: "npm run build", "mvn package", "go build ./..."
1587# fmt_check_command = "cargo fmt --check" # or: "prettier --check .", "gofmt -l ."
1588# doc_build_command = "mdbook build docs/" # or: "sphinx-build", "mkdocs build"
1589# doc_tool_command = "cargo doc --no-deps" # or: "sphinx-build -W docs docs/_build", "javadoc", "npx typedoc"
1590# spec_validate_command = "openspec validate {{CHANGE_ID}} --strict" # OpenSpec only
1591# security_audit_command = "cargo audit" # or: "npm audit", "bandit -r ."
1592# agent_approval = "auto" # one of: "manual", "auto", "full-auto"
1593# verify_on_commit_nudge = true # broker nudges the supervisor to verify each commit promptly (default true)
1594#
1595# Routing through the supervisor (the /tell and /agents commands). The user
1596# types in the supervisor pane and the supervisor routes the prompt to the
1597# named agent. `mode` selects the default delivery channel:
1598# "feedback" (default) — queue an agent.feedback; the agent picks it up on
1599# its next inbox poll. Safe for mixed-mode sessions.
1600# "send-keys" — inject the prompt directly into the target pane;
1601# used only when the target is in accept-edits mode,
1602# otherwise /tell falls back to feedback.
1603# `inventory_max_age_seconds` is how stale the cached /agents inventory may be
1604# before /tell or /agents re-polls the broker (default 60).
1605# [supervisor.tell]
1606# mode = "feedback"
1607# inventory_max_age_seconds = 60
1608#
1609# Conflict detector tuning. Active only when supervisor mode is enabled.
1610# [supervisor.conflict]
1611# window_seconds = 120 # escalate unresolved in-flight conflicts after this many seconds
1612# warn_on_intent_overlap = true # emit feedback when two agent.intent declarations overlap
1613# escalate_on_violation = true # also publish agent.question to supervisor on ownership violations
1614
1615# Common dev-command allowlist. When supervisor mode starts a session,
1616# git-paw seeds .claude/settings.json::allowed_bash_prefixes with the
1617# universal preset (non-destructive git verbs + find / grep / sed -n) so
1618# agents do not hit a permission prompt for each variant. Opt into a
1619# toolchain's curated grants with stacks (named presets: rust / node /
1620# python / go); extend with project-specific prefixes via extra. Opt out
1621# entirely by setting enabled = false.
1622# [supervisor.common_dev_allowlist]
1623# enabled = true
1624# stacks = ["rust"]
1625# extra = ["just", "mdbook build", "openspec validate"]
1626
1627# opsx (OpenSpec) role gating. When the session's spec engine is OpenSpec,
1628# git-paw's post-commit guard detects archive activity (`/opsx:archive` /
1629# `openspec archive`) by a non-supervisor agent and reacts per this mode:
1630# "warn" (default) — feedback to the offending agent + a permission_pattern
1631# learning the user sees in learnings.
1632# "block" — warn behaviour PLUS a feedback to the supervisor
1633# requesting it revert the offending commit.
1634# "off" — guard disabled entirely.
1635# The guard is inert under non-OpenSpec engines (speckit, markdown).
1636# [opsx]
1637# role_gating = "warn"
1638
1639# Custom CLI definitions.
1640# [clis.my-agent]
1641# command = "/usr/local/bin/my-agent"
1642# display_name = "My Agent"
1643
1644# Named presets for quick launches.
1645# [presets.my-preset]
1646# branches = ["feat/api", "fix/db"]
1647# cli = ""
1648"#
1649 .to_string()
1650}
1651
1652pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
1656 remove_custom_cli_from(&global_config_path()?, name)
1657}
1658
1659pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
1663 let mut config = load_config_file(config_path)?.unwrap_or_default();
1664
1665 if config.clis.remove(name).is_none() {
1666 return Err(PawError::CliNotFound(name.to_string()));
1667 }
1668
1669 save_config_to(config_path, &config)
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674 use super::*;
1675 use tempfile::TempDir;
1676
1677 fn write_file(path: &Path, content: &str) {
1678 if let Some(parent) = path.parent() {
1679 fs::create_dir_all(parent).unwrap();
1680 }
1681 fs::write(path, content).unwrap();
1682 }
1683
1684 #[test]
1687 fn parses_config_with_all_fields() {
1688 let tmp = TempDir::new().unwrap();
1689 let path = tmp.path().join("config.toml");
1690 write_file(
1691 &path,
1692 r#"
1693default_cli = "claude"
1694mouse = false
1695default_spec_cli = "gemini"
1696branch_prefix = "spec/"
1697
1698[clis.my-agent]
1699command = "/usr/local/bin/my-agent"
1700display_name = "My Agent"
1701
1702[clis.local-llm]
1703command = "ollama-code"
1704
1705[presets.backend]
1706branches = ["feature/api", "fix/db"]
1707cli = "claude"
1708
1709[specs]
1710dir = "my-specs"
1711type = "openspec"
1712
1713[logging]
1714enabled = true
1715"#,
1716 );
1717
1718 let config = load_config_file(&path).unwrap().unwrap();
1719 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1720 assert_eq!(config.mouse, Some(false));
1721 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1722 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1723 assert_eq!(config.clis.len(), 2);
1724 assert_eq!(
1725 config.clis["my-agent"].display_name.as_deref(),
1726 Some("My Agent")
1727 );
1728 assert_eq!(config.clis["local-llm"].command, "ollama-code");
1729 assert_eq!(config.presets["backend"].cli, "claude");
1730 assert_eq!(
1731 config.presets["backend"].branches,
1732 vec!["feature/api", "fix/db"]
1733 );
1734 let specs = config.specs.unwrap();
1735 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1736 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1737 let logging = config.logging.unwrap();
1738 assert!(logging.enabled);
1739 }
1740
1741 #[test]
1742 fn all_fields_are_optional() {
1743 let tmp = TempDir::new().unwrap();
1744 let path = tmp.path().join("config.toml");
1745 write_file(&path, "default_cli = \"gemini\"\n");
1746
1747 let config = load_config_file(&path).unwrap().unwrap();
1748 assert_eq!(config.default_cli.as_deref(), Some("gemini"));
1749 assert_eq!(config.mouse, None);
1750 assert!(config.clis.is_empty());
1751 assert!(config.presets.is_empty());
1752 }
1753
1754 #[test]
1755 fn returns_defaults_when_no_files_exist() {
1756 let tmp = TempDir::new().unwrap();
1757 let global_path = tmp.path().join("nonexistent").join("config.toml");
1758 let repo_root = tmp.path().join("repo");
1759 fs::create_dir_all(&repo_root).unwrap();
1760
1761 let config = load_config_from(&global_path, &repo_root).unwrap();
1762 assert_eq!(config.default_cli, None);
1763 assert_eq!(config.mouse, None);
1764 assert!(config.clis.is_empty());
1765 assert!(config.presets.is_empty());
1766 }
1767
1768 #[test]
1769 fn reports_error_for_invalid_toml() {
1770 let tmp = TempDir::new().unwrap();
1771 let path = tmp.path().join("bad.toml");
1772 write_file(&path, "this is not [valid toml");
1773
1774 let err = load_config_file(&path).unwrap_err();
1775 assert!(err.to_string().contains("bad.toml"));
1776 }
1777
1778 #[test]
1781 fn repo_config_overrides_global_scalars() {
1782 let tmp = TempDir::new().unwrap();
1783 let global_path = tmp.path().join("global").join("config.toml");
1784 let repo_root = tmp.path().join("repo");
1785 fs::create_dir_all(&repo_root).unwrap();
1786
1787 write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
1788 write_file(
1789 &repo_config_path(&repo_root),
1790 "default_cli = \"gemini\"\n", );
1792
1793 let config = load_config_from(&global_path, &repo_root).unwrap();
1794 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
1797
1798 #[test]
1799 fn repo_config_merges_cli_maps() {
1800 let tmp = TempDir::new().unwrap();
1801 let global_path = tmp.path().join("global").join("config.toml");
1802 let repo_root = tmp.path().join("repo");
1803 fs::create_dir_all(&repo_root).unwrap();
1804
1805 write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
1806 write_file(
1807 &repo_config_path(&repo_root),
1808 "[clis.agent-b]\ncommand = \"/bin/b\"\n",
1809 );
1810
1811 let config = load_config_from(&global_path, &repo_root).unwrap();
1812 assert_eq!(config.clis.len(), 2);
1813 assert!(config.clis.contains_key("agent-a"));
1814 assert!(config.clis.contains_key("agent-b"));
1815 }
1816
1817 #[test]
1818 fn repo_cli_overrides_global_cli_with_same_name() {
1819 let tmp = TempDir::new().unwrap();
1820 let global_path = tmp.path().join("global").join("config.toml");
1821 let repo_root = tmp.path().join("repo");
1822 fs::create_dir_all(&repo_root).unwrap();
1823
1824 write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
1825 write_file(
1826 &repo_config_path(&repo_root),
1827 "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
1828 );
1829
1830 let config = load_config_from(&global_path, &repo_root).unwrap();
1831 assert_eq!(config.clis["my-agent"].command, "/new/path");
1832 assert_eq!(
1833 config.clis["my-agent"].display_name.as_deref(),
1834 Some("Overridden")
1835 );
1836 }
1837
1838 #[test]
1839 fn load_config_from_reads_global_file_when_no_repo() {
1840 let tmp = TempDir::new().unwrap();
1841 let global_path = tmp.path().join("global").join("config.toml");
1842 let repo_root = tmp.path().join("repo");
1843 fs::create_dir_all(&repo_root).unwrap();
1844
1845 write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
1846 let config = load_config_from(&global_path, &repo_root).unwrap();
1849 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1850 assert_eq!(config.mouse, Some(false));
1851 }
1852
1853 #[test]
1854 fn load_config_from_reads_repo_file_when_no_global() {
1855 let tmp = TempDir::new().unwrap();
1856 let global_path = tmp.path().join("nonexistent").join("config.toml");
1857 let repo_root = tmp.path().join("repo");
1858 fs::create_dir_all(&repo_root).unwrap();
1859
1860 write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
1861
1862 let config = load_config_from(&global_path, &repo_root).unwrap();
1863 assert_eq!(config.default_cli.as_deref(), Some("codex"));
1864 }
1865
1866 #[test]
1869 fn preset_accessible_by_name() {
1870 let tmp = TempDir::new().unwrap();
1871 let global_path = tmp.path().join("global").join("config.toml");
1872 let repo_root = tmp.path().join("repo");
1873 fs::create_dir_all(&repo_root).unwrap();
1874
1875 write_file(
1876 &repo_config_path(&repo_root),
1877 "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
1878 );
1879
1880 let config = load_config_from(&global_path, &repo_root).unwrap();
1881 let preset = config.get_preset("backend").unwrap();
1882 assert_eq!(preset.cli, "claude");
1883 assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
1884 }
1885
1886 #[test]
1887 fn preset_returns_none_when_not_in_config() {
1888 let tmp = TempDir::new().unwrap();
1889 let global_path = tmp.path().join("config.toml");
1890 write_file(&global_path, "default_cli = \"claude\"\n");
1891
1892 let config = load_config_file(&global_path).unwrap().unwrap();
1893 assert!(config.get_preset("nonexistent").is_none());
1894 }
1895
1896 #[test]
1899 fn add_cli_writes_to_config_file() {
1900 let tmp = TempDir::new().unwrap();
1901 let config_path = tmp.path().join("git-paw").join("config.toml");
1902
1903 add_custom_cli_to(
1905 &config_path,
1906 "my-agent",
1907 "/usr/local/bin/my-agent",
1908 Some("My Agent"),
1909 )
1910 .unwrap();
1911
1912 let config = load_config_file(&config_path).unwrap().unwrap();
1914 assert_eq!(config.clis.len(), 1);
1915 assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
1916 assert_eq!(
1917 config.clis["my-agent"].display_name.as_deref(),
1918 Some("My Agent")
1919 );
1920 }
1921
1922 #[test]
1923 fn add_cli_preserves_existing_entries() {
1924 let tmp = TempDir::new().unwrap();
1925 let config_path = tmp.path().join("git-paw").join("config.toml");
1926
1927 add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
1928 add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
1929
1930 let config = load_config_file(&config_path).unwrap().unwrap();
1931 assert_eq!(config.clis.len(), 2);
1932 assert!(config.clis.contains_key("first"));
1933 assert!(config.clis.contains_key("second"));
1934 }
1935
1936 #[test]
1937 fn add_cli_errors_when_command_not_on_path() {
1938 let tmp = TempDir::new().unwrap();
1939 let config_path = tmp.path().join("config.toml");
1940
1941 let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
1942 .unwrap_err();
1943 assert!(err.to_string().contains("not found on PATH"));
1944 }
1945
1946 #[test]
1949 fn remove_cli_deletes_entry_from_config_file() {
1950 let tmp = TempDir::new().unwrap();
1951 let config_path = tmp.path().join("git-paw").join("config.toml");
1952
1953 add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
1955 add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
1956
1957 remove_custom_cli_from(&config_path, "remove-me").unwrap();
1959
1960 let config = load_config_file(&config_path).unwrap().unwrap();
1962 assert_eq!(config.clis.len(), 1);
1963 assert!(config.clis.contains_key("keep-me"));
1964 assert!(!config.clis.contains_key("remove-me"));
1965 }
1966
1967 #[test]
1968 fn remove_nonexistent_cli_returns_cli_not_found_error() {
1969 let tmp = TempDir::new().unwrap();
1970 let config_path = tmp.path().join("config.toml");
1971 write_file(&config_path, "");
1973
1974 let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
1975 match err {
1976 PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
1977 other => panic!("expected CliNotFound, got: {other}"),
1978 }
1979 }
1980
1981 #[test]
1982 fn remove_cli_from_empty_config_returns_error() {
1983 let tmp = TempDir::new().unwrap();
1984 let config_path = tmp.path().join("config.toml");
1985 let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
1988 match err {
1989 PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
1990 other => panic!("expected CliNotFound, got: {other}"),
1991 }
1992 }
1993
1994 #[test]
1999 fn parses_default_spec_cli_when_present() {
2000 let tmp = TempDir::new().unwrap();
2001 let path = tmp.path().join("config.toml");
2002 write_file(&path, "default_spec_cli = \"claude\"\n");
2003
2004 let config = load_config_file(&path).unwrap().unwrap();
2005 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
2006 }
2007
2008 #[test]
2009 fn default_spec_cli_defaults_to_none() {
2010 let tmp = TempDir::new().unwrap();
2011 let path = tmp.path().join("config.toml");
2012 write_file(&path, "default_cli = \"claude\"\n");
2013
2014 let config = load_config_file(&path).unwrap().unwrap();
2015 assert_eq!(config.default_spec_cli, None);
2016 }
2017
2018 #[test]
2019 fn repo_overrides_global_default_spec_cli() {
2020 let tmp = TempDir::new().unwrap();
2021 let global_path = tmp.path().join("global").join("config.toml");
2022 let repo_root = tmp.path().join("repo");
2023 fs::create_dir_all(&repo_root).unwrap();
2024
2025 write_file(&global_path, "default_spec_cli = \"claude\"\n");
2026 write_file(
2027 &repo_config_path(&repo_root),
2028 "default_spec_cli = \"gemini\"\n",
2029 );
2030
2031 let config = load_config_from(&global_path, &repo_root).unwrap();
2032 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
2033 }
2034
2035 #[test]
2036 fn global_default_spec_cli_preserved_when_repo_absent() {
2037 let tmp = TempDir::new().unwrap();
2038 let global_path = tmp.path().join("global").join("config.toml");
2039 let repo_root = tmp.path().join("repo");
2040 fs::create_dir_all(&repo_root).unwrap();
2041
2042 write_file(&global_path, "default_spec_cli = \"claude\"\n");
2043
2044 let config = load_config_from(&global_path, &repo_root).unwrap();
2045 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
2046 }
2047
2048 #[test]
2051 fn config_survives_save_and_load() {
2052 let tmp = TempDir::new().unwrap();
2053 let config_path = tmp.path().join("config.toml");
2054
2055 let original = PawConfig {
2056 default_cli: Some("claude".into()),
2057 default_spec_cli: None,
2058 branch_prefix: None,
2059 mouse: Some(true),
2060 clis: HashMap::from([(
2061 "test".into(),
2062 CustomCli {
2063 command: "/bin/test".into(),
2064 display_name: Some("Test CLI".into()),
2065 submit_delay_ms: None,
2066 settings_path: None,
2067 },
2068 )]),
2069 presets: HashMap::from([(
2070 "dev".into(),
2071 Preset {
2072 branches: vec!["main".into()],
2073 cli: "claude".into(),
2074 },
2075 )]),
2076 specs: None,
2077 logging: None,
2078 dashboard: None,
2079 broker: BrokerConfig::default(),
2080 supervisor: None,
2081 governance: GovernanceConfig::default(),
2082 layout: None,
2083 opsx: None,
2084 mcp: McpConfig::default(),
2085 worktree_placement: Some(WorktreePlacement::Child),
2086 };
2087
2088 save_config_to(&config_path, &original).unwrap();
2089 let loaded = load_config_file(&config_path).unwrap().unwrap();
2090 assert_eq!(original, loaded);
2091 }
2092
2093 #[test]
2096 fn parses_specs_section_with_populated_fields() {
2097 let tmp = TempDir::new().unwrap();
2098 let path = tmp.path().join("config.toml");
2099 write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
2100
2101 let config = load_config_file(&path).unwrap().unwrap();
2102 let specs = config.specs.unwrap();
2103 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
2104 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
2105 }
2106
2107 #[test]
2110 fn parses_logging_section_with_enabled() {
2111 let tmp = TempDir::new().unwrap();
2112 let path = tmp.path().join("config.toml");
2113 write_file(&path, "[logging]\nenabled = true\n");
2114
2115 let config = load_config_file(&path).unwrap().unwrap();
2116 let logging = config.logging.unwrap();
2117 assert!(logging.enabled);
2118 }
2119
2120 #[test]
2123 fn round_trip_with_specs_and_logging() {
2124 let tmp = TempDir::new().unwrap();
2125 let config_path = tmp.path().join("config.toml");
2126
2127 let original = PawConfig {
2128 specs: Some(SpecsConfig {
2129 dir: Some("specs".into()),
2130 spec_type: Some("openspec".into()),
2131 }),
2132 logging: Some(LoggingConfig { enabled: true }),
2133 ..Default::default()
2134 };
2135
2136 save_config_to(&config_path, &original).unwrap();
2137 let loaded = load_config_file(&config_path).unwrap().unwrap();
2138 assert_eq!(original, loaded);
2139 assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
2140 assert!(loaded.logging.unwrap().enabled);
2141 }
2142
2143 #[test]
2146 fn generated_default_config_is_valid_toml() {
2147 let raw = generate_default_config();
2148 let stripped: String = raw
2149 .lines()
2150 .filter(|line| !line.trim_start().starts_with('#'))
2151 .collect::<Vec<&str>>()
2152 .join("\n");
2153
2154 let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
2155 assert!(
2156 parsed.is_ok(),
2157 "generated config with comments stripped should be valid TOML, got: {:?}",
2158 parsed.unwrap_err()
2159 );
2160 }
2161
2162 #[test]
2165 fn branch_prefix_repo_overrides_global() {
2166 let tmp = TempDir::new().unwrap();
2167 let global_path = tmp.path().join("global").join("config.toml");
2168 let repo_root = tmp.path().join("repo");
2169 fs::create_dir_all(&repo_root).unwrap();
2170
2171 write_file(&global_path, "branch_prefix = \"feat/\"\n");
2172 write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
2173
2174 let config = load_config_from(&global_path, &repo_root).unwrap();
2175 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
2176 }
2177
2178 #[test]
2179 fn generated_default_config_contains_commented_examples() {
2180 let output = generate_default_config();
2181 assert!(
2182 output.contains("default_spec_cli"),
2183 "should contain default_spec_cli"
2184 );
2185 assert!(
2186 output.contains("branch_prefix"),
2187 "should contain branch_prefix"
2188 );
2189 assert!(output.contains("[specs]"), "should contain [specs]");
2190 assert!(output.contains("[logging]"), "should contain [logging]");
2191 assert!(output.contains("[broker]"), "should contain [broker]");
2192 }
2193
2194 #[test]
2195 fn generated_default_config_contains_child_worktree_placement() {
2196 let output = generate_default_config();
2197 assert!(
2198 output.contains("worktree_placement = \"child\""),
2199 "generated config must set child worktree placement for new repos"
2200 );
2201 let parsed: PawConfig = toml::from_str(&output).expect("generated config parses");
2203 assert_eq!(
2204 parsed.worktree_placement(),
2205 WorktreePlacement::Child,
2206 "generated config must resolve to child placement"
2207 );
2208 }
2209
2210 #[test]
2213 fn broker_config_defaults() {
2214 let config = BrokerConfig::default();
2215 assert!(!config.enabled);
2216 assert_eq!(config.port, 9119);
2217 assert_eq!(config.bind, "127.0.0.1");
2218 }
2219
2220 #[test]
2221 fn broker_config_url() {
2222 let config = BrokerConfig::default();
2223 assert_eq!(config.url(), "http://127.0.0.1:9119");
2224
2225 let custom = BrokerConfig {
2226 enabled: true,
2227 port: 8080,
2228 bind: "0.0.0.0".to_string(),
2229 ..Default::default()
2230 };
2231 assert_eq!(custom.url(), "http://0.0.0.0:8080");
2232 }
2233
2234 #[test]
2235 fn empty_config_gets_broker_defaults() {
2236 let tmp = TempDir::new().unwrap();
2237 let path = tmp.path().join("config.toml");
2238 write_file(&path, "");
2239
2240 let config = load_config_file(&path).unwrap().unwrap();
2241 assert!(!config.broker.enabled);
2242 assert_eq!(config.broker.port, 9119);
2243 assert_eq!(config.broker.bind, "127.0.0.1");
2244 }
2245
2246 #[test]
2247 fn parses_full_broker_section() {
2248 let tmp = TempDir::new().unwrap();
2249 let path = tmp.path().join("config.toml");
2250 write_file(
2251 &path,
2252 "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
2253 );
2254
2255 let config = load_config_file(&path).unwrap().unwrap();
2256 assert!(config.broker.enabled);
2257 assert_eq!(config.broker.port, 8080);
2258 assert_eq!(config.broker.bind, "0.0.0.0");
2259 }
2260
2261 #[test]
2262 fn parses_partial_broker_section() {
2263 let tmp = TempDir::new().unwrap();
2264 let path = tmp.path().join("config.toml");
2265 write_file(&path, "[broker]\nenabled = true\n");
2266
2267 let config = load_config_file(&path).unwrap().unwrap();
2268 assert!(config.broker.enabled);
2269 assert_eq!(config.broker.port, 9119);
2270 assert_eq!(config.broker.bind, "127.0.0.1");
2271 }
2272
2273 #[test]
2276 fn supervisor_is_none_when_section_absent() {
2277 let tmp = TempDir::new().unwrap();
2278 let path = tmp.path().join("config.toml");
2279 write_file(&path, "default_cli = \"claude\"\n");
2280
2281 let config = load_config_file(&path).unwrap().unwrap();
2282 assert!(config.supervisor.is_none());
2283 }
2284
2285 #[test]
2286 fn parses_full_supervisor_section() {
2287 let tmp = TempDir::new().unwrap();
2288 let path = tmp.path().join("config.toml");
2289 write_file(
2290 &path,
2291 "[supervisor]\n\
2292 enabled = true\n\
2293 cli = \"claude\"\n\
2294 test_command = \"just check\"\n\
2295 agent_approval = \"full-auto\"\n",
2296 );
2297
2298 let config = load_config_file(&path).unwrap().unwrap();
2299 let supervisor = config.supervisor.unwrap();
2300 assert!(supervisor.enabled);
2301 assert_eq!(supervisor.cli.as_deref(), Some("claude"));
2302 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2303 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
2304 }
2305
2306 #[test]
2307 fn parses_partial_supervisor_section() {
2308 let tmp = TempDir::new().unwrap();
2309 let path = tmp.path().join("config.toml");
2310 write_file(&path, "[supervisor]\nenabled = true\n");
2311
2312 let config = load_config_file(&path).unwrap().unwrap();
2313 let supervisor = config.supervisor.unwrap();
2314 assert!(supervisor.enabled);
2315 assert_eq!(supervisor.cli, None);
2316 assert_eq!(supervisor.test_command, None);
2317 assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
2318 }
2319
2320 #[test]
2323 fn verify_on_commit_nudge_defaults_true_when_absent() {
2324 let tmp = TempDir::new().unwrap();
2325 let path = tmp.path().join("config.toml");
2326 write_file(&path, "[supervisor]\nenabled = true\n");
2327
2328 let config = load_config_file(&path).unwrap().unwrap();
2329 let supervisor = config.supervisor.unwrap();
2330 assert_eq!(
2331 supervisor.verify_on_commit_nudge, None,
2332 "an omitted field must deserialise as None"
2333 );
2334 assert!(
2335 supervisor.verify_on_commit_nudge_enabled(),
2336 "an unset verify_on_commit_nudge must resolve to true (default on)"
2337 );
2338 }
2339
2340 #[test]
2341 fn verify_on_commit_nudge_explicit_false_disables() {
2342 let tmp = TempDir::new().unwrap();
2343 let path = tmp.path().join("config.toml");
2344 write_file(
2345 &path,
2346 "[supervisor]\nenabled = true\nverify_on_commit_nudge = false\n",
2347 );
2348
2349 let config = load_config_file(&path).unwrap().unwrap();
2350 let supervisor = config.supervisor.unwrap();
2351 assert_eq!(supervisor.verify_on_commit_nudge, Some(false));
2352 assert!(
2353 !supervisor.verify_on_commit_nudge_enabled(),
2354 "an explicit `false` must disable the nudge"
2355 );
2356 }
2357
2358 #[test]
2359 fn verify_on_commit_nudge_explicit_true_enables() {
2360 let tmp = TempDir::new().unwrap();
2361 let path = tmp.path().join("config.toml");
2362 write_file(
2363 &path,
2364 "[supervisor]\nenabled = true\nverify_on_commit_nudge = true\n",
2365 );
2366
2367 let config = load_config_file(&path).unwrap().unwrap();
2368 let supervisor = config.supervisor.unwrap();
2369 assert_eq!(supervisor.verify_on_commit_nudge, Some(true));
2370 assert!(supervisor.verify_on_commit_nudge_enabled());
2371 }
2372
2373 #[test]
2374 fn rejects_invalid_approval_level() {
2375 let tmp = TempDir::new().unwrap();
2376 let path = tmp.path().join("config.toml");
2377 write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
2378
2379 let err = load_config_file(&path).unwrap_err();
2380 assert!(
2381 err.to_string().contains("yolo"),
2382 "error should mention invalid value, got: {err}"
2383 );
2384 }
2385
2386 #[test]
2387 fn supervisor_round_trips_through_save_and_load() {
2388 let tmp = TempDir::new().unwrap();
2389 let config_path = tmp.path().join("config.toml");
2390
2391 let original = PawConfig {
2392 supervisor: Some(SupervisorConfig {
2393 enabled: true,
2394 cli: Some("claude".into()),
2395 test_command: Some("just check".into()),
2396 lint_command: None,
2397 build_command: None,
2398 doc_build_command: None,
2399 doc_tool_command: None,
2400 spec_validate_command: None,
2401 fmt_check_command: None,
2402 security_audit_command: None,
2403 agent_approval: ApprovalLevel::FullAuto,
2404 auto_approve: None,
2405 conflict: ConflictConfig::default(),
2406 learnings: false,
2407 learnings_config: LearningsConfig::default(),
2408 common_dev_allowlist: CommonDevAllowlistConfig::default(),
2409 verify_on_commit_nudge: None,
2410 strict_branch_guard: None,
2411 auto_revert: None,
2412 manual_approvals_log: None,
2413 tell: TellConfig::default(),
2414 }),
2415 ..Default::default()
2416 };
2417
2418 save_config_to(&config_path, &original).unwrap();
2419 let loaded = load_config_file(&config_path).unwrap().unwrap();
2420 assert_eq!(loaded.supervisor, original.supervisor);
2421 }
2422
2423 #[test]
2426 fn manual_approvals_log_defaults_to_true_when_absent() {
2427 let tmp = TempDir::new().unwrap();
2429 let path = tmp.path().join("config.toml");
2430 write_file(&path, "[supervisor]\nenabled = true\n");
2431 let cfg = load_config_file(&path).unwrap().unwrap();
2432 let sup = cfg.supervisor.unwrap();
2433 assert_eq!(sup.manual_approvals_log, None);
2434 assert!(
2435 sup.manual_approvals_log_enabled(),
2436 "absent field must resolve to true"
2437 );
2438 }
2439
2440 #[test]
2441 fn manual_approvals_log_explicit_false_opts_out() {
2442 let tmp = TempDir::new().unwrap();
2443 let path = tmp.path().join("config.toml");
2444 write_file(
2445 &path,
2446 "[supervisor]\nenabled = true\nmanual_approvals_log = false\n",
2447 );
2448 let cfg = load_config_file(&path).unwrap().unwrap();
2449 let sup = cfg.supervisor.unwrap();
2450 assert_eq!(sup.manual_approvals_log, Some(false));
2451 assert!(!sup.manual_approvals_log_enabled());
2452 }
2453
2454 #[test]
2455 fn pre_v050_config_parses_with_manual_approvals_log_absent() {
2456 let tmp = TempDir::new().unwrap();
2459 let path = tmp.path().join("config.toml");
2460 write_file(
2461 &path,
2462 "[supervisor]\nenabled = true\ncli = \"claude\"\nlearnings = true\n",
2463 );
2464 let cfg = load_config_file(&path).unwrap().unwrap();
2465 let sup = cfg.supervisor.unwrap();
2466 assert_eq!(sup.manual_approvals_log, None);
2467 assert!(sup.manual_approvals_log_enabled());
2468 }
2469
2470 #[test]
2473 fn strict_branch_guard_defaults_to_true_and_honours_opt_out() {
2474 let on = TempDir::new().unwrap();
2476 let on_path = on.path().join("config.toml");
2477 write_file(&on_path, "[supervisor]\nenabled = true\n");
2478 let cfg = load_config_file(&on_path).unwrap().unwrap();
2479 let sup = cfg.supervisor.unwrap();
2480 assert_eq!(sup.strict_branch_guard, None);
2481 assert!(sup.strict_branch_guard(), "default must resolve to true");
2482
2483 let off = TempDir::new().unwrap();
2485 let off_path = off.path().join("config.toml");
2486 write_file(
2487 &off_path,
2488 "[supervisor]\nenabled = true\nstrict_branch_guard = false\n",
2489 );
2490 let cfg = load_config_file(&off_path).unwrap().unwrap();
2491 let sup = cfg.supervisor.unwrap();
2492 assert_eq!(sup.strict_branch_guard, Some(false));
2493 assert!(!sup.strict_branch_guard());
2494 }
2495
2496 #[test]
2497 fn gate_command_fields_default_to_none() {
2498 let tmp = TempDir::new().unwrap();
2499 let path = tmp.path().join("config.toml");
2500 write_file(&path, "[supervisor]\nenabled = true\n");
2501
2502 let config = load_config_file(&path).unwrap().unwrap();
2503 let supervisor = config.supervisor.unwrap();
2504 assert_eq!(supervisor.test_command, None);
2505 assert_eq!(supervisor.lint_command, None);
2506 assert_eq!(supervisor.build_command, None);
2507 assert_eq!(supervisor.doc_build_command, None);
2508 assert_eq!(supervisor.doc_tool_command, None);
2509 assert_eq!(supervisor.spec_validate_command, None);
2510 assert_eq!(supervisor.fmt_check_command, None);
2511 assert_eq!(supervisor.security_audit_command, None);
2512 }
2513
2514 #[test]
2515 fn gate_command_fields_round_trip() {
2516 let tmp = TempDir::new().unwrap();
2517 let config_path = tmp.path().join("config.toml");
2518
2519 let original = PawConfig {
2520 supervisor: Some(SupervisorConfig {
2521 enabled: true,
2522 cli: Some("claude".into()),
2523 test_command: Some("just check".into()),
2524 lint_command: Some("cargo clippy -- -D warnings".into()),
2525 build_command: Some("cargo build".into()),
2526 doc_build_command: Some("mdbook build docs/".into()),
2527 doc_tool_command: Some("cargo doc --no-deps".into()),
2528 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict".into()),
2529 fmt_check_command: Some("cargo fmt --check".into()),
2530 security_audit_command: Some("cargo audit".into()),
2531 ..Default::default()
2532 }),
2533 ..Default::default()
2534 };
2535
2536 save_config_to(&config_path, &original).unwrap();
2537 let loaded = load_config_file(&config_path).unwrap().unwrap();
2538 assert_eq!(loaded.supervisor, original.supervisor);
2539 }
2540
2541 #[test]
2542 fn gate_command_fields_omit_from_toml_when_none() {
2543 let supervisor = SupervisorConfig {
2544 enabled: true,
2545 test_command: None,
2546 lint_command: None,
2547 build_command: None,
2548 doc_build_command: None,
2549 doc_tool_command: None,
2550 spec_validate_command: None,
2551 fmt_check_command: None,
2552 security_audit_command: None,
2553 ..Default::default()
2554 };
2555 let serialized = toml::to_string_pretty(&supervisor).unwrap();
2556 for key in [
2557 "test_command",
2558 "lint_command",
2559 "build_command",
2560 "doc_build_command",
2561 "doc_tool_command",
2562 "spec_validate_command",
2563 "fmt_check_command",
2564 "security_audit_command",
2565 ] {
2566 assert!(
2567 !serialized.contains(key),
2568 "TOML serialised with None gate fields should omit `{key}`; got:\n{serialized}",
2569 );
2570 }
2571 }
2572
2573 #[test]
2576 fn doc_tool_command_default_none() {
2577 let tmp = TempDir::new().unwrap();
2578 let path = tmp.path().join("config.toml");
2579 write_file(&path, "[supervisor]\nenabled = true\n");
2580
2581 let config = load_config_file(&path).unwrap().unwrap();
2582 let supervisor = config.supervisor.unwrap();
2583 assert_eq!(supervisor.doc_tool_command, None);
2584 }
2585
2586 #[test]
2587 fn doc_tool_command_explicit_value_preserved() {
2588 let tmp = TempDir::new().unwrap();
2589 let path = tmp.path().join("config.toml");
2590 write_file(
2591 &path,
2592 "[supervisor]\n\
2593 enabled = true\n\
2594 doc_tool_command = \"sphinx-build -W docs docs/_build\"\n",
2595 );
2596
2597 let config = load_config_file(&path).unwrap().unwrap();
2598 let supervisor = config.supervisor.unwrap();
2599 assert_eq!(
2600 supervisor.doc_tool_command.as_deref(),
2601 Some("sphinx-build -W docs docs/_build"),
2602 "explicit doc_tool_command value (including all whitespace) must be preserved verbatim",
2603 );
2604 }
2605
2606 #[test]
2607 fn doc_tool_command_v0_5_config_parses_without_field() {
2608 let tmp = TempDir::new().unwrap();
2611 let path = tmp.path().join("config.toml");
2612 write_file(
2613 &path,
2614 "[supervisor]\n\
2615 enabled = true\n\
2616 test_command = \"just check\"\n\
2617 lint_command = \"cargo clippy -- -D warnings\"\n\
2618 build_command = \"cargo build\"\n\
2619 doc_build_command = \"mdbook build docs/\"\n",
2620 );
2621
2622 let config = load_config_file(&path).unwrap().unwrap();
2623 let supervisor = config.supervisor.unwrap();
2624 assert_eq!(supervisor.doc_tool_command, None);
2625 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
2626 }
2627
2628 #[test]
2629 fn doc_tool_command_flows_into_gate_commands() {
2630 let supervisor = SupervisorConfig {
2631 doc_tool_command: Some("javadoc -d docs/api src/**/*.java".into()),
2632 ..Default::default()
2633 };
2634 let gates = supervisor.gate_commands();
2635 assert_eq!(
2636 gates.doc_tool_command,
2637 Some("javadoc -d docs/api src/**/*.java"),
2638 );
2639 }
2640
2641 #[test]
2644 fn supervisor_common_dev_allowlist_defaults_when_section_absent() {
2645 let tmp = TempDir::new().unwrap();
2646 let path = tmp.path().join("config.toml");
2647 write_file(&path, "[supervisor]\nenabled = true\n");
2648
2649 let config = load_config_file(&path).unwrap().unwrap();
2650 let supervisor = config.supervisor.unwrap();
2651 assert!(supervisor.common_dev_allowlist.enabled);
2652 assert!(supervisor.common_dev_allowlist.stacks.is_empty());
2653 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2654 }
2655
2656 #[test]
2657 fn supervisor_common_dev_allowlist_stacks_parsed() {
2658 let tmp = TempDir::new().unwrap();
2659 let path = tmp.path().join("config.toml");
2660 write_file(
2661 &path,
2662 "[supervisor]\nenabled = true\n\
2663 [supervisor.common_dev_allowlist]\nstacks = [\"rust\", \"node\"]\n",
2664 );
2665
2666 let config = load_config_file(&path).unwrap().unwrap();
2667 let supervisor = config.supervisor.unwrap();
2668 assert_eq!(
2669 supervisor.common_dev_allowlist.stacks,
2670 vec!["rust".to_string(), "node".to_string()],
2671 );
2672 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2674 assert!(supervisor.common_dev_allowlist.enabled);
2675 }
2676
2677 #[test]
2678 fn supervisor_common_dev_allowlist_disabled_opt_out() {
2679 let tmp = TempDir::new().unwrap();
2680 let path = tmp.path().join("config.toml");
2681 write_file(
2682 &path,
2683 "[supervisor]\nenabled = true\n\
2684 [supervisor.common_dev_allowlist]\nenabled = false\n",
2685 );
2686
2687 let config = load_config_file(&path).unwrap().unwrap();
2688 let supervisor = config.supervisor.unwrap();
2689 assert!(!supervisor.common_dev_allowlist.enabled);
2690 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2692 }
2693
2694 #[test]
2695 fn supervisor_common_dev_allowlist_extra_parsed() {
2696 let tmp = TempDir::new().unwrap();
2697 let path = tmp.path().join("config.toml");
2698 write_file(
2699 &path,
2700 "[supervisor]\nenabled = true\n\
2701 [supervisor.common_dev_allowlist]\nextra = [\"pnpm test\", \"deno fmt\"]\n",
2702 );
2703
2704 let config = load_config_file(&path).unwrap().unwrap();
2705 let supervisor = config.supervisor.unwrap();
2706 assert_eq!(
2707 supervisor.common_dev_allowlist.extra,
2708 vec!["pnpm test".to_string(), "deno fmt".to_string()],
2709 );
2710 assert!(supervisor.common_dev_allowlist.enabled);
2712 }
2713
2714 #[test]
2715 fn supervisor_common_dev_allowlist_round_trips_through_save_and_load() {
2716 let tmp = TempDir::new().unwrap();
2717 let config_path = tmp.path().join("config.toml");
2718
2719 let original = PawConfig {
2720 supervisor: Some(SupervisorConfig {
2721 enabled: true,
2722 common_dev_allowlist: CommonDevAllowlistConfig {
2723 enabled: false,
2724 stacks: vec!["rust".into(), "node".into()],
2725 extra: vec!["pnpm test".into(), "uv pip install".into()],
2726 },
2727 ..Default::default()
2728 }),
2729 ..Default::default()
2730 };
2731
2732 save_config_to(&config_path, &original).unwrap();
2733 let loaded = load_config_file(&config_path).unwrap().unwrap();
2734 assert_eq!(loaded.supervisor, original.supervisor);
2735 }
2736
2737 #[test]
2738 fn existing_pre_v05_config_loads_with_default_common_dev_allowlist() {
2739 let tmp = TempDir::new().unwrap();
2742 let path = tmp.path().join("config.toml");
2743 write_file(
2744 &path,
2745 "[supervisor]\n\
2746 enabled = true\n\
2747 cli = \"claude\"\n\
2748 test_command = \"just check\"\n\
2749 agent_approval = \"auto\"\n\
2750 [supervisor.conflict]\n\
2751 window_seconds = 60\n",
2752 );
2753
2754 let config = load_config_file(&path).unwrap().unwrap();
2755 let supervisor = config.supervisor.unwrap();
2756 assert!(supervisor.common_dev_allowlist.enabled);
2757 assert!(supervisor.common_dev_allowlist.extra.is_empty());
2758 }
2759
2760 #[test]
2761 fn generated_default_config_template_contains_common_dev_allowlist_section() {
2762 let template = generate_default_config();
2763 assert!(
2764 template.contains("[supervisor.common_dev_allowlist]"),
2765 "default template should document the new sub-table",
2766 );
2767 assert!(
2768 template.contains("enabled = true"),
2769 "template should show the enabled default",
2770 );
2771 assert!(
2772 template.contains("extra ="),
2773 "template should illustrate the extra field",
2774 );
2775 assert!(
2776 template.contains("stacks ="),
2777 "template should illustrate the stacks field",
2778 );
2779 }
2780
2781 #[test]
2784 fn learnings_defaults_to_false_when_supervisor_section_absent_field() {
2785 let tmp = TempDir::new().unwrap();
2787 let path = tmp.path().join("config.toml");
2788 write_file(&path, "[supervisor]\nenabled = true\n");
2789
2790 let config = load_config_file(&path).unwrap().unwrap();
2791 let supervisor = config.supervisor.unwrap();
2792 assert!(!supervisor.learnings);
2793 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2794 }
2795
2796 #[test]
2797 fn learnings_true_loads() {
2798 let tmp = TempDir::new().unwrap();
2799 let path = tmp.path().join("config.toml");
2800 write_file(&path, "[supervisor]\nenabled = true\nlearnings = true\n");
2801
2802 let config = load_config_file(&path).unwrap().unwrap();
2803 let supervisor = config.supervisor.unwrap();
2804 assert!(supervisor.learnings);
2805 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2807 }
2808
2809 #[test]
2810 fn learnings_config_custom_flush_interval_is_honoured() {
2811 let tmp = TempDir::new().unwrap();
2812 let path = tmp.path().join("config.toml");
2813 write_file(
2814 &path,
2815 "[supervisor]\n\
2816 enabled = true\n\
2817 learnings = true\n\
2818 [supervisor.learnings_config]\n\
2819 flush_interval_seconds = 30\n",
2820 );
2821
2822 let config = load_config_file(&path).unwrap().unwrap();
2823 let supervisor = config.supervisor.unwrap();
2824 assert!(supervisor.learnings);
2825 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 30);
2826 }
2827
2828 #[test]
2829 fn learnings_config_defaults_when_table_absent() {
2830 let cfg = LearningsConfig::default();
2832 assert_eq!(cfg.flush_interval_seconds, 60);
2833 }
2834
2835 #[test]
2836 fn pre_v050_config_loads_with_learnings_false() {
2837 let tmp = TempDir::new().unwrap();
2841 let path = tmp.path().join("config.toml");
2842 write_file(
2843 &path,
2844 "default_cli = \"claude\"\n\
2845 [supervisor]\n\
2846 enabled = true\n\
2847 agent_approval = \"auto\"\n",
2848 );
2849
2850 let config = load_config_file(&path).unwrap().unwrap();
2851 let supervisor = config.supervisor.unwrap();
2852 assert!(!supervisor.learnings);
2853 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
2854 }
2855
2856 #[test]
2857 fn learnings_round_trips_through_save_and_load() {
2858 let tmp = TempDir::new().unwrap();
2859 let config_path = tmp.path().join("config.toml");
2860
2861 let original = PawConfig {
2862 supervisor: Some(SupervisorConfig {
2863 enabled: true,
2864 learnings: true,
2865 learnings_config: LearningsConfig {
2866 flush_interval_seconds: 90,
2867 broker_publish: BrokerPublish::ForceOff,
2868 },
2869 ..Default::default()
2870 }),
2871 ..Default::default()
2872 };
2873
2874 save_config_to(&config_path, &original).unwrap();
2875 let loaded = load_config_file(&config_path).unwrap().unwrap();
2876 assert_eq!(loaded.supervisor, original.supervisor);
2877 let supervisor = loaded.supervisor.unwrap();
2878 assert!(supervisor.learnings);
2879 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 90);
2880 }
2881
2882 #[test]
2883 fn existing_v030_config_loads_without_supervisor() {
2884 let tmp = TempDir::new().unwrap();
2885 let path = tmp.path().join("config.toml");
2886 write_file(
2887 &path,
2888 "default_cli = \"claude\"\n\
2889 mouse = true\n\
2890 [broker]\n\
2891 enabled = true\n\
2892 [logging]\n\
2893 enabled = false\n",
2894 );
2895
2896 let config = load_config_file(&path).unwrap().unwrap();
2897 assert_eq!(config.default_cli.as_deref(), Some("claude"));
2898 assert!(config.broker.enabled);
2899 assert!(config.supervisor.is_none());
2900 }
2901
2902 #[test]
2903 fn generated_default_config_contains_commented_supervisor_section() {
2904 let output = generate_default_config();
2905 assert!(output.contains("[supervisor]"));
2906 assert!(output.contains("enabled"));
2907 assert!(output.contains("test_command"));
2908 assert!(output.contains("agent_approval"));
2909 }
2910
2911 #[test]
2914 fn dashboard_config_defaults_to_disabled() {
2915 let config = DashboardConfig::default();
2916 assert!(!config.show_message_log);
2917 }
2918
2919 #[test]
2920 fn parses_dashboard_section_with_show_message_log() {
2921 let tmp = TempDir::new().unwrap();
2922 let path = tmp.path().join("config.toml");
2923 write_file(&path, "[dashboard]\nshow_message_log = true\n");
2924
2925 let config = load_config_file(&path).unwrap().unwrap();
2926 let dashboard = config.dashboard.unwrap();
2927 assert!(dashboard.show_message_log);
2928 }
2929
2930 #[test]
2931 fn dashboard_is_none_when_section_absent() {
2932 let tmp = TempDir::new().unwrap();
2933 let path = tmp.path().join("config.toml");
2934 write_file(&path, "default_cli = \"claude\"\n");
2935
2936 let config = load_config_file(&path).unwrap().unwrap();
2937 assert!(config.dashboard.is_none());
2938 }
2939
2940 #[test]
2941 fn dashboard_merge_repo_wins() {
2942 let tmp = TempDir::new().unwrap();
2943 let global_path = tmp.path().join("global").join("config.toml");
2944 let repo_root = tmp.path().join("repo");
2945 fs::create_dir_all(&repo_root).unwrap();
2946
2947 write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
2948 write_file(
2949 &repo_config_path(&repo_root),
2950 "[dashboard]\nshow_message_log = true\n",
2951 );
2952
2953 let config = load_config_from(&global_path, &repo_root).unwrap();
2954 let dashboard = config.dashboard.unwrap();
2955 assert!(dashboard.show_message_log);
2956 }
2957
2958 #[test]
2959 fn dashboard_round_trip_through_save_and_load() {
2960 let tmp = TempDir::new().unwrap();
2961 let config_path = tmp.path().join("config.toml");
2962
2963 let original = PawConfig {
2964 dashboard: Some(DashboardConfig {
2965 show_message_log: true,
2966 ..Default::default()
2967 }),
2968 ..Default::default()
2969 };
2970
2971 save_config_to(&config_path, &original).unwrap();
2972 let loaded = load_config_file(&config_path).unwrap().unwrap();
2973 assert_eq!(loaded.dashboard, original.dashboard);
2974 assert!(loaded.dashboard.unwrap().show_message_log);
2975 }
2976
2977 #[test]
2980 fn broker_log_config_defaults() {
2981 let cfg = BrokerLogConfig::default();
2983 assert_eq!(cfg.max_messages, 500);
2984 assert!(cfg.default_visible);
2985 assert!(
2986 cfg.height_lines > 12,
2987 "default height_lines must be strictly greater than the v0.6.0 fixed 12, got {}",
2988 cfg.height_lines,
2989 );
2990 }
2991
2992 #[test]
2993 fn dashboard_config_default_includes_broker_log_defaults() {
2994 let cfg = DashboardConfig::default();
2998 assert_eq!(cfg.broker_log.max_messages, 500);
2999 assert!(cfg.broker_log.default_visible);
3000 assert!(cfg.broker_log.height_lines > 12);
3001 }
3002
3003 #[test]
3004 fn parses_broker_log_section_with_explicit_overrides() {
3005 let tmp = TempDir::new().unwrap();
3007 let path = tmp.path().join("config.toml");
3008 write_file(
3009 &path,
3010 "[dashboard.broker_log]\nmax_messages = 100\ndefault_visible = false\n",
3011 );
3012
3013 let config = load_config_file(&path).unwrap().unwrap();
3014 let dashboard = config.dashboard.unwrap();
3015 assert_eq!(dashboard.broker_log.max_messages, 100);
3016 assert!(!dashboard.broker_log.default_visible);
3017 }
3018
3019 #[test]
3020 fn broker_log_partial_section_fills_remaining_defaults() {
3021 let tmp = TempDir::new().unwrap();
3025 let path = tmp.path().join("config.toml");
3026 write_file(&path, "[dashboard.broker_log]\nmax_messages = 42\n");
3027
3028 let config = load_config_file(&path).unwrap().unwrap();
3029 let broker_log = config.dashboard.unwrap().broker_log;
3030 assert_eq!(broker_log.max_messages, 42);
3031 assert!(
3032 broker_log.default_visible,
3033 "default_visible must fall back to true when omitted"
3034 );
3035 assert_eq!(
3036 broker_log.height_lines,
3037 BrokerLogConfig::default_height_lines(),
3038 "height_lines must fall back to the documented default when omitted"
3039 );
3040 }
3041
3042 #[test]
3043 fn height_lines_parses_explicit_value() {
3044 let tmp = TempDir::new().unwrap();
3047 let path = tmp.path().join("config.toml");
3048 write_file(&path, "[dashboard.broker_log]\nheight_lines = 24\n");
3049
3050 let config = load_config_file(&path).unwrap().unwrap();
3051 let broker_log = config.dashboard.unwrap().broker_log;
3052 assert_eq!(broker_log.height_lines, 24);
3053 }
3054
3055 #[test]
3056 fn height_lines_absent_uses_default() {
3057 let tmp = TempDir::new().unwrap();
3061 let path = tmp.path().join("config.toml");
3062 write_file(&path, "[dashboard.broker_log]\ndefault_visible = true\n");
3063
3064 let config = load_config_file(&path).unwrap().unwrap();
3065 let broker_log = config.dashboard.unwrap().broker_log;
3066 assert_eq!(
3067 broker_log.height_lines,
3068 BrokerLogConfig::default_height_lines()
3069 );
3070 assert!(broker_log.height_lines > 12);
3071 }
3072
3073 #[test]
3074 fn v050_dashboard_section_without_broker_log_still_parses() {
3075 let tmp = TempDir::new().unwrap();
3078 let path = tmp.path().join("config.toml");
3079 write_file(&path, "[dashboard]\nshow_message_log = true\n");
3080
3081 let config = load_config_file(&path).unwrap().unwrap();
3082 let dashboard = config.dashboard.unwrap();
3083 assert!(dashboard.show_message_log);
3084 assert_eq!(dashboard.broker_log, BrokerLogConfig::default());
3085 }
3086
3087 #[test]
3088 fn broker_log_round_trips_through_save_and_load() {
3089 let tmp = TempDir::new().unwrap();
3090 let config_path = tmp.path().join("config.toml");
3091
3092 let original = PawConfig {
3093 dashboard: Some(DashboardConfig {
3094 show_message_log: false,
3095 broker_log: BrokerLogConfig {
3096 max_messages: 250,
3097 default_visible: false,
3098 height_lines: 30,
3099 },
3100 }),
3101 ..Default::default()
3102 };
3103
3104 save_config_to(&config_path, &original).unwrap();
3105 let loaded = load_config_file(&config_path).unwrap().unwrap();
3106 assert_eq!(loaded.dashboard, original.dashboard);
3107 assert_eq!(loaded.dashboard.unwrap().broker_log.height_lines, 30);
3110 }
3111
3112 #[test]
3113 fn get_dashboard_returns_none_when_not_configured() {
3114 let config = PawConfig::default();
3115 assert!(config.get_dashboard().is_none());
3116 }
3117
3118 #[test]
3119 fn get_dashboard_returns_config_when_present() {
3120 let config = PawConfig {
3121 dashboard: Some(DashboardConfig {
3122 show_message_log: true,
3123 ..Default::default()
3124 }),
3125 ..Default::default()
3126 };
3127 let dashboard = config.get_dashboard().unwrap();
3128 assert!(dashboard.show_message_log);
3129 }
3130
3131 #[test]
3134 fn approval_flags_claude_full_auto() {
3135 assert_eq!(
3136 approval_flags("claude", &ApprovalLevel::FullAuto),
3137 "--dangerously-skip-permissions"
3138 );
3139 }
3140
3141 #[test]
3142 fn approval_flags_codex_auto() {
3143 assert_eq!(
3144 approval_flags("codex", &ApprovalLevel::Auto),
3145 "--approval-mode=auto-edit"
3146 );
3147 }
3148
3149 #[test]
3150 fn approval_flags_codex_full_auto() {
3151 assert_eq!(
3152 approval_flags("codex", &ApprovalLevel::FullAuto),
3153 "--approval-mode=full-auto"
3154 );
3155 }
3156
3157 #[test]
3158 fn approval_flags_unknown_cli_is_empty() {
3159 assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
3160 }
3161
3162 #[test]
3163 fn approval_flags_manual_is_empty() {
3164 assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
3165 assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
3166 }
3167
3168 #[test]
3169 fn approval_flags_is_deterministic() {
3170 let first = approval_flags("claude", &ApprovalLevel::FullAuto);
3171 let second = approval_flags("claude", &ApprovalLevel::FullAuto);
3172 assert_eq!(first, second);
3173 }
3174
3175 #[test]
3176 fn supervisor_merge_repo_wins() {
3177 let tmp = TempDir::new().unwrap();
3178 let global_path = tmp.path().join("global").join("config.toml");
3179 let repo_root = tmp.path().join("repo");
3180 fs::create_dir_all(&repo_root).unwrap();
3181
3182 write_file(
3183 &global_path,
3184 "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
3185 );
3186 write_file(
3187 &repo_config_path(&repo_root),
3188 "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
3189 );
3190
3191 let config = load_config_from(&global_path, &repo_root).unwrap();
3192 let supervisor = config.supervisor.unwrap();
3193 assert!(supervisor.enabled);
3194 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
3195 }
3196
3197 #[test]
3198 fn broker_config_round_trip() {
3199 let tmp = TempDir::new().unwrap();
3200 let config_path = tmp.path().join("config.toml");
3201
3202 let original = PawConfig {
3203 broker: BrokerConfig {
3204 enabled: true,
3205 port: 9200,
3206 bind: "127.0.0.1".to_string(),
3207 ..Default::default()
3208 },
3209 ..Default::default()
3210 };
3211
3212 save_config_to(&config_path, &original).unwrap();
3213 let loaded = load_config_file(&config_path).unwrap().unwrap();
3214 assert_eq!(loaded.broker.enabled, original.broker.enabled);
3215 assert_eq!(loaded.broker.port, original.broker.port);
3216 assert_eq!(loaded.broker.bind, original.broker.bind);
3217 }
3218
3219 #[test]
3222 fn auto_approve_defaults_match_spec() {
3223 let cfg = AutoApproveConfig::default();
3224 assert!(cfg.enabled, "enabled defaults to true");
3225 assert!(
3226 cfg.safe_commands.is_empty(),
3227 "safe_commands defaults to empty"
3228 );
3229 assert_eq!(cfg.stall_threshold_seconds, 30);
3230 assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
3231 }
3232
3233 #[test]
3234 fn auto_approve_section_absent_keeps_supervisor_simple() {
3235 let tmp = TempDir::new().unwrap();
3236 let path = tmp.path().join("config.toml");
3237 write_file(&path, "[supervisor]\nenabled = true\n");
3238 let config = load_config_file(&path).unwrap().unwrap();
3239 let supervisor = config.supervisor.unwrap();
3240 assert!(supervisor.auto_approve.is_none());
3241 }
3242
3243 #[test]
3244 fn auto_approve_section_parses_full_body() {
3245 let tmp = TempDir::new().unwrap();
3246 let path = tmp.path().join("config.toml");
3247 write_file(
3248 &path,
3249 "[supervisor]\n\
3250 enabled = true\n\
3251 [supervisor.auto_approve]\n\
3252 enabled = false\n\
3253 safe_commands = [\"just smoke\"]\n\
3254 stall_threshold_seconds = 60\n\
3255 approval_level = \"conservative\"\n",
3256 );
3257 let config = load_config_file(&path).unwrap().unwrap();
3258 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3259 assert!(!aa.enabled);
3260 assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
3261 assert_eq!(aa.stall_threshold_seconds, 60);
3262 assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
3263 }
3264
3265 #[test]
3266 fn auto_approve_enabled_defaults_to_true_when_omitted() {
3267 let tmp = TempDir::new().unwrap();
3268 let path = tmp.path().join("config.toml");
3269 write_file(
3270 &path,
3271 "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
3272 );
3273 let config = load_config_file(&path).unwrap().unwrap();
3274 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3275 assert!(aa.enabled, "enabled should default to true");
3276 }
3277
3278 #[test]
3279 fn auto_approve_off_preset_forces_disabled() {
3280 let cfg = AutoApproveConfig {
3281 enabled: true,
3282 approval_level: ApprovalLevelPreset::Off,
3283 ..AutoApproveConfig::default()
3284 };
3285 let resolved = cfg.resolved();
3286 assert!(!resolved.enabled, "Off preset must force enabled = false");
3287 }
3288
3289 #[test]
3292 fn watcher_ttl_defaults_to_sixty_when_absent() {
3293 let cfg = WatcherConfig::default();
3294 assert_eq!(cfg.republish_working_ttl_seconds(), 60);
3295 }
3296
3297 #[test]
3298 fn watcher_ttl_zero_disables() {
3299 let cfg = WatcherConfig {
3300 republish_working_ttl_seconds: Some(0),
3301 };
3302 assert_eq!(cfg.republish_working_ttl_seconds(), 0);
3303 }
3304
3305 #[test]
3306 fn watcher_ttl_below_floor_clamps_to_five() {
3307 let cfg = WatcherConfig {
3308 republish_working_ttl_seconds: Some(2),
3309 };
3310 assert_eq!(
3311 cfg.republish_working_ttl_seconds(),
3312 WatcherConfig::MIN_REPUBLISH_TTL_SECONDS
3313 );
3314 }
3315
3316 #[test]
3317 fn watcher_ttl_explicit_non_zero_is_preserved() {
3318 let cfg = WatcherConfig {
3319 republish_working_ttl_seconds: Some(120),
3320 };
3321 assert_eq!(cfg.republish_working_ttl_seconds(), 120);
3322 }
3323
3324 #[test]
3325 fn watcher_ttl_parses_from_broker_table() {
3326 let tmp = TempDir::new().unwrap();
3327 let path = tmp.path().join("config.toml");
3328 write_file(
3329 &path,
3330 "[broker]\nenabled = true\n[broker.watcher]\nrepublish_working_ttl_seconds = 0\n",
3331 );
3332 let config = load_config_file(&path).unwrap().unwrap();
3333 assert_eq!(config.broker.watcher.republish_working_ttl_seconds, Some(0));
3334 assert_eq!(config.broker.watcher.republish_working_ttl_seconds(), 0);
3335 }
3336
3337 #[test]
3338 fn approve_worktree_writes_defaults_to_true_when_absent() {
3339 let cfg = AutoApproveConfig::default();
3341 assert!(
3342 cfg.approve_worktree_writes(),
3343 "absent approve_worktree_writes must resolve to true"
3344 );
3345 }
3346
3347 #[test]
3348 fn approve_worktree_writes_explicit_false_resolves_false() {
3349 let cfg = AutoApproveConfig {
3351 approve_worktree_writes: Some(false),
3352 ..AutoApproveConfig::default()
3353 };
3354 assert!(!cfg.approve_worktree_writes());
3355 }
3356
3357 #[test]
3358 fn approve_worktree_writes_parses_from_toml() {
3359 let tmp = TempDir::new().unwrap();
3360 let path = tmp.path().join("config.toml");
3361 write_file(
3362 &path,
3363 "[supervisor]\nenabled = true\n[supervisor.auto_approve]\napprove_worktree_writes = false\n",
3364 );
3365 let config = load_config_file(&path).unwrap().unwrap();
3366 let aa = config.supervisor.unwrap().auto_approve.unwrap();
3367 assert_eq!(aa.approve_worktree_writes, Some(false));
3368 assert!(!aa.approve_worktree_writes());
3369 }
3370
3371 #[test]
3372 fn auto_approve_threshold_floor_clamps() {
3373 let cfg = AutoApproveConfig {
3374 stall_threshold_seconds: 0,
3375 ..AutoApproveConfig::default()
3376 };
3377 let resolved = cfg.resolved();
3378 assert_eq!(
3379 resolved.stall_threshold_seconds,
3380 AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
3381 );
3382 }
3383
3384 #[test]
3385 fn auto_approve_safe_preset_keeps_defaults() {
3386 let cfg = AutoApproveConfig {
3387 approval_level: ApprovalLevelPreset::Safe,
3388 ..AutoApproveConfig::default()
3389 };
3390 let wl = cfg.effective_whitelist();
3391 assert!(wl.iter().any(|c| c == "cargo test"));
3392 assert!(wl.iter().any(|c| c == "git push"));
3393 assert!(wl.iter().any(|c| c.starts_with("curl")));
3394 }
3395
3396 #[test]
3397 fn auto_approve_conservative_drops_push_and_curl() {
3398 let cfg = AutoApproveConfig {
3399 approval_level: ApprovalLevelPreset::Conservative,
3400 ..AutoApproveConfig::default()
3401 };
3402 let wl = cfg.effective_whitelist();
3403 assert!(wl.iter().any(|c| c == "cargo test"));
3404 assert!(
3405 !wl.iter().any(|c| c.starts_with("git push")),
3406 "conservative drops git push"
3407 );
3408 assert!(
3409 !wl.iter().any(|c| c.starts_with("curl")),
3410 "conservative drops curl"
3411 );
3412 }
3413
3414 #[test]
3415 fn auto_approve_extras_are_unioned_with_defaults() {
3416 let cfg = AutoApproveConfig {
3417 safe_commands: vec!["just lint".to_string(), "just test".to_string()],
3418 ..AutoApproveConfig::default()
3419 };
3420 let wl = cfg.effective_whitelist();
3421 assert!(wl.iter().any(|c| c == "cargo fmt"));
3422 assert!(wl.iter().any(|c| c == "just lint"));
3423 assert!(wl.iter().any(|c| c == "just test"));
3424 }
3425
3426 #[test]
3427 fn auto_approve_empty_extras_keep_defaults() {
3428 let cfg = AutoApproveConfig::default();
3429 let wl = cfg.effective_whitelist();
3430 assert!(wl.iter().any(|c| c == "cargo test"));
3431 }
3432
3433 #[test]
3440 fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
3441 use crate::supervisor::auto_approve::is_safe_command;
3442
3443 let tmp = TempDir::new().unwrap();
3446 let extras_path = tmp.path().join("extras.toml");
3447 write_file(
3448 &extras_path,
3449 "[supervisor]\n\
3450 enabled = true\n\
3451 [supervisor.auto_approve]\n\
3452 safe_commands = [\"just smoke\"]\n",
3453 );
3454 let extras_config = load_config_file(&extras_path).unwrap().unwrap();
3455 let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
3456 let extras_whitelist = extras_aa.effective_whitelist();
3457 assert!(
3458 is_safe_command("just smoke -v", &extras_whitelist),
3459 "TOML extra `just smoke` must accept `just smoke -v`"
3460 );
3461 assert!(
3463 is_safe_command("cargo test", &extras_whitelist),
3464 "extras must not displace built-in defaults"
3465 );
3466
3467 let empty_path = tmp.path().join("empty.toml");
3470 write_file(
3471 &empty_path,
3472 "[supervisor]\n\
3473 enabled = true\n\
3474 [supervisor.auto_approve]\n\
3475 safe_commands = []\n",
3476 );
3477 let empty_config = load_config_file(&empty_path).unwrap().unwrap();
3478 let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
3479 let empty_whitelist = empty_aa.effective_whitelist();
3480 assert!(
3481 is_safe_command("cargo test", &empty_whitelist),
3482 "empty safe_commands must keep built-in defaults"
3483 );
3484 assert!(
3485 is_safe_command("cargo fmt --check", &empty_whitelist),
3486 "empty safe_commands must keep `cargo fmt` default"
3487 );
3488 assert!(
3490 !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
3491 "empty safe_commands must not whitelist arbitrary commands"
3492 );
3493 }
3494
3495 #[test]
3498 fn conflict_config_defaults_match_spec() {
3499 let cfg = ConflictConfig::default();
3500 assert_eq!(cfg.window_seconds, 120);
3501 assert!(cfg.warn_on_intent_overlap);
3502 assert!(cfg.escalate_on_violation);
3503 }
3504
3505 #[test]
3506 fn supervisor_with_no_conflict_section_loads_defaults() {
3507 let tmp = TempDir::new().unwrap();
3508 let path = tmp.path().join("config.toml");
3509 write_file(&path, "[supervisor]\nenabled = true\n");
3510 let supervisor = load_config_file(&path)
3511 .unwrap()
3512 .unwrap()
3513 .supervisor
3514 .unwrap();
3515 assert_eq!(supervisor.conflict.window_seconds, 120);
3516 assert!(supervisor.conflict.warn_on_intent_overlap);
3517 assert!(supervisor.conflict.escalate_on_violation);
3518 }
3519
3520 #[test]
3521 fn conflict_section_with_all_fields_overrides_defaults() {
3522 let tmp = TempDir::new().unwrap();
3523 let path = tmp.path().join("config.toml");
3524 write_file(
3525 &path,
3526 "[supervisor]\n\
3527 enabled = true\n\
3528 [supervisor.conflict]\n\
3529 window_seconds = 300\n\
3530 warn_on_intent_overlap = false\n\
3531 escalate_on_violation = false\n",
3532 );
3533 let conflict = load_config_file(&path)
3534 .unwrap()
3535 .unwrap()
3536 .supervisor
3537 .unwrap()
3538 .conflict;
3539 assert_eq!(conflict.window_seconds, 300);
3540 assert!(!conflict.warn_on_intent_overlap);
3541 assert!(!conflict.escalate_on_violation);
3542 }
3543
3544 #[test]
3545 fn conflict_section_with_partial_fields_keeps_other_defaults() {
3546 let tmp = TempDir::new().unwrap();
3547 let path = tmp.path().join("config.toml");
3548 write_file(
3549 &path,
3550 "[supervisor]\n[supervisor.conflict]\nwindow_seconds = 60\n",
3551 );
3552 let conflict = load_config_file(&path)
3553 .unwrap()
3554 .unwrap()
3555 .supervisor
3556 .unwrap()
3557 .conflict;
3558 assert_eq!(conflict.window_seconds, 60);
3559 assert!(conflict.warn_on_intent_overlap);
3560 assert!(conflict.escalate_on_violation);
3561 }
3562
3563 #[test]
3564 fn pre_v05_config_without_conflict_section_loads() {
3565 let tmp = TempDir::new().unwrap();
3566 let path = tmp.path().join("config.toml");
3567 write_file(
3569 &path,
3570 "default_cli = \"claude\"\n\
3571 [supervisor]\n\
3572 enabled = true\n\
3573 agent_approval = \"auto\"\n",
3574 );
3575 let config = load_config_file(&path).unwrap().unwrap();
3576 let supervisor = config.supervisor.unwrap();
3577 assert!(supervisor.enabled);
3578 assert_eq!(supervisor.conflict, ConflictConfig::default());
3580 }
3581
3582 #[test]
3583 fn conflict_config_round_trips_through_save_and_load() {
3584 let tmp = TempDir::new().unwrap();
3585 let config_path = tmp.path().join("config.toml");
3586 let original = PawConfig {
3587 supervisor: Some(SupervisorConfig {
3588 enabled: true,
3589 conflict: ConflictConfig {
3590 window_seconds: 90,
3591 warn_on_intent_overlap: false,
3592 escalate_on_violation: true,
3593 },
3594 ..Default::default()
3595 }),
3596 ..Default::default()
3597 };
3598 save_config_to(&config_path, &original).unwrap();
3599 let loaded = load_config_file(&config_path).unwrap().unwrap();
3600 assert_eq!(loaded.supervisor, original.supervisor);
3601 }
3602
3603 #[test]
3604 fn v030_config_loads_without_auto_approve() {
3605 let tmp = TempDir::new().unwrap();
3608 let path = tmp.path().join("config.toml");
3609 write_file(
3610 &path,
3611 "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
3612 );
3613 let config = load_config_file(&path).unwrap().unwrap();
3614 assert!(config.supervisor.is_none());
3615 assert!(config.broker.enabled);
3616 }
3617
3618 fn write_repo_config(repo_root: &Path, toml: &str) {
3624 write_file(&repo_config_path(repo_root), toml);
3625 }
3626
3627 fn missing_global(tmp: &TempDir) -> PathBuf {
3628 tmp.path().join("nonexistent-global").join("config.toml")
3629 }
3630
3631 #[test]
3633 fn governance_defaults_to_all_none_when_section_absent() {
3634 let tmp = TempDir::new().unwrap();
3635 let path = tmp.path().join("config.toml");
3636 write_file(&path, "default_cli = \"claude\"\n");
3637
3638 let config = load_config_file(&path).unwrap().unwrap();
3639 assert!(config.governance.adr.is_none());
3640 assert!(config.governance.test_strategy.is_none());
3641 assert!(config.governance.security.is_none());
3642 assert!(config.governance.dod.is_none());
3643 assert!(config.governance.constitution.is_none());
3644 }
3645
3646 #[test]
3648 fn governance_all_paths_populated() {
3649 let tmp = TempDir::new().unwrap();
3650 let path = tmp.path().join("config.toml");
3651 write_file(
3652 &path,
3653 "[governance]\n\
3654 adr = \"docs/adr\"\n\
3655 test_strategy = \"docs/test-strategy.md\"\n\
3656 security = \"docs/security-checklist.md\"\n\
3657 dod = \"docs/definition-of-done.md\"\n\
3658 constitution = \".specify/memory/constitution.md\"\n",
3659 );
3660
3661 let config = load_config_file(&path).unwrap().unwrap();
3662 assert_eq!(
3663 config.governance.adr.as_deref(),
3664 Some(Path::new("docs/adr"))
3665 );
3666 assert_eq!(
3667 config.governance.test_strategy.as_deref(),
3668 Some(Path::new("docs/test-strategy.md"))
3669 );
3670 assert_eq!(
3671 config.governance.security.as_deref(),
3672 Some(Path::new("docs/security-checklist.md"))
3673 );
3674 assert_eq!(
3675 config.governance.dod.as_deref(),
3676 Some(Path::new("docs/definition-of-done.md"))
3677 );
3678 assert_eq!(
3679 config.governance.constitution.as_deref(),
3680 Some(Path::new(".specify/memory/constitution.md"))
3681 );
3682 }
3683
3684 #[test]
3686 fn governance_partial_paths_only_some_fields_populated() {
3687 let tmp = TempDir::new().unwrap();
3688 let path = tmp.path().join("config.toml");
3689 write_file(
3690 &path,
3691 "[governance]\n\
3692 dod = \"docs/dod.md\"\n\
3693 security = \"docs/security.md\"\n",
3694 );
3695
3696 let config = load_config_file(&path).unwrap().unwrap();
3697 assert_eq!(
3698 config.governance.dod.as_deref(),
3699 Some(Path::new("docs/dod.md"))
3700 );
3701 assert_eq!(
3702 config.governance.security.as_deref(),
3703 Some(Path::new("docs/security.md"))
3704 );
3705 assert!(config.governance.adr.is_none());
3706 assert!(config.governance.test_strategy.is_none());
3707 assert!(config.governance.constitution.is_none());
3708 }
3709
3710 #[test]
3712 fn governance_absolute_path_preserved_as_is() {
3713 let tmp = TempDir::new().unwrap();
3714 let path = tmp.path().join("config.toml");
3715 write_file(&path, "[governance]\nadr = \"/absolute/path/to/adr\"\n");
3716
3717 let config = load_config_file(&path).unwrap().unwrap();
3718 assert_eq!(
3719 config.governance.adr,
3720 Some(PathBuf::from("/absolute/path/to/adr"))
3721 );
3722 }
3723
3724 #[test]
3726 fn governance_nonexistent_path_loads_cleanly() {
3727 let tmp = TempDir::new().unwrap();
3728 let path = tmp.path().join("config.toml");
3729 write_file(&path, "[governance]\ndod = \"docs/never-existed.md\"\n");
3730
3731 let config = load_config_file(&path).unwrap().unwrap();
3732 assert_eq!(
3733 config.governance.dod,
3734 Some(PathBuf::from("docs/never-existed.md"))
3735 );
3736 }
3737
3738 #[test]
3740 fn governance_round_trips_through_save_and_load() {
3741 let tmp = TempDir::new().unwrap();
3742 let config_path = tmp.path().join("config.toml");
3743
3744 let original = PawConfig {
3745 governance: GovernanceConfig {
3746 adr: Some(PathBuf::from("docs/adr")),
3747 test_strategy: Some(PathBuf::from("docs/test-strategy.md")),
3748 security: Some(PathBuf::from("docs/security.md")),
3749 dod: Some(PathBuf::from("docs/dod.md")),
3750 constitution: Some(PathBuf::from(".specify/memory/constitution.md")),
3751 readme: Some(PathBuf::from("README.md")),
3752 docs: Some(PathBuf::from("docs/src")),
3753 },
3754 ..Default::default()
3755 };
3756
3757 save_config_to(&config_path, &original).unwrap();
3758 let loaded = load_config_file(&config_path).unwrap().unwrap();
3759 assert_eq!(loaded.governance, original.governance);
3760 }
3761
3762 #[test]
3764 fn governance_v04_config_without_section_loads_with_defaults() {
3765 let tmp = TempDir::new().unwrap();
3766 let path = tmp.path().join("config.toml");
3767 write_file(
3768 &path,
3769 "default_cli = \"claude\"\n\
3770 mouse = true\n\
3771 [broker]\n\
3772 enabled = true\n\
3773 [supervisor]\n\
3774 enabled = true\n\
3775 [specs]\n\
3776 dir = \"specs\"\n\
3777 type = \"openspec\"\n\
3778 [clis.foo]\n\
3779 command = \"/bin/foo\"\n",
3780 );
3781
3782 let config = load_config_file(&path).unwrap().unwrap();
3783 assert_eq!(config.governance, GovernanceConfig::default());
3784 assert!(config.governance.adr.is_none());
3785 assert!(config.governance.test_strategy.is_none());
3786 assert!(config.governance.security.is_none());
3787 assert!(config.governance.dod.is_none());
3788 assert!(config.governance.constitution.is_none());
3789 assert!(config.governance.readme.is_none());
3790 assert!(config.governance.docs.is_none());
3791 }
3792
3793 #[test]
3796 fn governance_default_has_only_path_fields() {
3797 let GovernanceConfig {
3801 adr,
3802 test_strategy,
3803 security,
3804 dod,
3805 constitution,
3806 readme,
3807 docs,
3808 } = GovernanceConfig::default();
3809 assert!(adr.is_none());
3810 assert!(test_strategy.is_none());
3811 assert!(security.is_none());
3812 assert!(dod.is_none());
3813 assert!(constitution.is_none());
3814 assert!(readme.is_none());
3815 assert!(docs.is_none());
3816 }
3817
3818 #[test]
3820 fn governance_parses_readme_and_docs_fields() {
3821 let tmp = TempDir::new().unwrap();
3822 let path = tmp.path().join("config.toml");
3823 write_file(
3824 &path,
3825 "[governance]\n\
3826 readme = \"README.md\"\n\
3827 docs = \"docs/src\"\n",
3828 );
3829 let config = load_config_file(&path).unwrap().unwrap();
3830 assert_eq!(config.governance.readme, Some(PathBuf::from("README.md")));
3831 assert_eq!(config.governance.docs, Some(PathBuf::from("docs/src")));
3832 }
3833
3834 #[test]
3836 fn governance_readme_and_docs_default_to_none_when_omitted() {
3837 let tmp = TempDir::new().unwrap();
3838 let path = tmp.path().join("config.toml");
3839 write_file(&path, "[governance]\ndod = \"docs/dod.md\"\n");
3840 let config = load_config_file(&path).unwrap().unwrap();
3841 assert!(config.governance.readme.is_none());
3842 assert!(config.governance.docs.is_none());
3843 assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
3844 }
3845
3846 #[test]
3848 fn governance_readme_and_docs_round_trip() {
3849 let original = GovernanceConfig {
3850 readme: Some(PathBuf::from("README.md")),
3851 docs: Some(PathBuf::from("docs/src")),
3852 ..Default::default()
3853 };
3854 let toml_str = toml::to_string(&original).unwrap();
3855 let reparsed: GovernanceConfig = toml::from_str(&toml_str).unwrap();
3856 assert_eq!(reparsed.readme, original.readme);
3857 assert_eq!(reparsed.docs, original.docs);
3858 }
3859
3860 #[test]
3862 fn governance_auto_wires_constitution_when_speckit_detected() {
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_config_from(&missing_global(&tmp), &repo_root).unwrap();
3881 assert_eq!(
3882 config.governance.constitution.as_deref(),
3883 Some(constitution.as_path())
3884 );
3885 }
3886
3887 #[test]
3889 fn governance_explicit_constitution_preserved_over_auto_wiring() {
3890 let tmp = TempDir::new().unwrap();
3891 let repo_root = tmp.path().join("repo");
3892 let specify = repo_root.join(".specify");
3893 let specs = specify.join("specs");
3894 let memory = specify.join("memory");
3895 fs::create_dir_all(&specs).unwrap();
3896 fs::create_dir_all(&memory).unwrap();
3897 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3898
3899 write_repo_config(
3900 &repo_root,
3901 "[specs]\n\
3902 type = \"speckit\"\n\
3903 dir = \".specify/specs\"\n\
3904 [governance]\n\
3905 constitution = \"docs/principles.md\"\n",
3906 );
3907
3908 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3909 assert_eq!(
3910 config.governance.constitution,
3911 Some(PathBuf::from("docs/principles.md"))
3912 );
3913 }
3914
3915 #[test]
3917 fn governance_auto_wiring_skipped_when_specs_type_is_openspec() {
3918 let tmp = TempDir::new().unwrap();
3919 let repo_root = tmp.path().join("repo");
3920 let specify = repo_root.join(".specify");
3921 let memory = specify.join("memory");
3922 fs::create_dir_all(&memory).unwrap();
3923 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3924 fs::create_dir_all(repo_root.join("specs")).unwrap();
3925
3926 write_repo_config(
3927 &repo_root,
3928 "[specs]\n\
3929 type = \"openspec\"\n\
3930 dir = \"specs\"\n",
3931 );
3932
3933 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3934 assert!(config.governance.constitution.is_none());
3935 }
3936
3937 #[test]
3939 fn governance_auto_wiring_skipped_when_specs_section_absent() {
3940 let tmp = TempDir::new().unwrap();
3941 let repo_root = tmp.path().join("repo");
3942 let memory = repo_root.join(".specify").join("memory");
3943 fs::create_dir_all(&memory).unwrap();
3944 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3945 fs::create_dir_all(repo_root.join(".git-paw")).unwrap();
3946
3947 write_repo_config(&repo_root, "default_cli = \"claude\"\n");
3948
3949 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3950 assert!(config.governance.constitution.is_none());
3951 }
3952
3953 #[test]
3955 fn governance_auto_wiring_skipped_when_constitution_md_absent() {
3956 let tmp = TempDir::new().unwrap();
3957 let repo_root = tmp.path().join("repo");
3958 let specs = repo_root.join(".specify").join("specs");
3959 fs::create_dir_all(&specs).unwrap();
3960 write_repo_config(
3963 &repo_root,
3964 "[specs]\n\
3965 type = \"speckit\"\n\
3966 dir = \".specify/specs\"\n",
3967 );
3968
3969 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3970 assert!(config.governance.constitution.is_none());
3971 }
3972
3973 #[test]
3975 fn governance_explicit_empty_string_constitution_suppresses_auto_wiring() {
3976 let tmp = TempDir::new().unwrap();
3977 let repo_root = tmp.path().join("repo");
3978 let specify = repo_root.join(".specify");
3979 let specs = specify.join("specs");
3980 let memory = specify.join("memory");
3981 fs::create_dir_all(&specs).unwrap();
3982 fs::create_dir_all(&memory).unwrap();
3983 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
3984
3985 write_repo_config(
3986 &repo_root,
3987 "[specs]\n\
3988 type = \"speckit\"\n\
3989 dir = \".specify/specs\"\n\
3990 [governance]\n\
3991 constitution = \"\"\n",
3992 );
3993
3994 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
3995 assert_eq!(config.governance.constitution, Some(PathBuf::from("")));
3996 }
3997
3998 #[test]
4000 fn governance_merge_fields_independently_across_global_and_repo() {
4001 let tmp = TempDir::new().unwrap();
4002 let global_path = tmp.path().join("global").join("config.toml");
4003 let repo_root = tmp.path().join("repo");
4004 fs::create_dir_all(&repo_root).unwrap();
4005
4006 write_file(&global_path, "[governance]\nadr = \"docs/adr\"\n");
4007 write_file(
4008 &repo_config_path(&repo_root),
4009 "[governance]\ndod = \"docs/dod.md\"\n",
4010 );
4011
4012 let config = load_config_from(&global_path, &repo_root).unwrap();
4013 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/adr")));
4014 assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
4015 }
4016
4017 #[test]
4019 fn governance_merge_repo_wins_per_field_when_both_set() {
4020 let tmp = TempDir::new().unwrap();
4021 let global_path = tmp.path().join("global").join("config.toml");
4022 let repo_root = tmp.path().join("repo");
4023 fs::create_dir_all(&repo_root).unwrap();
4024
4025 write_file(&global_path, "[governance]\nadr = \"docs/global-adr\"\n");
4026 write_file(
4027 &repo_config_path(&repo_root),
4028 "[governance]\nadr = \"docs/repo-adr\"\n",
4029 );
4030
4031 let config = load_config_from(&global_path, &repo_root).unwrap();
4032 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/repo-adr")));
4033 }
4034
4035 #[test]
4037 fn governance_load_repo_config_also_auto_wires_constitution() {
4038 let tmp = TempDir::new().unwrap();
4039 let repo_root = tmp.path().join("repo");
4040 let specify = repo_root.join(".specify");
4041 let specs = specify.join("specs");
4042 let memory = specify.join("memory");
4043 fs::create_dir_all(&specs).unwrap();
4044 fs::create_dir_all(&memory).unwrap();
4045 let constitution = memory.join("constitution.md");
4046 fs::write(&constitution, "# Constitution\n").unwrap();
4047
4048 write_repo_config(
4049 &repo_root,
4050 "[specs]\n\
4051 type = \"speckit\"\n\
4052 dir = \".specify/specs\"\n",
4053 );
4054
4055 let config = load_repo_config(&repo_root).unwrap();
4056 assert_eq!(
4057 config.governance.constitution.as_deref(),
4058 Some(constitution.as_path())
4059 );
4060 }
4061
4062 #[test]
4065 fn load_config_with_some_pins_global_to_override_path() {
4066 let tmp = TempDir::new().unwrap();
4067 let repo_root = tmp.path().join("repo");
4068 fs::create_dir_all(&repo_root).unwrap();
4069
4070 let global_a = tmp.path().join("global-A.toml");
4071 let global_b = tmp.path().join("global-B.toml");
4072 write_file(&global_a, "[clis.cli-A]\ncommand = \"/bin/a\"\n");
4073 write_file(&global_b, "[clis.cli-B]\ncommand = \"/bin/b\"\n");
4074
4075 let config = load_config(&repo_root, Some(&global_a)).unwrap();
4076 assert!(config.clis.contains_key("cli-A"));
4077 assert!(!config.clis.contains_key("cli-B"));
4078 }
4079
4080 #[test]
4081 fn load_config_with_some_nonexistent_returns_defaults() {
4082 let tmp = TempDir::new().unwrap();
4083 let repo_root = tmp.path().join("repo");
4084 fs::create_dir_all(&repo_root).unwrap();
4085 let missing = tmp.path().join("does-not-exist.toml");
4086
4087 let config = load_config(&repo_root, Some(&missing)).unwrap();
4088 assert_eq!(config, PawConfig::default());
4089 }
4090
4091 #[test]
4101 fn load_config_override_does_not_affect_repo_resolution() {
4102 let tmp = TempDir::new().unwrap();
4103 let repo_root = tmp.path().join("repo");
4104 fs::create_dir_all(&repo_root).unwrap();
4105 write_file(&repo_config_path(&repo_root), "default_cli = \"claude\"\n");
4106
4107 let global_path = tmp.path().join("global.toml");
4108 write_file(&global_path, "default_cli = \"gemini\"\n");
4109
4110 let config = load_config(&repo_root, Some(&global_path)).unwrap();
4111 assert_eq!(config.default_cli.as_deref(), Some("claude"));
4112 }
4113
4114 #[test]
4121 fn governance_config_rejects_gates_field() {
4122 let toml_input = "[governance]\ndod = \"docs/dod.md\"\n[governance.gates]\ndod = true\n";
4123 let cfg: PawConfig = toml::from_str(toml_input).expect("toml parse");
4124 let gov = cfg.governance;
4125 assert_eq!(gov.dod.as_deref(), Some(Path::new("docs/dod.md")));
4126
4127 let round_trip = toml::to_string(&gov).expect("serialise gov");
4128 assert!(
4129 !round_trip.contains("gates"),
4130 "GovernanceConfig must not round-trip a `gates` field; got: {round_trip}"
4131 );
4132 assert!(
4133 !round_trip.contains("[governance.gates]"),
4134 "GovernanceConfig must not round-trip a `[governance.gates]` section; got: {round_trip}"
4135 );
4136 }
4137
4138 #[test]
4146 fn border_affordances_defaults_to_true_when_layout_absent() {
4147 let cfg: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("toml parse");
4148 assert!(
4149 cfg.layout.is_none(),
4150 "no [layout] section should parse as None"
4151 );
4152 assert!(
4153 cfg.border_affordances_enabled(),
4154 "border affordances default to on when [layout] is absent"
4155 );
4156 }
4157
4158 #[test]
4161 fn border_affordances_defaults_to_true_when_field_unset() {
4162 let cfg: PawConfig = toml::from_str("[layout]\n").expect("toml parse");
4163 assert!(
4164 cfg.border_affordances_enabled(),
4165 "border affordances default to on when the field is unset"
4166 );
4167 }
4168
4169 #[test]
4171 fn border_affordances_explicit_false_resolves_off() {
4172 let cfg: PawConfig =
4173 toml::from_str("[layout]\nborder_affordances = false\n").expect("toml parse");
4174 assert_eq!(cfg.layout.as_ref().unwrap().border_affordances, Some(false));
4175 assert!(
4176 !cfg.border_affordances_enabled(),
4177 "explicit false must resolve to off"
4178 );
4179 }
4180
4181 #[test]
4183 fn border_affordances_explicit_true_resolves_on() {
4184 let cfg: PawConfig =
4185 toml::from_str("[layout]\nborder_affordances = true\n").expect("toml parse");
4186 assert!(cfg.border_affordances_enabled());
4187 }
4188
4189 #[test]
4192 fn v0_5_0_config_without_layout_parses() {
4193 let v0_5_0 = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
4194 let cfg: PawConfig = toml::from_str(v0_5_0).expect("v0.5.0 config must still parse");
4195 assert!(cfg.layout.is_none());
4196 assert!(cfg.border_affordances_enabled());
4197 }
4198
4199 #[test]
4201 fn layout_overlay_wins_in_merge() {
4202 let base: PawConfig =
4203 toml::from_str("[layout]\nborder_affordances = true\n").expect("base");
4204 let overlay: PawConfig =
4205 toml::from_str("[layout]\nborder_affordances = false\n").expect("overlay");
4206 let merged = base.merged_with(&overlay);
4207 assert!(
4208 !merged.border_affordances_enabled(),
4209 "overlay [layout] must win in the merge"
4210 );
4211 }
4212
4213 #[test]
4215 fn layout_base_preserved_when_overlay_absent() {
4216 let base: PawConfig =
4217 toml::from_str("[layout]\nborder_affordances = false\n").expect("base");
4218 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4219 let merged = base.merged_with(&overlay);
4220 assert!(
4221 !merged.border_affordances_enabled(),
4222 "base [layout] must survive when the overlay has none"
4223 );
4224 }
4225
4226 #[test]
4229 fn role_gating_defaults_to_warn_when_section_absent() {
4230 let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
4233 assert!(config.opsx.is_none());
4234 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4235 }
4236
4237 #[test]
4238 fn role_gating_section_present_but_field_absent_resolves_warn() {
4239 let config: PawConfig = toml::from_str("[opsx]\n").expect("parses");
4240 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4241 }
4242
4243 #[test]
4244 fn role_gating_explicit_warn() {
4245 let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("parses");
4246 assert_eq!(config.role_gating_mode(), RoleGatingMode::Warn);
4247 }
4248
4249 #[test]
4250 fn role_gating_explicit_block() {
4251 let config: PawConfig =
4252 toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("parses");
4253 assert_eq!(config.role_gating_mode(), RoleGatingMode::Block);
4254 }
4255
4256 #[test]
4257 fn role_gating_explicit_off() {
4258 let config: PawConfig = toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("parses");
4259 assert_eq!(config.role_gating_mode(), RoleGatingMode::Off);
4260 }
4261
4262 #[test]
4263 fn role_gating_invalid_value_is_a_parse_error() {
4264 let err = toml::from_str::<PawConfig>("[opsx]\nrole_gating = \"loud\"\n").unwrap_err();
4265 assert!(
4266 err.to_string().contains("role_gating") || err.to_string().contains("variant"),
4267 "got: {err}"
4268 );
4269 }
4270
4271 #[test]
4272 fn role_gating_mode_round_trips_through_toml() {
4273 let config = PawConfig {
4274 opsx: Some(OpsxConfig {
4275 role_gating: Some(RoleGatingMode::Block),
4276 }),
4277 ..Default::default()
4278 };
4279 let serialized = toml::to_string(&config).expect("serializes");
4280 assert!(
4281 serialized.contains("role_gating = \"block\""),
4282 "got: {serialized}"
4283 );
4284 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4285 assert_eq!(reparsed.role_gating_mode(), RoleGatingMode::Block);
4286 }
4287
4288 #[test]
4289 fn opsx_section_merges_with_overlay_winning() {
4290 let base: PawConfig =
4291 toml::from_str("[opsx]\nrole_gating = \"warn\"\n").expect("base parses");
4292 let overlay: PawConfig =
4293 toml::from_str("[opsx]\nrole_gating = \"block\"\n").expect("overlay parses");
4294 let merged = base.merged_with(&overlay);
4295 assert_eq!(merged.role_gating_mode(), RoleGatingMode::Block);
4296 }
4297
4298 #[test]
4299 fn opsx_section_base_preserved_when_overlay_absent() {
4300 let base: PawConfig =
4301 toml::from_str("[opsx]\nrole_gating = \"off\"\n").expect("base parses");
4302 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4303 let merged = base.merged_with(&overlay);
4304 assert_eq!(merged.role_gating_mode(), RoleGatingMode::Off);
4305 }
4306
4307 #[test]
4308 fn supervisor_auto_revert_defaults_false() {
4309 let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4310 let sup = config.supervisor.expect("supervisor present");
4311 assert!(!sup.auto_revert(), "auto_revert defaults to false");
4312 }
4313
4314 #[test]
4315 fn supervisor_auto_revert_explicit_true() {
4316 let config: PawConfig =
4317 toml::from_str("[supervisor]\nenabled = true\nauto_revert = true\n").expect("parses");
4318 let sup = config.supervisor.expect("supervisor present");
4319 assert!(sup.auto_revert());
4320 }
4321
4322 #[test]
4325 fn tell_config_defaults_when_table_absent() {
4326 let config: PawConfig = toml::from_str("[supervisor]\nenabled = true\n").expect("parses");
4329 let sup = config.supervisor.expect("supervisor present");
4330 assert_eq!(sup.tell.mode, TellMode::Feedback);
4331 assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4332 assert!(sup.tell.is_default());
4333 }
4334
4335 #[test]
4336 fn tell_config_explicit_feedback_loads() {
4337 let config: PawConfig = toml::from_str(
4338 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"feedback\"\n",
4339 )
4340 .expect("parses");
4341 let sup = config.supervisor.expect("supervisor present");
4342 assert_eq!(sup.tell.mode, TellMode::Feedback);
4343 assert_eq!(sup.tell.inventory_max_age_seconds, 60);
4345 }
4346
4347 #[test]
4348 fn tell_config_explicit_send_keys_loads() {
4349 let config: PawConfig = toml::from_str(
4350 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"send-keys\"\ninventory_max_age_seconds = 15\n",
4351 )
4352 .expect("parses");
4353 let sup = config.supervisor.expect("supervisor present");
4354 assert_eq!(sup.tell.mode, TellMode::SendKeys);
4355 assert_eq!(sup.tell.inventory_max_age_seconds, 15);
4356 assert!(!sup.tell.is_default());
4357 }
4358
4359 #[test]
4360 fn tell_config_rejects_unknown_mode() {
4361 let err = toml::from_str::<PawConfig>(
4362 "[supervisor]\nenabled = true\n[supervisor.tell]\nmode = \"shout\"\n",
4363 )
4364 .unwrap_err();
4365 assert!(
4366 err.to_string().contains("shout") || err.to_string().contains("mode"),
4367 "unknown mode should be a parse error; got {err}"
4368 );
4369 }
4370
4371 #[test]
4372 fn tell_config_all_default_table_round_trips_without_emitting_tell() {
4373 let sup = SupervisorConfig {
4376 enabled: true,
4377 ..SupervisorConfig::default()
4378 };
4379 let config = PawConfig {
4380 supervisor: Some(sup),
4381 ..PawConfig::default()
4382 };
4383 let serialized = toml::to_string_pretty(&config).expect("serializes");
4384 assert!(
4385 !serialized.contains("[supervisor.tell]"),
4386 "all-default tell table must be omitted; got:\n{serialized}"
4387 );
4388 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4389 assert_eq!(config, reparsed);
4390 }
4391
4392 #[test]
4396 fn mcp_name_parses_to_some() {
4397 let config: PawConfig = toml::from_str("[mcp]\nname = \"my-project\"\n").expect("parses");
4398 assert_eq!(config.mcp.name, Some("my-project".to_string()));
4399 assert_eq!(config.mcp_server_name(), "my-project");
4400 }
4401
4402 #[test]
4405 fn mcp_section_absent_defaults_to_none() {
4406 let config: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parses");
4407 assert_eq!(config.mcp, McpConfig::default());
4408 assert!(config.mcp.name.is_none());
4409 assert_eq!(config.mcp_server_name(), "git-paw");
4410 }
4411
4412 #[test]
4415 fn pre_existing_config_without_mcp_loads() {
4416 let prior = "default_cli = \"claude\"\nmouse = true\n\n[broker]\nenabled = true\nport = 9119\n\n[supervisor]\nenabled = true\n";
4417 let config: PawConfig = toml::from_str(prior).expect("prior config must still parse");
4418 assert_eq!(config.mcp, McpConfig::default());
4419 }
4420
4421 #[test]
4424 fn mcp_config_round_trips_through_toml() {
4425 let config = PawConfig {
4426 mcp: McpConfig {
4427 name: Some("my-project".to_string()),
4428 },
4429 ..PawConfig::default()
4430 };
4431 let serialized = toml::to_string(&config).expect("serializes");
4432 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4433 assert_eq!(reparsed.mcp, config.mcp);
4434 }
4435
4436 #[test]
4439 fn mcp_default_omits_name_on_serialize() {
4440 let config = PawConfig::default();
4441 let serialized = toml::to_string_pretty(&config).expect("serializes");
4442 assert!(
4443 !serialized.contains("name ="),
4444 "default [mcp] must not emit a name; got:\n{serialized}"
4445 );
4446 let reparsed: PawConfig = toml::from_str(&serialized).expect("re-parses");
4447 assert_eq!(config, reparsed);
4448 }
4449
4450 #[test]
4452 fn mcp_overlay_name_wins_in_merge() {
4453 let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
4454 let overlay: PawConfig = toml::from_str("[mcp]\nname = \"repo-name\"\n").expect("overlay");
4455 let merged = base.merged_with(&overlay);
4456 assert_eq!(merged.mcp.name, Some("repo-name".to_string()));
4457 }
4458
4459 #[test]
4461 fn mcp_base_name_preserved_when_overlay_absent() {
4462 let base: PawConfig = toml::from_str("[mcp]\nname = \"global-name\"\n").expect("base");
4463 let overlay: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("overlay");
4464 let merged = base.merged_with(&overlay);
4465 assert_eq!(merged.mcp.name, Some("global-name".to_string()));
4466 }
4467
4468 #[test]
4471 fn worktree_placement_parses_child() {
4472 let cfg: PawConfig =
4473 toml::from_str("worktree_placement = \"child\"\n").expect("parse child");
4474 assert_eq!(cfg.worktree_placement, Some(WorktreePlacement::Child));
4475 assert_eq!(cfg.worktree_placement(), WorktreePlacement::Child);
4476 }
4477
4478 #[test]
4479 fn worktree_placement_parses_sibling() {
4480 let cfg: PawConfig =
4481 toml::from_str("worktree_placement = \"sibling\"\n").expect("parse sibling");
4482 assert_eq!(cfg.worktree_placement, Some(WorktreePlacement::Sibling));
4483 assert_eq!(cfg.worktree_placement(), WorktreePlacement::Sibling);
4484 }
4485
4486 #[test]
4487 fn worktree_placement_absent_defaults_to_sibling() {
4488 let cfg: PawConfig = toml::from_str("default_cli = \"claude\"\n").expect("parse");
4489 assert_eq!(cfg.worktree_placement, None);
4490 assert_eq!(cfg.worktree_placement(), WorktreePlacement::Sibling);
4491 }
4492
4493 #[test]
4494 fn worktree_placement_repo_overrides_global() {
4495 let tmp = TempDir::new().unwrap();
4496 let global_path = tmp.path().join("global").join("config.toml");
4497 let repo_root = tmp.path().join("repo");
4498 fs::create_dir_all(&repo_root).unwrap();
4499
4500 write_file(&global_path, "worktree_placement = \"sibling\"\n");
4501 write_file(
4502 &repo_config_path(&repo_root),
4503 "worktree_placement = \"child\"\n",
4504 );
4505
4506 let config = load_config_from(&global_path, &repo_root).unwrap();
4507 assert_eq!(config.worktree_placement(), WorktreePlacement::Child);
4508 }
4509
4510 #[test]
4511 fn worktree_placement_survives_round_trip() {
4512 let cfg = PawConfig {
4513 worktree_placement: Some(WorktreePlacement::Child),
4514 ..PawConfig::default()
4515 };
4516 let serialized = toml::to_string_pretty(&cfg).expect("serialize");
4517 let reparsed: PawConfig = toml::from_str(&serialized).expect("reparse");
4518 assert_eq!(reparsed.worktree_placement(), WorktreePlacement::Child);
4519 }
4520
4521 #[test]
4522 fn worktree_placement_default_skipped_on_serialize() {
4523 let cfg = PawConfig::default();
4526 let serialized = toml::to_string_pretty(&cfg).expect("serialize");
4527 assert!(
4528 !serialized.contains("worktree_placement"),
4529 "absent placement must not be serialized; got:\n{serialized}"
4530 );
4531 }
4532
4533 #[test]
4534 fn preexisting_config_without_placement_loads_without_error() {
4535 let prior = "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n";
4538 let cfg: PawConfig = toml::from_str(prior).expect("v0.7.0 config must load");
4539 assert_eq!(cfg.worktree_placement(), WorktreePlacement::Sibling);
4540 }
4541}