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}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct Preset {
28 pub branches: Vec<String>,
30 pub cli: String,
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
51pub struct GovernanceConfig {
52 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub adr: Option<PathBuf>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub test_strategy: Option<PathBuf>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub security: Option<PathBuf>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub dod: Option<PathBuf>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub constitution: Option<PathBuf>,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
76pub struct SpecsConfig {
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub dir: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
82 pub spec_type: Option<String>,
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
87pub struct LoggingConfig {
88 #[serde(default)]
90 pub enabled: bool,
91}
92
93#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "kebab-case")]
106pub enum ApprovalLevel {
107 Manual,
109 #[default]
111 Auto,
112 FullAuto,
114}
115
116#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
118pub struct DashboardConfig {
119 #[serde(default)]
121 pub show_message_log: bool,
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
130pub struct SupervisorConfig {
131 #[serde(default)]
133 pub enabled: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub cli: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub test_command: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub lint_command: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub build_command: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub doc_build_command: Option<String>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub spec_validate_command: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub fmt_check_command: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub security_audit_command: Option<String>,
198 #[serde(default)]
200 pub agent_approval: ApprovalLevel,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub auto_approve: Option<AutoApproveConfig>,
208 #[serde(default)]
216 pub conflict: ConflictConfig,
217 #[serde(default)]
224 pub learnings: bool,
225 #[serde(default)]
232 pub learnings_config: LearningsConfig,
233 #[serde(default)]
240 pub common_dev_allowlist: CommonDevAllowlistConfig,
241}
242
243impl SupervisorConfig {
244 #[must_use]
248 pub fn gate_commands(&self) -> crate::skills::GateCommands<'_> {
249 crate::skills::GateCommands {
250 test_command: self.test_command.as_deref(),
251 lint_command: self.lint_command.as_deref(),
252 build_command: self.build_command.as_deref(),
253 doc_build_command: self.doc_build_command.as_deref(),
254 spec_validate_command: self.spec_validate_command.as_deref(),
255 fmt_check_command: self.fmt_check_command.as_deref(),
256 security_audit_command: self.security_audit_command.as_deref(),
257 }
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270pub struct CommonDevAllowlistConfig {
271 #[serde(default = "CommonDevAllowlistConfig::default_enabled")]
277 pub enabled: bool,
278 #[serde(default)]
285 pub extra: Vec<String>,
286}
287
288impl Default for CommonDevAllowlistConfig {
289 fn default() -> Self {
290 Self {
291 enabled: Self::default_enabled(),
292 extra: Vec::new(),
293 }
294 }
295}
296
297impl CommonDevAllowlistConfig {
298 fn default_enabled() -> bool {
299 true
300 }
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
310pub struct LearningsConfig {
311 #[serde(default = "LearningsConfig::default_flush_interval_seconds")]
313 pub flush_interval_seconds: u64,
314}
315
316impl Default for LearningsConfig {
317 fn default() -> Self {
318 Self {
319 flush_interval_seconds: Self::default_flush_interval_seconds(),
320 }
321 }
322}
323
324impl LearningsConfig {
325 fn default_flush_interval_seconds() -> u64 {
326 60
327 }
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338pub struct ConflictConfig {
339 #[serde(default = "ConflictConfig::default_window_seconds")]
342 pub window_seconds: u64,
343 #[serde(default = "ConflictConfig::default_true")]
349 pub warn_on_intent_overlap: bool,
350 #[serde(default = "ConflictConfig::default_true")]
355 pub escalate_on_violation: bool,
356}
357
358impl Default for ConflictConfig {
359 fn default() -> Self {
360 Self {
361 window_seconds: Self::default_window_seconds(),
362 warn_on_intent_overlap: true,
363 escalate_on_violation: true,
364 }
365 }
366}
367
368impl ConflictConfig {
369 fn default_window_seconds() -> u64 {
370 120
371 }
372
373 fn default_true() -> bool {
374 true
375 }
376}
377
378#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
390#[serde(rename_all = "kebab-case")]
391pub enum ApprovalLevelPreset {
392 Off,
394 Conservative,
396 #[default]
398 Safe,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
412pub struct AutoApproveConfig {
413 #[serde(default = "AutoApproveConfig::default_enabled")]
415 pub enabled: bool,
416 #[serde(default)]
420 pub safe_commands: Vec<String>,
421 #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
424 pub stall_threshold_seconds: u64,
425 #[serde(default)]
432 pub approval_level: ApprovalLevelPreset,
433}
434
435impl Default for AutoApproveConfig {
436 fn default() -> Self {
437 Self {
438 enabled: Self::default_enabled(),
439 safe_commands: Vec::new(),
440 stall_threshold_seconds: Self::default_stall_threshold_seconds(),
441 approval_level: ApprovalLevelPreset::Safe,
442 }
443 }
444}
445
446impl AutoApproveConfig {
447 pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
450
451 fn default_enabled() -> bool {
452 true
453 }
454
455 fn default_stall_threshold_seconds() -> u64 {
456 30
457 }
458
459 #[must_use]
466 pub fn resolved(&self) -> Self {
467 let mut out = self.clone();
468 if out.approval_level == ApprovalLevelPreset::Off {
469 out.enabled = false;
470 }
471 if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
472 eprintln!(
473 "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
474 out.stall_threshold_seconds,
475 Self::MIN_STALL_THRESHOLD_SECONDS
476 );
477 out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
478 }
479 out
480 }
481
482 #[must_use]
489 pub fn effective_whitelist(&self) -> Vec<String> {
490 let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
491 .iter()
492 .map(|s| (*s).to_string())
493 .collect();
494 for extra in &self.safe_commands {
495 if !out.iter().any(|e| e == extra) {
496 out.push(extra.clone());
497 }
498 }
499 if self.approval_level == ApprovalLevelPreset::Conservative {
500 out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
501 }
502 out
503 }
504}
505
506#[must_use]
526pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
527 match (cli, level) {
528 ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
529 ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
530 ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
531 _ => "",
532 }
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
537pub struct BrokerConfig {
538 #[serde(default)]
540 pub enabled: bool,
541 #[serde(default = "BrokerConfig::default_port")]
543 pub port: u16,
544 #[serde(default = "BrokerConfig::default_bind")]
546 pub bind: String,
547}
548
549impl Default for BrokerConfig {
550 fn default() -> Self {
551 Self {
552 enabled: false,
553 port: 9119,
554 bind: "127.0.0.1".to_string(),
555 }
556 }
557}
558
559impl BrokerConfig {
560 pub fn url(&self) -> String {
562 format!("http://{}:{}", self.bind, self.port)
563 }
564
565 fn default_port() -> u16 {
566 9119
567 }
568
569 fn default_bind() -> String {
570 "127.0.0.1".to_string()
571 }
572}
573
574#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
578pub struct PawConfig {
579 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub default_cli: Option<String>,
582
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub default_spec_cli: Option<String>,
586
587 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub branch_prefix: Option<String>,
590
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub mouse: Option<bool>,
594
595 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
597 pub clis: HashMap<String, CustomCli>,
598
599 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
601 pub presets: HashMap<String, Preset>,
602
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub specs: Option<SpecsConfig>,
606
607 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub logging: Option<LoggingConfig>,
610
611 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub dashboard: Option<DashboardConfig>,
614
615 #[serde(default)]
617 pub broker: BrokerConfig,
618
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub supervisor: Option<SupervisorConfig>,
622
623 #[serde(default)]
629 pub governance: GovernanceConfig,
630}
631
632impl PawConfig {
633 #[must_use]
638 pub fn merged_with(&self, overlay: &Self) -> Self {
639 let mut clis = self.clis.clone();
640 for (k, v) in &overlay.clis {
641 clis.insert(k.clone(), v.clone());
642 }
643
644 let mut presets = self.presets.clone();
645 for (k, v) in &overlay.presets {
646 presets.insert(k.clone(), v.clone());
647 }
648
649 Self {
650 default_cli: overlay
651 .default_cli
652 .clone()
653 .or_else(|| self.default_cli.clone()),
654 default_spec_cli: overlay
655 .default_spec_cli
656 .clone()
657 .or_else(|| self.default_spec_cli.clone()),
658 branch_prefix: overlay
659 .branch_prefix
660 .clone()
661 .or_else(|| self.branch_prefix.clone()),
662 mouse: overlay.mouse.or(self.mouse),
663 clis,
664 presets,
665 specs: overlay.specs.clone().or_else(|| self.specs.clone()),
666 logging: overlay.logging.clone().or_else(|| self.logging.clone()),
667 dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
668 broker: if overlay.broker == BrokerConfig::default() {
669 self.broker.clone()
670 } else {
671 overlay.broker.clone()
672 },
673 supervisor: overlay
674 .supervisor
675 .clone()
676 .or_else(|| self.supervisor.clone()),
677 governance: GovernanceConfig {
678 adr: overlay
679 .governance
680 .adr
681 .clone()
682 .or_else(|| self.governance.adr.clone()),
683 test_strategy: overlay
684 .governance
685 .test_strategy
686 .clone()
687 .or_else(|| self.governance.test_strategy.clone()),
688 security: overlay
689 .governance
690 .security
691 .clone()
692 .or_else(|| self.governance.security.clone()),
693 dod: overlay
694 .governance
695 .dod
696 .clone()
697 .or_else(|| self.governance.dod.clone()),
698 constitution: overlay
699 .governance
700 .constitution
701 .clone()
702 .or_else(|| self.governance.constitution.clone()),
703 },
704 }
705 }
706
707 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
709 self.presets.get(name)
710 }
711
712 pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
714 self.dashboard.as_ref()
715 }
716}
717
718pub fn global_config_path() -> Result<PathBuf, PawError> {
720 crate::dirs::config_dir()
721 .map(|d| d.join("git-paw").join("config.toml"))
722 .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
723}
724
725pub fn repo_config_path(repo_root: &Path) -> PathBuf {
727 repo_root.join(".git-paw").join("config.toml")
728}
729
730fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
732 match fs::read_to_string(path) {
733 Ok(contents) => {
734 let config: PawConfig = toml::from_str(&contents)
735 .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
736 Ok(Some(config))
737 }
738 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
739 Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
740 }
741}
742
743pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
751 let mut config = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
752 auto_wire_governance(&mut config, repo_root);
753 Ok(config)
754}
755
756fn auto_wire_governance(config: &mut PawConfig, repo_root: &Path) {
774 if config.governance.constitution.is_some() {
775 return;
776 }
777 let Some(specs_cfg) = config.specs.as_ref() else {
778 return;
779 };
780 let Some(spec_type) = specs_cfg.spec_type.as_deref() else {
781 return;
782 };
783 if spec_type != "speckit" {
784 return;
785 }
786 let dir = specs_cfg.dir.as_deref().unwrap_or("specs");
787 let specs_dir = repo_root.join(dir);
788 if let Some(detected) = crate::specs::speckit::detect_constitution(&specs_dir) {
789 config.governance.constitution = Some(detected);
790 }
791}
792
793pub fn load_config(
819 repo_root: &Path,
820 user_config_path: Option<&Path>,
821) -> Result<PawConfig, PawError> {
822 let global_path = match user_config_path {
823 Some(p) => p.to_path_buf(),
824 None => global_config_path()?,
825 };
826 load_config_from(&global_path, repo_root)
827}
828
829pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
834 let global = load_config_file(global_path)?.unwrap_or_default();
835 let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
836 let mut merged = global.merged_with(&repo);
837 auto_wire_governance(&mut merged, repo_root);
838 Ok(merged)
839}
840
841pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
843 save_config_to(&repo_config_path(repo_root), config)
844}
845
846fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
848 let dir = path
849 .parent()
850 .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
851 fs::create_dir_all(dir)
852 .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
853
854 let contents =
855 toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
856
857 let tmp = path.with_extension("toml.tmp");
859 fs::write(&tmp, &contents)
860 .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
861 fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
862
863 Ok(())
864}
865
866pub fn add_custom_cli(
870 name: &str,
871 command: &str,
872 display_name: Option<&str>,
873) -> Result<(), PawError> {
874 add_custom_cli_to(&global_config_path()?, name, command, display_name)
875}
876
877pub fn add_custom_cli_to(
881 config_path: &Path,
882 name: &str,
883 command: &str,
884 display_name: Option<&str>,
885) -> Result<(), PawError> {
886 let resolved_command = if Path::new(command).is_absolute() {
887 command.to_string()
888 } else {
889 which::which(command)
890 .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
891 .to_string_lossy()
892 .into_owned()
893 };
894
895 let mut config = load_config_file(config_path)?.unwrap_or_default();
896
897 config.clis.insert(
898 name.to_string(),
899 CustomCli {
900 command: resolved_command,
901 display_name: display_name.map(String::from),
902 },
903 );
904
905 save_config_to(config_path, &config)
906}
907
908pub fn generate_default_config() -> String {
911 r#"# git-paw configuration
912# See https://github.com/bearicorn/git-paw for documentation.
913
914# Pre-select a CLI in the interactive picker (user can still change).
915# Omit to show the full picker with no default.
916# default_cli = ""
917
918# Enable tmux mouse mode for sessions (default: true).
919# mouse = true
920
921# Bypass the CLI picker entirely for --from-specs mode.
922# Omit to prompt or use per-spec paw_cli fields.
923# default_spec_cli = ""
924
925# Prefix for spec-derived branch names (default: "spec/" ).
926# branch_prefix = "spec/"
927
928# Dashboard message log configuration.
929# [dashboard]
930# show_message_log = false
931
932# Spec scanning configuration.
933# [specs]
934# dir = "specs"
935#
936# OpenSpec format (directory-based, default):
937# type = "openspec"
938#
939# Markdown format (frontmatter-based):
940# type = "markdown"
941# Each .md file uses YAML frontmatter fields:
942# paw_status — "pending" | "done" | "in-progress" (required)
943# paw_branch — branch name suffix (optional, falls back to filename)
944# paw_cli — CLI override for this spec (optional)
945
946# Session logging configuration.
947# [logging]
948# enabled = false
949
950# HTTP broker for agent coordination (requires --broker flag on start).
951# [broker]
952# enabled = true
953# port = 9119
954# bind = "127.0.0.1"
955
956# Supervisor mode — git-paw acts as a coordinating layer in front of the
957# agent CLI, enforcing approval policy and running configured gate
958# commands during the five-gate verification workflow.
959#
960# Gate command templates feed the supervisor skill's five gates: gate 1
961# Testing (fmt_check / lint / build / test), gate 3 Spec audit
962# (spec_validate), gate 4 Doc audit (doc_build), gate 5 Security audit
963# (security_audit). When a key is omitted, the matching placeholder
964# renders as `(not configured)` in the supervisor skill and the agent
965# skips that tooling step (the gate's manual review still applies).
966# `{{CHANGE_ID}}` inside spec_validate_command is substituted by the
967# supervisor agent at verification time with the change name.
968# [supervisor]
969# enabled = true
970# cli = "claude"
971# test_command = "just check" # or: "cargo test", "npm test", "pytest"
972# lint_command = "cargo clippy -- -D warnings" # or: "npm run lint", "ruff check .", "golangci-lint run"
973# build_command = "cargo build" # or: "npm run build", "mvn package", "go build ./..."
974# fmt_check_command = "cargo fmt --check" # or: "prettier --check .", "gofmt -l ."
975# doc_build_command = "mdbook build docs/" # or: "sphinx-build", "mkdocs build"
976# spec_validate_command = "openspec validate {{CHANGE_ID}} --strict" # OpenSpec only
977# security_audit_command = "cargo audit" # or: "npm audit", "bandit -r ."
978# agent_approval = "auto" # one of: "manual", "auto", "full-auto"
979#
980# Conflict detector tuning. Active only when supervisor mode is enabled.
981# [supervisor.conflict]
982# window_seconds = 120 # escalate unresolved in-flight conflicts after this many seconds
983# warn_on_intent_overlap = true # emit feedback when two agent.intent declarations overlap
984# escalate_on_violation = true # also publish agent.question to supervisor on ownership violations
985
986# Common dev-command allowlist. When supervisor mode starts a session,
987# git-paw seeds .claude/settings.json::allowed_bash_prefixes with a
988# curated preset (cargo, git, just, mdbook, openspec, find, grep, sed -n)
989# so agents do not hit a permission prompt for each variant. Opt out by
990# setting enabled = false; extend with project-specific prefixes via extra.
991# [supervisor.common_dev_allowlist]
992# enabled = true
993# extra = ["pnpm test", "deno fmt"]
994
995# Custom CLI definitions.
996# [clis.my-agent]
997# command = "/usr/local/bin/my-agent"
998# display_name = "My Agent"
999
1000# Named presets for quick launches.
1001# [presets.my-preset]
1002# branches = ["feat/api", "fix/db"]
1003# cli = ""
1004"#
1005 .to_string()
1006}
1007
1008pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
1012 remove_custom_cli_from(&global_config_path()?, name)
1013}
1014
1015pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
1019 let mut config = load_config_file(config_path)?.unwrap_or_default();
1020
1021 if config.clis.remove(name).is_none() {
1022 return Err(PawError::CliNotFound(name.to_string()));
1023 }
1024
1025 save_config_to(config_path, &config)
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use tempfile::TempDir;
1032
1033 fn write_file(path: &Path, content: &str) {
1034 if let Some(parent) = path.parent() {
1035 fs::create_dir_all(parent).unwrap();
1036 }
1037 fs::write(path, content).unwrap();
1038 }
1039
1040 #[test]
1043 fn parses_config_with_all_fields() {
1044 let tmp = TempDir::new().unwrap();
1045 let path = tmp.path().join("config.toml");
1046 write_file(
1047 &path,
1048 r#"
1049default_cli = "claude"
1050mouse = false
1051default_spec_cli = "gemini"
1052branch_prefix = "spec/"
1053
1054[clis.my-agent]
1055command = "/usr/local/bin/my-agent"
1056display_name = "My Agent"
1057
1058[clis.local-llm]
1059command = "ollama-code"
1060
1061[presets.backend]
1062branches = ["feature/api", "fix/db"]
1063cli = "claude"
1064
1065[specs]
1066dir = "my-specs"
1067type = "openspec"
1068
1069[logging]
1070enabled = true
1071"#,
1072 );
1073
1074 let config = load_config_file(&path).unwrap().unwrap();
1075 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1076 assert_eq!(config.mouse, Some(false));
1077 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1078 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1079 assert_eq!(config.clis.len(), 2);
1080 assert_eq!(
1081 config.clis["my-agent"].display_name.as_deref(),
1082 Some("My Agent")
1083 );
1084 assert_eq!(config.clis["local-llm"].command, "ollama-code");
1085 assert_eq!(config.presets["backend"].cli, "claude");
1086 assert_eq!(
1087 config.presets["backend"].branches,
1088 vec!["feature/api", "fix/db"]
1089 );
1090 let specs = config.specs.unwrap();
1091 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1092 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1093 let logging = config.logging.unwrap();
1094 assert!(logging.enabled);
1095 }
1096
1097 #[test]
1098 fn all_fields_are_optional() {
1099 let tmp = TempDir::new().unwrap();
1100 let path = tmp.path().join("config.toml");
1101 write_file(&path, "default_cli = \"gemini\"\n");
1102
1103 let config = load_config_file(&path).unwrap().unwrap();
1104 assert_eq!(config.default_cli.as_deref(), Some("gemini"));
1105 assert_eq!(config.mouse, None);
1106 assert!(config.clis.is_empty());
1107 assert!(config.presets.is_empty());
1108 }
1109
1110 #[test]
1111 fn returns_defaults_when_no_files_exist() {
1112 let tmp = TempDir::new().unwrap();
1113 let global_path = tmp.path().join("nonexistent").join("config.toml");
1114 let repo_root = tmp.path().join("repo");
1115 fs::create_dir_all(&repo_root).unwrap();
1116
1117 let config = load_config_from(&global_path, &repo_root).unwrap();
1118 assert_eq!(config.default_cli, None);
1119 assert_eq!(config.mouse, None);
1120 assert!(config.clis.is_empty());
1121 assert!(config.presets.is_empty());
1122 }
1123
1124 #[test]
1125 fn reports_error_for_invalid_toml() {
1126 let tmp = TempDir::new().unwrap();
1127 let path = tmp.path().join("bad.toml");
1128 write_file(&path, "this is not [valid toml");
1129
1130 let err = load_config_file(&path).unwrap_err();
1131 assert!(err.to_string().contains("bad.toml"));
1132 }
1133
1134 #[test]
1137 fn repo_config_overrides_global_scalars() {
1138 let tmp = TempDir::new().unwrap();
1139 let global_path = tmp.path().join("global").join("config.toml");
1140 let repo_root = tmp.path().join("repo");
1141 fs::create_dir_all(&repo_root).unwrap();
1142
1143 write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
1144 write_file(
1145 &repo_config_path(&repo_root),
1146 "default_cli = \"gemini\"\n", );
1148
1149 let config = load_config_from(&global_path, &repo_root).unwrap();
1150 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
1153
1154 #[test]
1155 fn repo_config_merges_cli_maps() {
1156 let tmp = TempDir::new().unwrap();
1157 let global_path = tmp.path().join("global").join("config.toml");
1158 let repo_root = tmp.path().join("repo");
1159 fs::create_dir_all(&repo_root).unwrap();
1160
1161 write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
1162 write_file(
1163 &repo_config_path(&repo_root),
1164 "[clis.agent-b]\ncommand = \"/bin/b\"\n",
1165 );
1166
1167 let config = load_config_from(&global_path, &repo_root).unwrap();
1168 assert_eq!(config.clis.len(), 2);
1169 assert!(config.clis.contains_key("agent-a"));
1170 assert!(config.clis.contains_key("agent-b"));
1171 }
1172
1173 #[test]
1174 fn repo_cli_overrides_global_cli_with_same_name() {
1175 let tmp = TempDir::new().unwrap();
1176 let global_path = tmp.path().join("global").join("config.toml");
1177 let repo_root = tmp.path().join("repo");
1178 fs::create_dir_all(&repo_root).unwrap();
1179
1180 write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
1181 write_file(
1182 &repo_config_path(&repo_root),
1183 "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
1184 );
1185
1186 let config = load_config_from(&global_path, &repo_root).unwrap();
1187 assert_eq!(config.clis["my-agent"].command, "/new/path");
1188 assert_eq!(
1189 config.clis["my-agent"].display_name.as_deref(),
1190 Some("Overridden")
1191 );
1192 }
1193
1194 #[test]
1195 fn load_config_from_reads_global_file_when_no_repo() {
1196 let tmp = TempDir::new().unwrap();
1197 let global_path = tmp.path().join("global").join("config.toml");
1198 let repo_root = tmp.path().join("repo");
1199 fs::create_dir_all(&repo_root).unwrap();
1200
1201 write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
1202 let config = load_config_from(&global_path, &repo_root).unwrap();
1205 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1206 assert_eq!(config.mouse, Some(false));
1207 }
1208
1209 #[test]
1210 fn load_config_from_reads_repo_file_when_no_global() {
1211 let tmp = TempDir::new().unwrap();
1212 let global_path = tmp.path().join("nonexistent").join("config.toml");
1213 let repo_root = tmp.path().join("repo");
1214 fs::create_dir_all(&repo_root).unwrap();
1215
1216 write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
1217
1218 let config = load_config_from(&global_path, &repo_root).unwrap();
1219 assert_eq!(config.default_cli.as_deref(), Some("codex"));
1220 }
1221
1222 #[test]
1225 fn preset_accessible_by_name() {
1226 let tmp = TempDir::new().unwrap();
1227 let global_path = tmp.path().join("global").join("config.toml");
1228 let repo_root = tmp.path().join("repo");
1229 fs::create_dir_all(&repo_root).unwrap();
1230
1231 write_file(
1232 &repo_config_path(&repo_root),
1233 "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
1234 );
1235
1236 let config = load_config_from(&global_path, &repo_root).unwrap();
1237 let preset = config.get_preset("backend").unwrap();
1238 assert_eq!(preset.cli, "claude");
1239 assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
1240 }
1241
1242 #[test]
1243 fn preset_returns_none_when_not_in_config() {
1244 let tmp = TempDir::new().unwrap();
1245 let global_path = tmp.path().join("config.toml");
1246 write_file(&global_path, "default_cli = \"claude\"\n");
1247
1248 let config = load_config_file(&global_path).unwrap().unwrap();
1249 assert!(config.get_preset("nonexistent").is_none());
1250 }
1251
1252 #[test]
1255 fn add_cli_writes_to_config_file() {
1256 let tmp = TempDir::new().unwrap();
1257 let config_path = tmp.path().join("git-paw").join("config.toml");
1258
1259 add_custom_cli_to(
1261 &config_path,
1262 "my-agent",
1263 "/usr/local/bin/my-agent",
1264 Some("My Agent"),
1265 )
1266 .unwrap();
1267
1268 let config = load_config_file(&config_path).unwrap().unwrap();
1270 assert_eq!(config.clis.len(), 1);
1271 assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
1272 assert_eq!(
1273 config.clis["my-agent"].display_name.as_deref(),
1274 Some("My Agent")
1275 );
1276 }
1277
1278 #[test]
1279 fn add_cli_preserves_existing_entries() {
1280 let tmp = TempDir::new().unwrap();
1281 let config_path = tmp.path().join("git-paw").join("config.toml");
1282
1283 add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
1284 add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
1285
1286 let config = load_config_file(&config_path).unwrap().unwrap();
1287 assert_eq!(config.clis.len(), 2);
1288 assert!(config.clis.contains_key("first"));
1289 assert!(config.clis.contains_key("second"));
1290 }
1291
1292 #[test]
1293 fn add_cli_errors_when_command_not_on_path() {
1294 let tmp = TempDir::new().unwrap();
1295 let config_path = tmp.path().join("config.toml");
1296
1297 let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
1298 .unwrap_err();
1299 assert!(err.to_string().contains("not found on PATH"));
1300 }
1301
1302 #[test]
1305 fn remove_cli_deletes_entry_from_config_file() {
1306 let tmp = TempDir::new().unwrap();
1307 let config_path = tmp.path().join("git-paw").join("config.toml");
1308
1309 add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
1311 add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
1312
1313 remove_custom_cli_from(&config_path, "remove-me").unwrap();
1315
1316 let config = load_config_file(&config_path).unwrap().unwrap();
1318 assert_eq!(config.clis.len(), 1);
1319 assert!(config.clis.contains_key("keep-me"));
1320 assert!(!config.clis.contains_key("remove-me"));
1321 }
1322
1323 #[test]
1324 fn remove_nonexistent_cli_returns_cli_not_found_error() {
1325 let tmp = TempDir::new().unwrap();
1326 let config_path = tmp.path().join("config.toml");
1327 write_file(&config_path, "");
1329
1330 let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
1331 match err {
1332 PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
1333 other => panic!("expected CliNotFound, got: {other}"),
1334 }
1335 }
1336
1337 #[test]
1338 fn remove_cli_from_empty_config_returns_error() {
1339 let tmp = TempDir::new().unwrap();
1340 let config_path = tmp.path().join("config.toml");
1341 let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
1344 match err {
1345 PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
1346 other => panic!("expected CliNotFound, got: {other}"),
1347 }
1348 }
1349
1350 #[test]
1355 fn parses_default_spec_cli_when_present() {
1356 let tmp = TempDir::new().unwrap();
1357 let path = tmp.path().join("config.toml");
1358 write_file(&path, "default_spec_cli = \"claude\"\n");
1359
1360 let config = load_config_file(&path).unwrap().unwrap();
1361 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1362 }
1363
1364 #[test]
1365 fn default_spec_cli_defaults_to_none() {
1366 let tmp = TempDir::new().unwrap();
1367 let path = tmp.path().join("config.toml");
1368 write_file(&path, "default_cli = \"claude\"\n");
1369
1370 let config = load_config_file(&path).unwrap().unwrap();
1371 assert_eq!(config.default_spec_cli, None);
1372 }
1373
1374 #[test]
1375 fn repo_overrides_global_default_spec_cli() {
1376 let tmp = TempDir::new().unwrap();
1377 let global_path = tmp.path().join("global").join("config.toml");
1378 let repo_root = tmp.path().join("repo");
1379 fs::create_dir_all(&repo_root).unwrap();
1380
1381 write_file(&global_path, "default_spec_cli = \"claude\"\n");
1382 write_file(
1383 &repo_config_path(&repo_root),
1384 "default_spec_cli = \"gemini\"\n",
1385 );
1386
1387 let config = load_config_from(&global_path, &repo_root).unwrap();
1388 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
1389 }
1390
1391 #[test]
1392 fn global_default_spec_cli_preserved_when_repo_absent() {
1393 let tmp = TempDir::new().unwrap();
1394 let global_path = tmp.path().join("global").join("config.toml");
1395 let repo_root = tmp.path().join("repo");
1396 fs::create_dir_all(&repo_root).unwrap();
1397
1398 write_file(&global_path, "default_spec_cli = \"claude\"\n");
1399
1400 let config = load_config_from(&global_path, &repo_root).unwrap();
1401 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
1402 }
1403
1404 #[test]
1407 fn config_survives_save_and_load() {
1408 let tmp = TempDir::new().unwrap();
1409 let config_path = tmp.path().join("config.toml");
1410
1411 let original = PawConfig {
1412 default_cli: Some("claude".into()),
1413 default_spec_cli: None,
1414 branch_prefix: None,
1415 mouse: Some(true),
1416 clis: HashMap::from([(
1417 "test".into(),
1418 CustomCli {
1419 command: "/bin/test".into(),
1420 display_name: Some("Test CLI".into()),
1421 },
1422 )]),
1423 presets: HashMap::from([(
1424 "dev".into(),
1425 Preset {
1426 branches: vec!["main".into()],
1427 cli: "claude".into(),
1428 },
1429 )]),
1430 specs: None,
1431 logging: None,
1432 dashboard: None,
1433 broker: BrokerConfig::default(),
1434 supervisor: None,
1435 governance: GovernanceConfig::default(),
1436 };
1437
1438 save_config_to(&config_path, &original).unwrap();
1439 let loaded = load_config_file(&config_path).unwrap().unwrap();
1440 assert_eq!(original, loaded);
1441 }
1442
1443 #[test]
1446 fn parses_specs_section_with_populated_fields() {
1447 let tmp = TempDir::new().unwrap();
1448 let path = tmp.path().join("config.toml");
1449 write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
1450
1451 let config = load_config_file(&path).unwrap().unwrap();
1452 let specs = config.specs.unwrap();
1453 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1454 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1455 }
1456
1457 #[test]
1460 fn parses_logging_section_with_enabled() {
1461 let tmp = TempDir::new().unwrap();
1462 let path = tmp.path().join("config.toml");
1463 write_file(&path, "[logging]\nenabled = true\n");
1464
1465 let config = load_config_file(&path).unwrap().unwrap();
1466 let logging = config.logging.unwrap();
1467 assert!(logging.enabled);
1468 }
1469
1470 #[test]
1473 fn round_trip_with_specs_and_logging() {
1474 let tmp = TempDir::new().unwrap();
1475 let config_path = tmp.path().join("config.toml");
1476
1477 let original = PawConfig {
1478 specs: Some(SpecsConfig {
1479 dir: Some("specs".into()),
1480 spec_type: Some("openspec".into()),
1481 }),
1482 logging: Some(LoggingConfig { enabled: true }),
1483 ..Default::default()
1484 };
1485
1486 save_config_to(&config_path, &original).unwrap();
1487 let loaded = load_config_file(&config_path).unwrap().unwrap();
1488 assert_eq!(original, loaded);
1489 assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
1490 assert!(loaded.logging.unwrap().enabled);
1491 }
1492
1493 #[test]
1496 fn generated_default_config_is_valid_toml() {
1497 let raw = generate_default_config();
1498 let stripped: String = raw
1499 .lines()
1500 .filter(|line| !line.trim_start().starts_with('#'))
1501 .collect::<Vec<&str>>()
1502 .join("\n");
1503
1504 let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
1505 assert!(
1506 parsed.is_ok(),
1507 "generated config with comments stripped should be valid TOML, got: {:?}",
1508 parsed.unwrap_err()
1509 );
1510 }
1511
1512 #[test]
1515 fn branch_prefix_repo_overrides_global() {
1516 let tmp = TempDir::new().unwrap();
1517 let global_path = tmp.path().join("global").join("config.toml");
1518 let repo_root = tmp.path().join("repo");
1519 fs::create_dir_all(&repo_root).unwrap();
1520
1521 write_file(&global_path, "branch_prefix = \"feat/\"\n");
1522 write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
1523
1524 let config = load_config_from(&global_path, &repo_root).unwrap();
1525 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1526 }
1527
1528 #[test]
1529 fn generated_default_config_contains_commented_examples() {
1530 let output = generate_default_config();
1531 assert!(
1532 output.contains("default_spec_cli"),
1533 "should contain default_spec_cli"
1534 );
1535 assert!(
1536 output.contains("branch_prefix"),
1537 "should contain branch_prefix"
1538 );
1539 assert!(output.contains("[specs]"), "should contain [specs]");
1540 assert!(output.contains("[logging]"), "should contain [logging]");
1541 assert!(output.contains("[broker]"), "should contain [broker]");
1542 }
1543
1544 #[test]
1547 fn broker_config_defaults() {
1548 let config = BrokerConfig::default();
1549 assert!(!config.enabled);
1550 assert_eq!(config.port, 9119);
1551 assert_eq!(config.bind, "127.0.0.1");
1552 }
1553
1554 #[test]
1555 fn broker_config_url() {
1556 let config = BrokerConfig::default();
1557 assert_eq!(config.url(), "http://127.0.0.1:9119");
1558
1559 let custom = BrokerConfig {
1560 enabled: true,
1561 port: 8080,
1562 bind: "0.0.0.0".to_string(),
1563 };
1564 assert_eq!(custom.url(), "http://0.0.0.0:8080");
1565 }
1566
1567 #[test]
1568 fn empty_config_gets_broker_defaults() {
1569 let tmp = TempDir::new().unwrap();
1570 let path = tmp.path().join("config.toml");
1571 write_file(&path, "");
1572
1573 let config = load_config_file(&path).unwrap().unwrap();
1574 assert!(!config.broker.enabled);
1575 assert_eq!(config.broker.port, 9119);
1576 assert_eq!(config.broker.bind, "127.0.0.1");
1577 }
1578
1579 #[test]
1580 fn parses_full_broker_section() {
1581 let tmp = TempDir::new().unwrap();
1582 let path = tmp.path().join("config.toml");
1583 write_file(
1584 &path,
1585 "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
1586 );
1587
1588 let config = load_config_file(&path).unwrap().unwrap();
1589 assert!(config.broker.enabled);
1590 assert_eq!(config.broker.port, 8080);
1591 assert_eq!(config.broker.bind, "0.0.0.0");
1592 }
1593
1594 #[test]
1595 fn parses_partial_broker_section() {
1596 let tmp = TempDir::new().unwrap();
1597 let path = tmp.path().join("config.toml");
1598 write_file(&path, "[broker]\nenabled = true\n");
1599
1600 let config = load_config_file(&path).unwrap().unwrap();
1601 assert!(config.broker.enabled);
1602 assert_eq!(config.broker.port, 9119);
1603 assert_eq!(config.broker.bind, "127.0.0.1");
1604 }
1605
1606 #[test]
1609 fn supervisor_is_none_when_section_absent() {
1610 let tmp = TempDir::new().unwrap();
1611 let path = tmp.path().join("config.toml");
1612 write_file(&path, "default_cli = \"claude\"\n");
1613
1614 let config = load_config_file(&path).unwrap().unwrap();
1615 assert!(config.supervisor.is_none());
1616 }
1617
1618 #[test]
1619 fn parses_full_supervisor_section() {
1620 let tmp = TempDir::new().unwrap();
1621 let path = tmp.path().join("config.toml");
1622 write_file(
1623 &path,
1624 "[supervisor]\n\
1625 enabled = true\n\
1626 cli = \"claude\"\n\
1627 test_command = \"just check\"\n\
1628 agent_approval = \"full-auto\"\n",
1629 );
1630
1631 let config = load_config_file(&path).unwrap().unwrap();
1632 let supervisor = config.supervisor.unwrap();
1633 assert!(supervisor.enabled);
1634 assert_eq!(supervisor.cli.as_deref(), Some("claude"));
1635 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
1636 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
1637 }
1638
1639 #[test]
1640 fn parses_partial_supervisor_section() {
1641 let tmp = TempDir::new().unwrap();
1642 let path = tmp.path().join("config.toml");
1643 write_file(&path, "[supervisor]\nenabled = true\n");
1644
1645 let config = load_config_file(&path).unwrap().unwrap();
1646 let supervisor = config.supervisor.unwrap();
1647 assert!(supervisor.enabled);
1648 assert_eq!(supervisor.cli, None);
1649 assert_eq!(supervisor.test_command, None);
1650 assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
1651 }
1652
1653 #[test]
1654 fn rejects_invalid_approval_level() {
1655 let tmp = TempDir::new().unwrap();
1656 let path = tmp.path().join("config.toml");
1657 write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
1658
1659 let err = load_config_file(&path).unwrap_err();
1660 assert!(
1661 err.to_string().contains("yolo"),
1662 "error should mention invalid value, got: {err}"
1663 );
1664 }
1665
1666 #[test]
1667 fn supervisor_round_trips_through_save_and_load() {
1668 let tmp = TempDir::new().unwrap();
1669 let config_path = tmp.path().join("config.toml");
1670
1671 let original = PawConfig {
1672 supervisor: Some(SupervisorConfig {
1673 enabled: true,
1674 cli: Some("claude".into()),
1675 test_command: Some("just check".into()),
1676 lint_command: None,
1677 build_command: None,
1678 doc_build_command: None,
1679 spec_validate_command: None,
1680 fmt_check_command: None,
1681 security_audit_command: None,
1682 agent_approval: ApprovalLevel::FullAuto,
1683 auto_approve: None,
1684 conflict: ConflictConfig::default(),
1685 learnings: false,
1686 learnings_config: LearningsConfig::default(),
1687 common_dev_allowlist: CommonDevAllowlistConfig::default(),
1688 }),
1689 ..Default::default()
1690 };
1691
1692 save_config_to(&config_path, &original).unwrap();
1693 let loaded = load_config_file(&config_path).unwrap().unwrap();
1694 assert_eq!(loaded.supervisor, original.supervisor);
1695 }
1696
1697 #[test]
1700 fn gate_command_fields_default_to_none() {
1701 let tmp = TempDir::new().unwrap();
1702 let path = tmp.path().join("config.toml");
1703 write_file(&path, "[supervisor]\nenabled = true\n");
1704
1705 let config = load_config_file(&path).unwrap().unwrap();
1706 let supervisor = config.supervisor.unwrap();
1707 assert_eq!(supervisor.test_command, None);
1708 assert_eq!(supervisor.lint_command, None);
1709 assert_eq!(supervisor.build_command, None);
1710 assert_eq!(supervisor.doc_build_command, None);
1711 assert_eq!(supervisor.spec_validate_command, None);
1712 assert_eq!(supervisor.fmt_check_command, None);
1713 assert_eq!(supervisor.security_audit_command, None);
1714 }
1715
1716 #[test]
1717 fn gate_command_fields_round_trip() {
1718 let tmp = TempDir::new().unwrap();
1719 let config_path = tmp.path().join("config.toml");
1720
1721 let original = PawConfig {
1722 supervisor: Some(SupervisorConfig {
1723 enabled: true,
1724 cli: Some("claude".into()),
1725 test_command: Some("just check".into()),
1726 lint_command: Some("cargo clippy -- -D warnings".into()),
1727 build_command: Some("cargo build".into()),
1728 doc_build_command: Some("mdbook build docs/".into()),
1729 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict".into()),
1730 fmt_check_command: Some("cargo fmt --check".into()),
1731 security_audit_command: Some("cargo audit".into()),
1732 ..Default::default()
1733 }),
1734 ..Default::default()
1735 };
1736
1737 save_config_to(&config_path, &original).unwrap();
1738 let loaded = load_config_file(&config_path).unwrap().unwrap();
1739 assert_eq!(loaded.supervisor, original.supervisor);
1740 }
1741
1742 #[test]
1743 fn gate_command_fields_omit_from_toml_when_none() {
1744 let supervisor = SupervisorConfig {
1745 enabled: true,
1746 test_command: None,
1747 lint_command: None,
1748 build_command: None,
1749 doc_build_command: None,
1750 spec_validate_command: None,
1751 fmt_check_command: None,
1752 security_audit_command: None,
1753 ..Default::default()
1754 };
1755 let serialized = toml::to_string_pretty(&supervisor).unwrap();
1756 for key in [
1757 "test_command",
1758 "lint_command",
1759 "build_command",
1760 "doc_build_command",
1761 "spec_validate_command",
1762 "fmt_check_command",
1763 "security_audit_command",
1764 ] {
1765 assert!(
1766 !serialized.contains(key),
1767 "TOML serialised with None gate fields should omit `{key}`; got:\n{serialized}",
1768 );
1769 }
1770 }
1771
1772 #[test]
1775 fn supervisor_common_dev_allowlist_defaults_when_section_absent() {
1776 let tmp = TempDir::new().unwrap();
1777 let path = tmp.path().join("config.toml");
1778 write_file(&path, "[supervisor]\nenabled = true\n");
1779
1780 let config = load_config_file(&path).unwrap().unwrap();
1781 let supervisor = config.supervisor.unwrap();
1782 assert!(supervisor.common_dev_allowlist.enabled);
1783 assert!(supervisor.common_dev_allowlist.extra.is_empty());
1784 }
1785
1786 #[test]
1787 fn supervisor_common_dev_allowlist_disabled_opt_out() {
1788 let tmp = TempDir::new().unwrap();
1789 let path = tmp.path().join("config.toml");
1790 write_file(
1791 &path,
1792 "[supervisor]\nenabled = true\n\
1793 [supervisor.common_dev_allowlist]\nenabled = false\n",
1794 );
1795
1796 let config = load_config_file(&path).unwrap().unwrap();
1797 let supervisor = config.supervisor.unwrap();
1798 assert!(!supervisor.common_dev_allowlist.enabled);
1799 assert!(supervisor.common_dev_allowlist.extra.is_empty());
1801 }
1802
1803 #[test]
1804 fn supervisor_common_dev_allowlist_extra_parsed() {
1805 let tmp = TempDir::new().unwrap();
1806 let path = tmp.path().join("config.toml");
1807 write_file(
1808 &path,
1809 "[supervisor]\nenabled = true\n\
1810 [supervisor.common_dev_allowlist]\nextra = [\"pnpm test\", \"deno fmt\"]\n",
1811 );
1812
1813 let config = load_config_file(&path).unwrap().unwrap();
1814 let supervisor = config.supervisor.unwrap();
1815 assert_eq!(
1816 supervisor.common_dev_allowlist.extra,
1817 vec!["pnpm test".to_string(), "deno fmt".to_string()],
1818 );
1819 assert!(supervisor.common_dev_allowlist.enabled);
1821 }
1822
1823 #[test]
1824 fn supervisor_common_dev_allowlist_round_trips_through_save_and_load() {
1825 let tmp = TempDir::new().unwrap();
1826 let config_path = tmp.path().join("config.toml");
1827
1828 let original = PawConfig {
1829 supervisor: Some(SupervisorConfig {
1830 enabled: true,
1831 common_dev_allowlist: CommonDevAllowlistConfig {
1832 enabled: false,
1833 extra: vec!["pnpm test".into(), "uv pip install".into()],
1834 },
1835 ..Default::default()
1836 }),
1837 ..Default::default()
1838 };
1839
1840 save_config_to(&config_path, &original).unwrap();
1841 let loaded = load_config_file(&config_path).unwrap().unwrap();
1842 assert_eq!(loaded.supervisor, original.supervisor);
1843 }
1844
1845 #[test]
1846 fn existing_pre_v05_config_loads_with_default_common_dev_allowlist() {
1847 let tmp = TempDir::new().unwrap();
1850 let path = tmp.path().join("config.toml");
1851 write_file(
1852 &path,
1853 "[supervisor]\n\
1854 enabled = true\n\
1855 cli = \"claude\"\n\
1856 test_command = \"just check\"\n\
1857 agent_approval = \"auto\"\n\
1858 [supervisor.conflict]\n\
1859 window_seconds = 60\n",
1860 );
1861
1862 let config = load_config_file(&path).unwrap().unwrap();
1863 let supervisor = config.supervisor.unwrap();
1864 assert!(supervisor.common_dev_allowlist.enabled);
1865 assert!(supervisor.common_dev_allowlist.extra.is_empty());
1866 }
1867
1868 #[test]
1869 fn generated_default_config_template_contains_common_dev_allowlist_section() {
1870 let template = generate_default_config();
1871 assert!(
1872 template.contains("[supervisor.common_dev_allowlist]"),
1873 "default template should document the new sub-table",
1874 );
1875 assert!(
1876 template.contains("enabled = true"),
1877 "template should show the enabled default",
1878 );
1879 assert!(
1880 template.contains("extra ="),
1881 "template should illustrate the extra field",
1882 );
1883 }
1884
1885 #[test]
1888 fn learnings_defaults_to_false_when_supervisor_section_absent_field() {
1889 let tmp = TempDir::new().unwrap();
1891 let path = tmp.path().join("config.toml");
1892 write_file(&path, "[supervisor]\nenabled = true\n");
1893
1894 let config = load_config_file(&path).unwrap().unwrap();
1895 let supervisor = config.supervisor.unwrap();
1896 assert!(!supervisor.learnings);
1897 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
1898 }
1899
1900 #[test]
1901 fn learnings_true_loads() {
1902 let tmp = TempDir::new().unwrap();
1903 let path = tmp.path().join("config.toml");
1904 write_file(&path, "[supervisor]\nenabled = true\nlearnings = true\n");
1905
1906 let config = load_config_file(&path).unwrap().unwrap();
1907 let supervisor = config.supervisor.unwrap();
1908 assert!(supervisor.learnings);
1909 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
1911 }
1912
1913 #[test]
1914 fn learnings_config_custom_flush_interval_is_honoured() {
1915 let tmp = TempDir::new().unwrap();
1916 let path = tmp.path().join("config.toml");
1917 write_file(
1918 &path,
1919 "[supervisor]\n\
1920 enabled = true\n\
1921 learnings = true\n\
1922 [supervisor.learnings_config]\n\
1923 flush_interval_seconds = 30\n",
1924 );
1925
1926 let config = load_config_file(&path).unwrap().unwrap();
1927 let supervisor = config.supervisor.unwrap();
1928 assert!(supervisor.learnings);
1929 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 30);
1930 }
1931
1932 #[test]
1933 fn learnings_config_defaults_when_table_absent() {
1934 let cfg = LearningsConfig::default();
1936 assert_eq!(cfg.flush_interval_seconds, 60);
1937 }
1938
1939 #[test]
1940 fn pre_v050_config_loads_with_learnings_false() {
1941 let tmp = TempDir::new().unwrap();
1945 let path = tmp.path().join("config.toml");
1946 write_file(
1947 &path,
1948 "default_cli = \"claude\"\n\
1949 [supervisor]\n\
1950 enabled = true\n\
1951 agent_approval = \"auto\"\n",
1952 );
1953
1954 let config = load_config_file(&path).unwrap().unwrap();
1955 let supervisor = config.supervisor.unwrap();
1956 assert!(!supervisor.learnings);
1957 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 60);
1958 }
1959
1960 #[test]
1961 fn learnings_round_trips_through_save_and_load() {
1962 let tmp = TempDir::new().unwrap();
1963 let config_path = tmp.path().join("config.toml");
1964
1965 let original = PawConfig {
1966 supervisor: Some(SupervisorConfig {
1967 enabled: true,
1968 learnings: true,
1969 learnings_config: LearningsConfig {
1970 flush_interval_seconds: 90,
1971 },
1972 ..Default::default()
1973 }),
1974 ..Default::default()
1975 };
1976
1977 save_config_to(&config_path, &original).unwrap();
1978 let loaded = load_config_file(&config_path).unwrap().unwrap();
1979 assert_eq!(loaded.supervisor, original.supervisor);
1980 let supervisor = loaded.supervisor.unwrap();
1981 assert!(supervisor.learnings);
1982 assert_eq!(supervisor.learnings_config.flush_interval_seconds, 90);
1983 }
1984
1985 #[test]
1986 fn existing_v030_config_loads_without_supervisor() {
1987 let tmp = TempDir::new().unwrap();
1988 let path = tmp.path().join("config.toml");
1989 write_file(
1990 &path,
1991 "default_cli = \"claude\"\n\
1992 mouse = true\n\
1993 [broker]\n\
1994 enabled = true\n\
1995 [logging]\n\
1996 enabled = false\n",
1997 );
1998
1999 let config = load_config_file(&path).unwrap().unwrap();
2000 assert_eq!(config.default_cli.as_deref(), Some("claude"));
2001 assert!(config.broker.enabled);
2002 assert!(config.supervisor.is_none());
2003 }
2004
2005 #[test]
2006 fn generated_default_config_contains_commented_supervisor_section() {
2007 let output = generate_default_config();
2008 assert!(output.contains("[supervisor]"));
2009 assert!(output.contains("enabled"));
2010 assert!(output.contains("test_command"));
2011 assert!(output.contains("agent_approval"));
2012 }
2013
2014 #[test]
2017 fn dashboard_config_defaults_to_disabled() {
2018 let config = DashboardConfig::default();
2019 assert!(!config.show_message_log);
2020 }
2021
2022 #[test]
2023 fn parses_dashboard_section_with_show_message_log() {
2024 let tmp = TempDir::new().unwrap();
2025 let path = tmp.path().join("config.toml");
2026 write_file(&path, "[dashboard]\nshow_message_log = true\n");
2027
2028 let config = load_config_file(&path).unwrap().unwrap();
2029 let dashboard = config.dashboard.unwrap();
2030 assert!(dashboard.show_message_log);
2031 }
2032
2033 #[test]
2034 fn dashboard_is_none_when_section_absent() {
2035 let tmp = TempDir::new().unwrap();
2036 let path = tmp.path().join("config.toml");
2037 write_file(&path, "default_cli = \"claude\"\n");
2038
2039 let config = load_config_file(&path).unwrap().unwrap();
2040 assert!(config.dashboard.is_none());
2041 }
2042
2043 #[test]
2044 fn dashboard_merge_repo_wins() {
2045 let tmp = TempDir::new().unwrap();
2046 let global_path = tmp.path().join("global").join("config.toml");
2047 let repo_root = tmp.path().join("repo");
2048 fs::create_dir_all(&repo_root).unwrap();
2049
2050 write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
2051 write_file(
2052 &repo_config_path(&repo_root),
2053 "[dashboard]\nshow_message_log = true\n",
2054 );
2055
2056 let config = load_config_from(&global_path, &repo_root).unwrap();
2057 let dashboard = config.dashboard.unwrap();
2058 assert!(dashboard.show_message_log);
2059 }
2060
2061 #[test]
2062 fn dashboard_round_trip_through_save_and_load() {
2063 let tmp = TempDir::new().unwrap();
2064 let config_path = tmp.path().join("config.toml");
2065
2066 let original = PawConfig {
2067 dashboard: Some(DashboardConfig {
2068 show_message_log: true,
2069 }),
2070 ..Default::default()
2071 };
2072
2073 save_config_to(&config_path, &original).unwrap();
2074 let loaded = load_config_file(&config_path).unwrap().unwrap();
2075 assert_eq!(loaded.dashboard, original.dashboard);
2076 assert!(loaded.dashboard.unwrap().show_message_log);
2077 }
2078
2079 #[test]
2080 fn get_dashboard_returns_none_when_not_configured() {
2081 let config = PawConfig::default();
2082 assert!(config.get_dashboard().is_none());
2083 }
2084
2085 #[test]
2086 fn get_dashboard_returns_config_when_present() {
2087 let config = PawConfig {
2088 dashboard: Some(DashboardConfig {
2089 show_message_log: true,
2090 }),
2091 ..Default::default()
2092 };
2093 let dashboard = config.get_dashboard().unwrap();
2094 assert!(dashboard.show_message_log);
2095 }
2096
2097 #[test]
2100 fn approval_flags_claude_full_auto() {
2101 assert_eq!(
2102 approval_flags("claude", &ApprovalLevel::FullAuto),
2103 "--dangerously-skip-permissions"
2104 );
2105 }
2106
2107 #[test]
2108 fn approval_flags_codex_auto() {
2109 assert_eq!(
2110 approval_flags("codex", &ApprovalLevel::Auto),
2111 "--approval-mode=auto-edit"
2112 );
2113 }
2114
2115 #[test]
2116 fn approval_flags_codex_full_auto() {
2117 assert_eq!(
2118 approval_flags("codex", &ApprovalLevel::FullAuto),
2119 "--approval-mode=full-auto"
2120 );
2121 }
2122
2123 #[test]
2124 fn approval_flags_unknown_cli_is_empty() {
2125 assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
2126 }
2127
2128 #[test]
2129 fn approval_flags_manual_is_empty() {
2130 assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
2131 assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
2132 }
2133
2134 #[test]
2135 fn approval_flags_is_deterministic() {
2136 let first = approval_flags("claude", &ApprovalLevel::FullAuto);
2137 let second = approval_flags("claude", &ApprovalLevel::FullAuto);
2138 assert_eq!(first, second);
2139 }
2140
2141 #[test]
2142 fn supervisor_merge_repo_wins() {
2143 let tmp = TempDir::new().unwrap();
2144 let global_path = tmp.path().join("global").join("config.toml");
2145 let repo_root = tmp.path().join("repo");
2146 fs::create_dir_all(&repo_root).unwrap();
2147
2148 write_file(
2149 &global_path,
2150 "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
2151 );
2152 write_file(
2153 &repo_config_path(&repo_root),
2154 "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
2155 );
2156
2157 let config = load_config_from(&global_path, &repo_root).unwrap();
2158 let supervisor = config.supervisor.unwrap();
2159 assert!(supervisor.enabled);
2160 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
2161 }
2162
2163 #[test]
2164 fn broker_config_round_trip() {
2165 let tmp = TempDir::new().unwrap();
2166 let config_path = tmp.path().join("config.toml");
2167
2168 let original = PawConfig {
2169 broker: BrokerConfig {
2170 enabled: true,
2171 port: 9200,
2172 bind: "127.0.0.1".to_string(),
2173 },
2174 ..Default::default()
2175 };
2176
2177 save_config_to(&config_path, &original).unwrap();
2178 let loaded = load_config_file(&config_path).unwrap().unwrap();
2179 assert_eq!(loaded.broker.enabled, original.broker.enabled);
2180 assert_eq!(loaded.broker.port, original.broker.port);
2181 assert_eq!(loaded.broker.bind, original.broker.bind);
2182 }
2183
2184 #[test]
2187 fn auto_approve_defaults_match_spec() {
2188 let cfg = AutoApproveConfig::default();
2189 assert!(cfg.enabled, "enabled defaults to true");
2190 assert!(
2191 cfg.safe_commands.is_empty(),
2192 "safe_commands defaults to empty"
2193 );
2194 assert_eq!(cfg.stall_threshold_seconds, 30);
2195 assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
2196 }
2197
2198 #[test]
2199 fn auto_approve_section_absent_keeps_supervisor_simple() {
2200 let tmp = TempDir::new().unwrap();
2201 let path = tmp.path().join("config.toml");
2202 write_file(&path, "[supervisor]\nenabled = true\n");
2203 let config = load_config_file(&path).unwrap().unwrap();
2204 let supervisor = config.supervisor.unwrap();
2205 assert!(supervisor.auto_approve.is_none());
2206 }
2207
2208 #[test]
2209 fn auto_approve_section_parses_full_body() {
2210 let tmp = TempDir::new().unwrap();
2211 let path = tmp.path().join("config.toml");
2212 write_file(
2213 &path,
2214 "[supervisor]\n\
2215 enabled = true\n\
2216 [supervisor.auto_approve]\n\
2217 enabled = false\n\
2218 safe_commands = [\"just smoke\"]\n\
2219 stall_threshold_seconds = 60\n\
2220 approval_level = \"conservative\"\n",
2221 );
2222 let config = load_config_file(&path).unwrap().unwrap();
2223 let aa = config.supervisor.unwrap().auto_approve.unwrap();
2224 assert!(!aa.enabled);
2225 assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
2226 assert_eq!(aa.stall_threshold_seconds, 60);
2227 assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
2228 }
2229
2230 #[test]
2231 fn auto_approve_enabled_defaults_to_true_when_omitted() {
2232 let tmp = TempDir::new().unwrap();
2233 let path = tmp.path().join("config.toml");
2234 write_file(
2235 &path,
2236 "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
2237 );
2238 let config = load_config_file(&path).unwrap().unwrap();
2239 let aa = config.supervisor.unwrap().auto_approve.unwrap();
2240 assert!(aa.enabled, "enabled should default to true");
2241 }
2242
2243 #[test]
2244 fn auto_approve_off_preset_forces_disabled() {
2245 let cfg = AutoApproveConfig {
2246 enabled: true,
2247 approval_level: ApprovalLevelPreset::Off,
2248 ..AutoApproveConfig::default()
2249 };
2250 let resolved = cfg.resolved();
2251 assert!(!resolved.enabled, "Off preset must force enabled = false");
2252 }
2253
2254 #[test]
2255 fn auto_approve_threshold_floor_clamps() {
2256 let cfg = AutoApproveConfig {
2257 stall_threshold_seconds: 0,
2258 ..AutoApproveConfig::default()
2259 };
2260 let resolved = cfg.resolved();
2261 assert_eq!(
2262 resolved.stall_threshold_seconds,
2263 AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
2264 );
2265 }
2266
2267 #[test]
2268 fn auto_approve_safe_preset_keeps_defaults() {
2269 let cfg = AutoApproveConfig {
2270 approval_level: ApprovalLevelPreset::Safe,
2271 ..AutoApproveConfig::default()
2272 };
2273 let wl = cfg.effective_whitelist();
2274 assert!(wl.iter().any(|c| c == "cargo test"));
2275 assert!(wl.iter().any(|c| c == "git push"));
2276 assert!(wl.iter().any(|c| c.starts_with("curl")));
2277 }
2278
2279 #[test]
2280 fn auto_approve_conservative_drops_push_and_curl() {
2281 let cfg = AutoApproveConfig {
2282 approval_level: ApprovalLevelPreset::Conservative,
2283 ..AutoApproveConfig::default()
2284 };
2285 let wl = cfg.effective_whitelist();
2286 assert!(wl.iter().any(|c| c == "cargo test"));
2287 assert!(
2288 !wl.iter().any(|c| c.starts_with("git push")),
2289 "conservative drops git push"
2290 );
2291 assert!(
2292 !wl.iter().any(|c| c.starts_with("curl")),
2293 "conservative drops curl"
2294 );
2295 }
2296
2297 #[test]
2298 fn auto_approve_extras_are_unioned_with_defaults() {
2299 let cfg = AutoApproveConfig {
2300 safe_commands: vec!["just lint".to_string(), "just test".to_string()],
2301 ..AutoApproveConfig::default()
2302 };
2303 let wl = cfg.effective_whitelist();
2304 assert!(wl.iter().any(|c| c == "cargo fmt"));
2305 assert!(wl.iter().any(|c| c == "just lint"));
2306 assert!(wl.iter().any(|c| c == "just test"));
2307 }
2308
2309 #[test]
2310 fn auto_approve_empty_extras_keep_defaults() {
2311 let cfg = AutoApproveConfig::default();
2312 let wl = cfg.effective_whitelist();
2313 assert!(wl.iter().any(|c| c == "cargo test"));
2314 }
2315
2316 #[test]
2323 fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
2324 use crate::supervisor::auto_approve::is_safe_command;
2325
2326 let tmp = TempDir::new().unwrap();
2329 let extras_path = tmp.path().join("extras.toml");
2330 write_file(
2331 &extras_path,
2332 "[supervisor]\n\
2333 enabled = true\n\
2334 [supervisor.auto_approve]\n\
2335 safe_commands = [\"just smoke\"]\n",
2336 );
2337 let extras_config = load_config_file(&extras_path).unwrap().unwrap();
2338 let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
2339 let extras_whitelist = extras_aa.effective_whitelist();
2340 assert!(
2341 is_safe_command("just smoke -v", &extras_whitelist),
2342 "TOML extra `just smoke` must accept `just smoke -v`"
2343 );
2344 assert!(
2346 is_safe_command("cargo test", &extras_whitelist),
2347 "extras must not displace built-in defaults"
2348 );
2349
2350 let empty_path = tmp.path().join("empty.toml");
2353 write_file(
2354 &empty_path,
2355 "[supervisor]\n\
2356 enabled = true\n\
2357 [supervisor.auto_approve]\n\
2358 safe_commands = []\n",
2359 );
2360 let empty_config = load_config_file(&empty_path).unwrap().unwrap();
2361 let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
2362 let empty_whitelist = empty_aa.effective_whitelist();
2363 assert!(
2364 is_safe_command("cargo test", &empty_whitelist),
2365 "empty safe_commands must keep built-in defaults"
2366 );
2367 assert!(
2368 is_safe_command("cargo fmt --check", &empty_whitelist),
2369 "empty safe_commands must keep `cargo fmt` default"
2370 );
2371 assert!(
2373 !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
2374 "empty safe_commands must not whitelist arbitrary commands"
2375 );
2376 }
2377
2378 #[test]
2381 fn conflict_config_defaults_match_spec() {
2382 let cfg = ConflictConfig::default();
2383 assert_eq!(cfg.window_seconds, 120);
2384 assert!(cfg.warn_on_intent_overlap);
2385 assert!(cfg.escalate_on_violation);
2386 }
2387
2388 #[test]
2389 fn supervisor_with_no_conflict_section_loads_defaults() {
2390 let tmp = TempDir::new().unwrap();
2391 let path = tmp.path().join("config.toml");
2392 write_file(&path, "[supervisor]\nenabled = true\n");
2393 let supervisor = load_config_file(&path)
2394 .unwrap()
2395 .unwrap()
2396 .supervisor
2397 .unwrap();
2398 assert_eq!(supervisor.conflict.window_seconds, 120);
2399 assert!(supervisor.conflict.warn_on_intent_overlap);
2400 assert!(supervisor.conflict.escalate_on_violation);
2401 }
2402
2403 #[test]
2404 fn conflict_section_with_all_fields_overrides_defaults() {
2405 let tmp = TempDir::new().unwrap();
2406 let path = tmp.path().join("config.toml");
2407 write_file(
2408 &path,
2409 "[supervisor]\n\
2410 enabled = true\n\
2411 [supervisor.conflict]\n\
2412 window_seconds = 300\n\
2413 warn_on_intent_overlap = false\n\
2414 escalate_on_violation = false\n",
2415 );
2416 let conflict = load_config_file(&path)
2417 .unwrap()
2418 .unwrap()
2419 .supervisor
2420 .unwrap()
2421 .conflict;
2422 assert_eq!(conflict.window_seconds, 300);
2423 assert!(!conflict.warn_on_intent_overlap);
2424 assert!(!conflict.escalate_on_violation);
2425 }
2426
2427 #[test]
2428 fn conflict_section_with_partial_fields_keeps_other_defaults() {
2429 let tmp = TempDir::new().unwrap();
2430 let path = tmp.path().join("config.toml");
2431 write_file(
2432 &path,
2433 "[supervisor]\n[supervisor.conflict]\nwindow_seconds = 60\n",
2434 );
2435 let conflict = load_config_file(&path)
2436 .unwrap()
2437 .unwrap()
2438 .supervisor
2439 .unwrap()
2440 .conflict;
2441 assert_eq!(conflict.window_seconds, 60);
2442 assert!(conflict.warn_on_intent_overlap);
2443 assert!(conflict.escalate_on_violation);
2444 }
2445
2446 #[test]
2447 fn pre_v05_config_without_conflict_section_loads() {
2448 let tmp = TempDir::new().unwrap();
2449 let path = tmp.path().join("config.toml");
2450 write_file(
2452 &path,
2453 "default_cli = \"claude\"\n\
2454 [supervisor]\n\
2455 enabled = true\n\
2456 agent_approval = \"auto\"\n",
2457 );
2458 let config = load_config_file(&path).unwrap().unwrap();
2459 let supervisor = config.supervisor.unwrap();
2460 assert!(supervisor.enabled);
2461 assert_eq!(supervisor.conflict, ConflictConfig::default());
2463 }
2464
2465 #[test]
2466 fn conflict_config_round_trips_through_save_and_load() {
2467 let tmp = TempDir::new().unwrap();
2468 let config_path = tmp.path().join("config.toml");
2469 let original = PawConfig {
2470 supervisor: Some(SupervisorConfig {
2471 enabled: true,
2472 conflict: ConflictConfig {
2473 window_seconds: 90,
2474 warn_on_intent_overlap: false,
2475 escalate_on_violation: true,
2476 },
2477 ..Default::default()
2478 }),
2479 ..Default::default()
2480 };
2481 save_config_to(&config_path, &original).unwrap();
2482 let loaded = load_config_file(&config_path).unwrap().unwrap();
2483 assert_eq!(loaded.supervisor, original.supervisor);
2484 }
2485
2486 #[test]
2487 fn v030_config_loads_without_auto_approve() {
2488 let tmp = TempDir::new().unwrap();
2491 let path = tmp.path().join("config.toml");
2492 write_file(
2493 &path,
2494 "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
2495 );
2496 let config = load_config_file(&path).unwrap().unwrap();
2497 assert!(config.supervisor.is_none());
2498 assert!(config.broker.enabled);
2499 }
2500
2501 fn write_repo_config(repo_root: &Path, toml: &str) {
2507 write_file(&repo_config_path(repo_root), toml);
2508 }
2509
2510 fn missing_global(tmp: &TempDir) -> PathBuf {
2511 tmp.path().join("nonexistent-global").join("config.toml")
2512 }
2513
2514 #[test]
2516 fn governance_defaults_to_all_none_when_section_absent() {
2517 let tmp = TempDir::new().unwrap();
2518 let path = tmp.path().join("config.toml");
2519 write_file(&path, "default_cli = \"claude\"\n");
2520
2521 let config = load_config_file(&path).unwrap().unwrap();
2522 assert!(config.governance.adr.is_none());
2523 assert!(config.governance.test_strategy.is_none());
2524 assert!(config.governance.security.is_none());
2525 assert!(config.governance.dod.is_none());
2526 assert!(config.governance.constitution.is_none());
2527 }
2528
2529 #[test]
2531 fn governance_all_paths_populated() {
2532 let tmp = TempDir::new().unwrap();
2533 let path = tmp.path().join("config.toml");
2534 write_file(
2535 &path,
2536 "[governance]\n\
2537 adr = \"docs/adr\"\n\
2538 test_strategy = \"docs/test-strategy.md\"\n\
2539 security = \"docs/security-checklist.md\"\n\
2540 dod = \"docs/definition-of-done.md\"\n\
2541 constitution = \".specify/memory/constitution.md\"\n",
2542 );
2543
2544 let config = load_config_file(&path).unwrap().unwrap();
2545 assert_eq!(
2546 config.governance.adr.as_deref(),
2547 Some(Path::new("docs/adr"))
2548 );
2549 assert_eq!(
2550 config.governance.test_strategy.as_deref(),
2551 Some(Path::new("docs/test-strategy.md"))
2552 );
2553 assert_eq!(
2554 config.governance.security.as_deref(),
2555 Some(Path::new("docs/security-checklist.md"))
2556 );
2557 assert_eq!(
2558 config.governance.dod.as_deref(),
2559 Some(Path::new("docs/definition-of-done.md"))
2560 );
2561 assert_eq!(
2562 config.governance.constitution.as_deref(),
2563 Some(Path::new(".specify/memory/constitution.md"))
2564 );
2565 }
2566
2567 #[test]
2569 fn governance_partial_paths_only_some_fields_populated() {
2570 let tmp = TempDir::new().unwrap();
2571 let path = tmp.path().join("config.toml");
2572 write_file(
2573 &path,
2574 "[governance]\n\
2575 dod = \"docs/dod.md\"\n\
2576 security = \"docs/security.md\"\n",
2577 );
2578
2579 let config = load_config_file(&path).unwrap().unwrap();
2580 assert_eq!(
2581 config.governance.dod.as_deref(),
2582 Some(Path::new("docs/dod.md"))
2583 );
2584 assert_eq!(
2585 config.governance.security.as_deref(),
2586 Some(Path::new("docs/security.md"))
2587 );
2588 assert!(config.governance.adr.is_none());
2589 assert!(config.governance.test_strategy.is_none());
2590 assert!(config.governance.constitution.is_none());
2591 }
2592
2593 #[test]
2595 fn governance_absolute_path_preserved_as_is() {
2596 let tmp = TempDir::new().unwrap();
2597 let path = tmp.path().join("config.toml");
2598 write_file(&path, "[governance]\nadr = \"/absolute/path/to/adr\"\n");
2599
2600 let config = load_config_file(&path).unwrap().unwrap();
2601 assert_eq!(
2602 config.governance.adr,
2603 Some(PathBuf::from("/absolute/path/to/adr"))
2604 );
2605 }
2606
2607 #[test]
2609 fn governance_nonexistent_path_loads_cleanly() {
2610 let tmp = TempDir::new().unwrap();
2611 let path = tmp.path().join("config.toml");
2612 write_file(&path, "[governance]\ndod = \"docs/never-existed.md\"\n");
2613
2614 let config = load_config_file(&path).unwrap().unwrap();
2615 assert_eq!(
2616 config.governance.dod,
2617 Some(PathBuf::from("docs/never-existed.md"))
2618 );
2619 }
2620
2621 #[test]
2623 fn governance_round_trips_through_save_and_load() {
2624 let tmp = TempDir::new().unwrap();
2625 let config_path = tmp.path().join("config.toml");
2626
2627 let original = PawConfig {
2628 governance: GovernanceConfig {
2629 adr: Some(PathBuf::from("docs/adr")),
2630 test_strategy: Some(PathBuf::from("docs/test-strategy.md")),
2631 security: Some(PathBuf::from("docs/security.md")),
2632 dod: Some(PathBuf::from("docs/dod.md")),
2633 constitution: Some(PathBuf::from(".specify/memory/constitution.md")),
2634 },
2635 ..Default::default()
2636 };
2637
2638 save_config_to(&config_path, &original).unwrap();
2639 let loaded = load_config_file(&config_path).unwrap().unwrap();
2640 assert_eq!(loaded.governance, original.governance);
2641 }
2642
2643 #[test]
2645 fn governance_v04_config_without_section_loads_with_defaults() {
2646 let tmp = TempDir::new().unwrap();
2647 let path = tmp.path().join("config.toml");
2648 write_file(
2649 &path,
2650 "default_cli = \"claude\"\n\
2651 mouse = true\n\
2652 [broker]\n\
2653 enabled = true\n\
2654 [supervisor]\n\
2655 enabled = true\n\
2656 [specs]\n\
2657 dir = \"specs\"\n\
2658 type = \"openspec\"\n\
2659 [clis.foo]\n\
2660 command = \"/bin/foo\"\n",
2661 );
2662
2663 let config = load_config_file(&path).unwrap().unwrap();
2664 assert_eq!(config.governance, GovernanceConfig::default());
2665 assert!(config.governance.adr.is_none());
2666 assert!(config.governance.test_strategy.is_none());
2667 assert!(config.governance.security.is_none());
2668 assert!(config.governance.dod.is_none());
2669 assert!(config.governance.constitution.is_none());
2670 }
2671
2672 #[test]
2675 fn governance_default_has_only_five_path_fields() {
2676 let GovernanceConfig {
2680 adr,
2681 test_strategy,
2682 security,
2683 dod,
2684 constitution,
2685 } = GovernanceConfig::default();
2686 assert!(adr.is_none());
2687 assert!(test_strategy.is_none());
2688 assert!(security.is_none());
2689 assert!(dod.is_none());
2690 assert!(constitution.is_none());
2691 }
2692
2693 #[test]
2695 fn governance_auto_wires_constitution_when_speckit_detected() {
2696 let tmp = TempDir::new().unwrap();
2697 let repo_root = tmp.path().join("repo");
2698 let specify = repo_root.join(".specify");
2699 let specs = specify.join("specs");
2700 let memory = specify.join("memory");
2701 fs::create_dir_all(&specs).unwrap();
2702 fs::create_dir_all(&memory).unwrap();
2703 let constitution = memory.join("constitution.md");
2704 fs::write(&constitution, "# Constitution\n").unwrap();
2705
2706 write_repo_config(
2707 &repo_root,
2708 "[specs]\n\
2709 type = \"speckit\"\n\
2710 dir = \".specify/specs\"\n",
2711 );
2712
2713 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2714 assert_eq!(
2715 config.governance.constitution.as_deref(),
2716 Some(constitution.as_path())
2717 );
2718 }
2719
2720 #[test]
2722 fn governance_explicit_constitution_preserved_over_auto_wiring() {
2723 let tmp = TempDir::new().unwrap();
2724 let repo_root = tmp.path().join("repo");
2725 let specify = repo_root.join(".specify");
2726 let specs = specify.join("specs");
2727 let memory = specify.join("memory");
2728 fs::create_dir_all(&specs).unwrap();
2729 fs::create_dir_all(&memory).unwrap();
2730 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2731
2732 write_repo_config(
2733 &repo_root,
2734 "[specs]\n\
2735 type = \"speckit\"\n\
2736 dir = \".specify/specs\"\n\
2737 [governance]\n\
2738 constitution = \"docs/principles.md\"\n",
2739 );
2740
2741 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2742 assert_eq!(
2743 config.governance.constitution,
2744 Some(PathBuf::from("docs/principles.md"))
2745 );
2746 }
2747
2748 #[test]
2750 fn governance_auto_wiring_skipped_when_specs_type_is_openspec() {
2751 let tmp = TempDir::new().unwrap();
2752 let repo_root = tmp.path().join("repo");
2753 let specify = repo_root.join(".specify");
2754 let memory = specify.join("memory");
2755 fs::create_dir_all(&memory).unwrap();
2756 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2757 fs::create_dir_all(repo_root.join("specs")).unwrap();
2758
2759 write_repo_config(
2760 &repo_root,
2761 "[specs]\n\
2762 type = \"openspec\"\n\
2763 dir = \"specs\"\n",
2764 );
2765
2766 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2767 assert!(config.governance.constitution.is_none());
2768 }
2769
2770 #[test]
2772 fn governance_auto_wiring_skipped_when_specs_section_absent() {
2773 let tmp = TempDir::new().unwrap();
2774 let repo_root = tmp.path().join("repo");
2775 let memory = repo_root.join(".specify").join("memory");
2776 fs::create_dir_all(&memory).unwrap();
2777 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2778 fs::create_dir_all(repo_root.join(".git-paw")).unwrap();
2779
2780 write_repo_config(&repo_root, "default_cli = \"claude\"\n");
2781
2782 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2783 assert!(config.governance.constitution.is_none());
2784 }
2785
2786 #[test]
2788 fn governance_auto_wiring_skipped_when_constitution_md_absent() {
2789 let tmp = TempDir::new().unwrap();
2790 let repo_root = tmp.path().join("repo");
2791 let specs = repo_root.join(".specify").join("specs");
2792 fs::create_dir_all(&specs).unwrap();
2793 write_repo_config(
2796 &repo_root,
2797 "[specs]\n\
2798 type = \"speckit\"\n\
2799 dir = \".specify/specs\"\n",
2800 );
2801
2802 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2803 assert!(config.governance.constitution.is_none());
2804 }
2805
2806 #[test]
2808 fn governance_explicit_empty_string_constitution_suppresses_auto_wiring() {
2809 let tmp = TempDir::new().unwrap();
2810 let repo_root = tmp.path().join("repo");
2811 let specify = repo_root.join(".specify");
2812 let specs = specify.join("specs");
2813 let memory = specify.join("memory");
2814 fs::create_dir_all(&specs).unwrap();
2815 fs::create_dir_all(&memory).unwrap();
2816 fs::write(memory.join("constitution.md"), "# Constitution\n").unwrap();
2817
2818 write_repo_config(
2819 &repo_root,
2820 "[specs]\n\
2821 type = \"speckit\"\n\
2822 dir = \".specify/specs\"\n\
2823 [governance]\n\
2824 constitution = \"\"\n",
2825 );
2826
2827 let config = load_config_from(&missing_global(&tmp), &repo_root).unwrap();
2828 assert_eq!(config.governance.constitution, Some(PathBuf::from("")));
2829 }
2830
2831 #[test]
2833 fn governance_merge_fields_independently_across_global_and_repo() {
2834 let tmp = TempDir::new().unwrap();
2835 let global_path = tmp.path().join("global").join("config.toml");
2836 let repo_root = tmp.path().join("repo");
2837 fs::create_dir_all(&repo_root).unwrap();
2838
2839 write_file(&global_path, "[governance]\nadr = \"docs/adr\"\n");
2840 write_file(
2841 &repo_config_path(&repo_root),
2842 "[governance]\ndod = \"docs/dod.md\"\n",
2843 );
2844
2845 let config = load_config_from(&global_path, &repo_root).unwrap();
2846 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/adr")));
2847 assert_eq!(config.governance.dod, Some(PathBuf::from("docs/dod.md")));
2848 }
2849
2850 #[test]
2852 fn governance_merge_repo_wins_per_field_when_both_set() {
2853 let tmp = TempDir::new().unwrap();
2854 let global_path = tmp.path().join("global").join("config.toml");
2855 let repo_root = tmp.path().join("repo");
2856 fs::create_dir_all(&repo_root).unwrap();
2857
2858 write_file(&global_path, "[governance]\nadr = \"docs/global-adr\"\n");
2859 write_file(
2860 &repo_config_path(&repo_root),
2861 "[governance]\nadr = \"docs/repo-adr\"\n",
2862 );
2863
2864 let config = load_config_from(&global_path, &repo_root).unwrap();
2865 assert_eq!(config.governance.adr, Some(PathBuf::from("docs/repo-adr")));
2866 }
2867
2868 #[test]
2870 fn governance_load_repo_config_also_auto_wires_constitution() {
2871 let tmp = TempDir::new().unwrap();
2872 let repo_root = tmp.path().join("repo");
2873 let specify = repo_root.join(".specify");
2874 let specs = specify.join("specs");
2875 let memory = specify.join("memory");
2876 fs::create_dir_all(&specs).unwrap();
2877 fs::create_dir_all(&memory).unwrap();
2878 let constitution = memory.join("constitution.md");
2879 fs::write(&constitution, "# Constitution\n").unwrap();
2880
2881 write_repo_config(
2882 &repo_root,
2883 "[specs]\n\
2884 type = \"speckit\"\n\
2885 dir = \".specify/specs\"\n",
2886 );
2887
2888 let config = load_repo_config(&repo_root).unwrap();
2889 assert_eq!(
2890 config.governance.constitution.as_deref(),
2891 Some(constitution.as_path())
2892 );
2893 }
2894
2895 #[test]
2898 fn load_config_with_some_pins_global_to_override_path() {
2899 let tmp = TempDir::new().unwrap();
2900 let repo_root = tmp.path().join("repo");
2901 fs::create_dir_all(&repo_root).unwrap();
2902
2903 let global_a = tmp.path().join("global-A.toml");
2904 let global_b = tmp.path().join("global-B.toml");
2905 write_file(&global_a, "[clis.cli-A]\ncommand = \"/bin/a\"\n");
2906 write_file(&global_b, "[clis.cli-B]\ncommand = \"/bin/b\"\n");
2907
2908 let config = load_config(&repo_root, Some(&global_a)).unwrap();
2909 assert!(config.clis.contains_key("cli-A"));
2910 assert!(!config.clis.contains_key("cli-B"));
2911 }
2912
2913 #[test]
2914 fn load_config_with_some_nonexistent_returns_defaults() {
2915 let tmp = TempDir::new().unwrap();
2916 let repo_root = tmp.path().join("repo");
2917 fs::create_dir_all(&repo_root).unwrap();
2918 let missing = tmp.path().join("does-not-exist.toml");
2919
2920 let config = load_config(&repo_root, Some(&missing)).unwrap();
2921 assert_eq!(config, PawConfig::default());
2922 }
2923
2924 #[test]
2934 fn load_config_override_does_not_affect_repo_resolution() {
2935 let tmp = TempDir::new().unwrap();
2936 let repo_root = tmp.path().join("repo");
2937 fs::create_dir_all(&repo_root).unwrap();
2938 write_file(&repo_config_path(&repo_root), "default_cli = \"claude\"\n");
2939
2940 let global_path = tmp.path().join("global.toml");
2941 write_file(&global_path, "default_cli = \"gemini\"\n");
2942
2943 let config = load_config(&repo_root, Some(&global_path)).unwrap();
2944 assert_eq!(config.default_cli.as_deref(), Some("claude"));
2945 }
2946
2947 #[test]
2954 fn governance_config_rejects_gates_field() {
2955 let toml_input = "[governance]\ndod = \"docs/dod.md\"\n[governance.gates]\ndod = true\n";
2956 let cfg: PawConfig = toml::from_str(toml_input).expect("toml parse");
2957 let gov = cfg.governance;
2958 assert_eq!(gov.dod.as_deref(), Some(Path::new("docs/dod.md")));
2959
2960 let round_trip = toml::to_string(&gov).expect("serialise gov");
2961 assert!(
2962 !round_trip.contains("gates"),
2963 "GovernanceConfig must not round-trip a `gates` field; got: {round_trip}"
2964 );
2965 assert!(
2966 !round_trip.contains("[governance.gates]"),
2967 "GovernanceConfig must not round-trip a `[governance.gates]` section; got: {round_trip}"
2968 );
2969 }
2970}