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)]
36pub struct SpecsConfig {
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub dir: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
42 pub spec_type: Option<String>,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
47pub struct LoggingConfig {
48 #[serde(default)]
50 pub enabled: bool,
51}
52
53#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "kebab-case")]
66pub enum ApprovalLevel {
67 Manual,
69 #[default]
71 Auto,
72 FullAuto,
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
78pub struct DashboardConfig {
79 #[serde(default)]
81 pub show_message_log: bool,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
90pub struct SupervisorConfig {
91 #[serde(default)]
93 pub enabled: bool,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub cli: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub test_command: Option<String>,
102 #[serde(default)]
104 pub agent_approval: ApprovalLevel,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub auto_approve: Option<AutoApproveConfig>,
112}
113
114#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "kebab-case")]
127pub enum ApprovalLevelPreset {
128 Off,
130 Conservative,
132 #[default]
134 Safe,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
148pub struct AutoApproveConfig {
149 #[serde(default = "AutoApproveConfig::default_enabled")]
151 pub enabled: bool,
152 #[serde(default)]
156 pub safe_commands: Vec<String>,
157 #[serde(default = "AutoApproveConfig::default_stall_threshold_seconds")]
160 pub stall_threshold_seconds: u64,
161 #[serde(default)]
168 pub approval_level: ApprovalLevelPreset,
169}
170
171impl Default for AutoApproveConfig {
172 fn default() -> Self {
173 Self {
174 enabled: Self::default_enabled(),
175 safe_commands: Vec::new(),
176 stall_threshold_seconds: Self::default_stall_threshold_seconds(),
177 approval_level: ApprovalLevelPreset::Safe,
178 }
179 }
180}
181
182impl AutoApproveConfig {
183 pub const MIN_STALL_THRESHOLD_SECONDS: u64 = 5;
186
187 fn default_enabled() -> bool {
188 true
189 }
190
191 fn default_stall_threshold_seconds() -> u64 {
192 30
193 }
194
195 #[must_use]
202 pub fn resolved(&self) -> Self {
203 let mut out = self.clone();
204 if out.approval_level == ApprovalLevelPreset::Off {
205 out.enabled = false;
206 }
207 if out.stall_threshold_seconds < Self::MIN_STALL_THRESHOLD_SECONDS {
208 eprintln!(
209 "warning: [supervisor.auto_approve] stall_threshold_seconds = {} clamped to {}s minimum",
210 out.stall_threshold_seconds,
211 Self::MIN_STALL_THRESHOLD_SECONDS
212 );
213 out.stall_threshold_seconds = Self::MIN_STALL_THRESHOLD_SECONDS;
214 }
215 out
216 }
217
218 #[must_use]
225 pub fn effective_whitelist(&self) -> Vec<String> {
226 let mut out: Vec<String> = crate::supervisor::auto_approve::default_safe_commands()
227 .iter()
228 .map(|s| (*s).to_string())
229 .collect();
230 for extra in &self.safe_commands {
231 if !out.iter().any(|e| e == extra) {
232 out.push(extra.clone());
233 }
234 }
235 if self.approval_level == ApprovalLevelPreset::Conservative {
236 out.retain(|cmd| !cmd.starts_with("git push") && !cmd.starts_with("curl"));
237 }
238 out
239 }
240}
241
242#[must_use]
262pub fn approval_flags(cli: &str, level: &ApprovalLevel) -> &'static str {
263 match (cli, level) {
264 ("claude", ApprovalLevel::FullAuto) => "--dangerously-skip-permissions",
265 ("codex", ApprovalLevel::FullAuto) => "--approval-mode=full-auto",
266 ("codex", ApprovalLevel::Auto) => "--approval-mode=auto-edit",
267 _ => "",
268 }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
273pub struct BrokerConfig {
274 #[serde(default)]
276 pub enabled: bool,
277 #[serde(default = "BrokerConfig::default_port")]
279 pub port: u16,
280 #[serde(default = "BrokerConfig::default_bind")]
282 pub bind: String,
283}
284
285impl Default for BrokerConfig {
286 fn default() -> Self {
287 Self {
288 enabled: false,
289 port: 9119,
290 bind: "127.0.0.1".to_string(),
291 }
292 }
293}
294
295impl BrokerConfig {
296 pub fn url(&self) -> String {
298 format!("http://{}:{}", self.bind, self.port)
299 }
300
301 fn default_port() -> u16 {
302 9119
303 }
304
305 fn default_bind() -> String {
306 "127.0.0.1".to_string()
307 }
308}
309
310#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
314pub struct PawConfig {
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub default_cli: Option<String>,
318
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub default_spec_cli: Option<String>,
322
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub branch_prefix: Option<String>,
326
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub mouse: Option<bool>,
330
331 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
333 pub clis: HashMap<String, CustomCli>,
334
335 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
337 pub presets: HashMap<String, Preset>,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub specs: Option<SpecsConfig>,
342
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub logging: Option<LoggingConfig>,
346
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub dashboard: Option<DashboardConfig>,
350
351 #[serde(default)]
353 pub broker: BrokerConfig,
354
355 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub supervisor: Option<SupervisorConfig>,
358}
359
360impl PawConfig {
361 #[must_use]
366 pub fn merged_with(&self, overlay: &Self) -> Self {
367 let mut clis = self.clis.clone();
368 for (k, v) in &overlay.clis {
369 clis.insert(k.clone(), v.clone());
370 }
371
372 let mut presets = self.presets.clone();
373 for (k, v) in &overlay.presets {
374 presets.insert(k.clone(), v.clone());
375 }
376
377 Self {
378 default_cli: overlay
379 .default_cli
380 .clone()
381 .or_else(|| self.default_cli.clone()),
382 default_spec_cli: overlay
383 .default_spec_cli
384 .clone()
385 .or_else(|| self.default_spec_cli.clone()),
386 branch_prefix: overlay
387 .branch_prefix
388 .clone()
389 .or_else(|| self.branch_prefix.clone()),
390 mouse: overlay.mouse.or(self.mouse),
391 clis,
392 presets,
393 specs: overlay.specs.clone().or_else(|| self.specs.clone()),
394 logging: overlay.logging.clone().or_else(|| self.logging.clone()),
395 dashboard: overlay.dashboard.clone().or_else(|| self.dashboard.clone()),
396 broker: if overlay.broker == BrokerConfig::default() {
397 self.broker.clone()
398 } else {
399 overlay.broker.clone()
400 },
401 supervisor: overlay
402 .supervisor
403 .clone()
404 .or_else(|| self.supervisor.clone()),
405 }
406 }
407
408 pub fn get_preset(&self, name: &str) -> Option<&Preset> {
410 self.presets.get(name)
411 }
412
413 pub fn get_dashboard(&self) -> Option<&DashboardConfig> {
415 self.dashboard.as_ref()
416 }
417}
418
419pub fn global_config_path() -> Result<PathBuf, PawError> {
421 crate::dirs::config_dir()
422 .map(|d| d.join("git-paw").join("config.toml"))
423 .ok_or_else(|| PawError::ConfigError("could not determine config directory".into()))
424}
425
426pub fn repo_config_path(repo_root: &Path) -> PathBuf {
428 repo_root.join(".git-paw").join("config.toml")
429}
430
431fn load_config_file(path: &Path) -> Result<Option<PawConfig>, PawError> {
433 match fs::read_to_string(path) {
434 Ok(contents) => {
435 let config: PawConfig = toml::from_str(&contents)
436 .map_err(|e| PawError::ConfigError(format!("{}: {e}", path.display())))?;
437 Ok(Some(config))
438 }
439 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
440 Err(e) => Err(PawError::ConfigError(format!("{}: {e}", path.display()))),
441 }
442}
443
444pub fn load_repo_config(repo_root: &Path) -> Result<PawConfig, PawError> {
449 Ok(load_config_file(&repo_config_path(repo_root))?.unwrap_or_default())
450}
451
452pub fn load_config(repo_root: &Path) -> Result<PawConfig, PawError> {
457 let global_path = global_config_path()?;
458 load_config_from(&global_path, repo_root)
459}
460
461pub fn load_config_from(global_path: &Path, repo_root: &Path) -> Result<PawConfig, PawError> {
463 let global = load_config_file(global_path)?.unwrap_or_default();
464 let repo = load_config_file(&repo_config_path(repo_root))?.unwrap_or_default();
465 Ok(global.merged_with(&repo))
466}
467
468pub fn save_repo_config(repo_root: &Path, config: &PawConfig) -> Result<(), PawError> {
470 save_config_to(&repo_config_path(repo_root), config)
471}
472
473fn save_config_to(path: &Path, config: &PawConfig) -> Result<(), PawError> {
475 let dir = path
476 .parent()
477 .ok_or_else(|| PawError::ConfigError("invalid config path".into()))?;
478 fs::create_dir_all(dir)
479 .map_err(|e| PawError::ConfigError(format!("create config dir: {e}")))?;
480
481 let contents =
482 toml::to_string_pretty(config).map_err(|e| PawError::ConfigError(e.to_string()))?;
483
484 let tmp = path.with_extension("toml.tmp");
486 fs::write(&tmp, &contents)
487 .map_err(|e| PawError::ConfigError(format!("write temp config: {e}")))?;
488 fs::rename(&tmp, path).map_err(|e| PawError::ConfigError(format!("rename config: {e}")))?;
489
490 Ok(())
491}
492
493pub fn add_custom_cli(
497 name: &str,
498 command: &str,
499 display_name: Option<&str>,
500) -> Result<(), PawError> {
501 add_custom_cli_to(&global_config_path()?, name, command, display_name)
502}
503
504pub fn add_custom_cli_to(
508 config_path: &Path,
509 name: &str,
510 command: &str,
511 display_name: Option<&str>,
512) -> Result<(), PawError> {
513 let resolved_command = if Path::new(command).is_absolute() {
514 command.to_string()
515 } else {
516 which::which(command)
517 .map_err(|_| PawError::ConfigError(format!("command '{command}' not found on PATH")))?
518 .to_string_lossy()
519 .into_owned()
520 };
521
522 let mut config = load_config_file(config_path)?.unwrap_or_default();
523
524 config.clis.insert(
525 name.to_string(),
526 CustomCli {
527 command: resolved_command,
528 display_name: display_name.map(String::from),
529 },
530 );
531
532 save_config_to(config_path, &config)
533}
534
535pub fn generate_default_config() -> String {
538 r#"# git-paw configuration
539# See https://github.com/bearicorn/git-paw for documentation.
540
541# Pre-select a CLI in the interactive picker (user can still change).
542# Omit to show the full picker with no default.
543# default_cli = ""
544
545# Enable tmux mouse mode for sessions (default: true).
546# mouse = true
547
548# Bypass the CLI picker entirely for --from-specs mode.
549# Omit to prompt or use per-spec paw_cli fields.
550# default_spec_cli = ""
551
552# Prefix for spec-derived branch names (default: "spec/" ).
553# branch_prefix = "spec/"
554
555# Dashboard message log configuration.
556# [dashboard]
557# show_message_log = false
558
559# Spec scanning configuration.
560# [specs]
561# dir = "specs"
562#
563# OpenSpec format (directory-based, default):
564# type = "openspec"
565#
566# Markdown format (frontmatter-based):
567# type = "markdown"
568# Each .md file uses YAML frontmatter fields:
569# paw_status — "pending" | "done" | "in-progress" (required)
570# paw_branch — branch name suffix (optional, falls back to filename)
571# paw_cli — CLI override for this spec (optional)
572
573# Session logging configuration.
574# [logging]
575# enabled = false
576
577# HTTP broker for agent coordination (requires --broker flag on start).
578# [broker]
579# enabled = true
580# port = 9119
581# bind = "127.0.0.1"
582
583# Supervisor mode — git-paw acts as a coordinating layer in front of the
584# agent CLI, enforcing approval policy and optionally running a test
585# command after each agent completes.
586# [supervisor]
587# enabled = true
588# cli = "claude"
589# test_command = "just check"
590# agent_approval = "auto" # one of: "manual", "auto", "full-auto"
591
592# Custom CLI definitions.
593# [clis.my-agent]
594# command = "/usr/local/bin/my-agent"
595# display_name = "My Agent"
596
597# Named presets for quick launches.
598# [presets.my-preset]
599# branches = ["feat/api", "fix/db"]
600# cli = ""
601"#
602 .to_string()
603}
604
605pub fn remove_custom_cli(name: &str) -> Result<(), PawError> {
609 remove_custom_cli_from(&global_config_path()?, name)
610}
611
612pub fn remove_custom_cli_from(config_path: &Path, name: &str) -> Result<(), PawError> {
616 let mut config = load_config_file(config_path)?.unwrap_or_default();
617
618 if config.clis.remove(name).is_none() {
619 return Err(PawError::CliNotFound(name.to_string()));
620 }
621
622 save_config_to(config_path, &config)
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use tempfile::TempDir;
629
630 fn write_file(path: &Path, content: &str) {
631 if let Some(parent) = path.parent() {
632 fs::create_dir_all(parent).unwrap();
633 }
634 fs::write(path, content).unwrap();
635 }
636
637 #[test]
640 fn parses_config_with_all_fields() {
641 let tmp = TempDir::new().unwrap();
642 let path = tmp.path().join("config.toml");
643 write_file(
644 &path,
645 r#"
646default_cli = "claude"
647mouse = false
648default_spec_cli = "gemini"
649branch_prefix = "spec/"
650
651[clis.my-agent]
652command = "/usr/local/bin/my-agent"
653display_name = "My Agent"
654
655[clis.local-llm]
656command = "ollama-code"
657
658[presets.backend]
659branches = ["feature/api", "fix/db"]
660cli = "claude"
661
662[specs]
663dir = "my-specs"
664type = "openspec"
665
666[logging]
667enabled = true
668"#,
669 );
670
671 let config = load_config_file(&path).unwrap().unwrap();
672 assert_eq!(config.default_cli.as_deref(), Some("claude"));
673 assert_eq!(config.mouse, Some(false));
674 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
675 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
676 assert_eq!(config.clis.len(), 2);
677 assert_eq!(
678 config.clis["my-agent"].display_name.as_deref(),
679 Some("My Agent")
680 );
681 assert_eq!(config.clis["local-llm"].command, "ollama-code");
682 assert_eq!(config.presets["backend"].cli, "claude");
683 assert_eq!(
684 config.presets["backend"].branches,
685 vec!["feature/api", "fix/db"]
686 );
687 let specs = config.specs.unwrap();
688 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
689 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
690 let logging = config.logging.unwrap();
691 assert!(logging.enabled);
692 }
693
694 #[test]
695 fn all_fields_are_optional() {
696 let tmp = TempDir::new().unwrap();
697 let path = tmp.path().join("config.toml");
698 write_file(&path, "default_cli = \"gemini\"\n");
699
700 let config = load_config_file(&path).unwrap().unwrap();
701 assert_eq!(config.default_cli.as_deref(), Some("gemini"));
702 assert_eq!(config.mouse, None);
703 assert!(config.clis.is_empty());
704 assert!(config.presets.is_empty());
705 }
706
707 #[test]
708 fn returns_defaults_when_no_files_exist() {
709 let tmp = TempDir::new().unwrap();
710 let global_path = tmp.path().join("nonexistent").join("config.toml");
711 let repo_root = tmp.path().join("repo");
712 fs::create_dir_all(&repo_root).unwrap();
713
714 let config = load_config_from(&global_path, &repo_root).unwrap();
715 assert_eq!(config.default_cli, None);
716 assert_eq!(config.mouse, None);
717 assert!(config.clis.is_empty());
718 assert!(config.presets.is_empty());
719 }
720
721 #[test]
722 fn reports_error_for_invalid_toml() {
723 let tmp = TempDir::new().unwrap();
724 let path = tmp.path().join("bad.toml");
725 write_file(&path, "this is not [valid toml");
726
727 let err = load_config_file(&path).unwrap_err();
728 assert!(err.to_string().contains("bad.toml"));
729 }
730
731 #[test]
734 fn repo_config_overrides_global_scalars() {
735 let tmp = TempDir::new().unwrap();
736 let global_path = tmp.path().join("global").join("config.toml");
737 let repo_root = tmp.path().join("repo");
738 fs::create_dir_all(&repo_root).unwrap();
739
740 write_file(&global_path, "default_cli = \"claude\"\nmouse = true\n");
741 write_file(
742 &repo_config_path(&repo_root),
743 "default_cli = \"gemini\"\n", );
745
746 let config = load_config_from(&global_path, &repo_root).unwrap();
747 assert_eq!(config.default_cli.as_deref(), Some("gemini")); assert_eq!(config.mouse, Some(true)); }
750
751 #[test]
752 fn repo_config_merges_cli_maps() {
753 let tmp = TempDir::new().unwrap();
754 let global_path = tmp.path().join("global").join("config.toml");
755 let repo_root = tmp.path().join("repo");
756 fs::create_dir_all(&repo_root).unwrap();
757
758 write_file(&global_path, "[clis.agent-a]\ncommand = \"/bin/a\"\n");
759 write_file(
760 &repo_config_path(&repo_root),
761 "[clis.agent-b]\ncommand = \"/bin/b\"\n",
762 );
763
764 let config = load_config_from(&global_path, &repo_root).unwrap();
765 assert_eq!(config.clis.len(), 2);
766 assert!(config.clis.contains_key("agent-a"));
767 assert!(config.clis.contains_key("agent-b"));
768 }
769
770 #[test]
771 fn repo_cli_overrides_global_cli_with_same_name() {
772 let tmp = TempDir::new().unwrap();
773 let global_path = tmp.path().join("global").join("config.toml");
774 let repo_root = tmp.path().join("repo");
775 fs::create_dir_all(&repo_root).unwrap();
776
777 write_file(&global_path, "[clis.my-agent]\ncommand = \"/old/path\"\n");
778 write_file(
779 &repo_config_path(&repo_root),
780 "[clis.my-agent]\ncommand = \"/new/path\"\ndisplay_name = \"Overridden\"\n",
781 );
782
783 let config = load_config_from(&global_path, &repo_root).unwrap();
784 assert_eq!(config.clis["my-agent"].command, "/new/path");
785 assert_eq!(
786 config.clis["my-agent"].display_name.as_deref(),
787 Some("Overridden")
788 );
789 }
790
791 #[test]
792 fn load_config_from_reads_global_file_when_no_repo() {
793 let tmp = TempDir::new().unwrap();
794 let global_path = tmp.path().join("global").join("config.toml");
795 let repo_root = tmp.path().join("repo");
796 fs::create_dir_all(&repo_root).unwrap();
797
798 write_file(&global_path, "default_cli = \"claude\"\nmouse = false\n");
799 let config = load_config_from(&global_path, &repo_root).unwrap();
802 assert_eq!(config.default_cli.as_deref(), Some("claude"));
803 assert_eq!(config.mouse, Some(false));
804 }
805
806 #[test]
807 fn load_config_from_reads_repo_file_when_no_global() {
808 let tmp = TempDir::new().unwrap();
809 let global_path = tmp.path().join("nonexistent").join("config.toml");
810 let repo_root = tmp.path().join("repo");
811 fs::create_dir_all(&repo_root).unwrap();
812
813 write_file(&repo_config_path(&repo_root), "default_cli = \"codex\"\n");
814
815 let config = load_config_from(&global_path, &repo_root).unwrap();
816 assert_eq!(config.default_cli.as_deref(), Some("codex"));
817 }
818
819 #[test]
822 fn preset_accessible_by_name() {
823 let tmp = TempDir::new().unwrap();
824 let global_path = tmp.path().join("global").join("config.toml");
825 let repo_root = tmp.path().join("repo");
826 fs::create_dir_all(&repo_root).unwrap();
827
828 write_file(
829 &repo_config_path(&repo_root),
830 "[presets.backend]\nbranches = [\"feat/api\", \"fix/db\"]\ncli = \"claude\"\n",
831 );
832
833 let config = load_config_from(&global_path, &repo_root).unwrap();
834 let preset = config.get_preset("backend").unwrap();
835 assert_eq!(preset.cli, "claude");
836 assert_eq!(preset.branches, vec!["feat/api", "fix/db"]);
837 }
838
839 #[test]
840 fn preset_returns_none_when_not_in_config() {
841 let tmp = TempDir::new().unwrap();
842 let global_path = tmp.path().join("config.toml");
843 write_file(&global_path, "default_cli = \"claude\"\n");
844
845 let config = load_config_file(&global_path).unwrap().unwrap();
846 assert!(config.get_preset("nonexistent").is_none());
847 }
848
849 #[test]
852 fn add_cli_writes_to_config_file() {
853 let tmp = TempDir::new().unwrap();
854 let config_path = tmp.path().join("git-paw").join("config.toml");
855
856 add_custom_cli_to(
858 &config_path,
859 "my-agent",
860 "/usr/local/bin/my-agent",
861 Some("My Agent"),
862 )
863 .unwrap();
864
865 let config = load_config_file(&config_path).unwrap().unwrap();
867 assert_eq!(config.clis.len(), 1);
868 assert_eq!(config.clis["my-agent"].command, "/usr/local/bin/my-agent");
869 assert_eq!(
870 config.clis["my-agent"].display_name.as_deref(),
871 Some("My Agent")
872 );
873 }
874
875 #[test]
876 fn add_cli_preserves_existing_entries() {
877 let tmp = TempDir::new().unwrap();
878 let config_path = tmp.path().join("git-paw").join("config.toml");
879
880 add_custom_cli_to(&config_path, "first", "/bin/first", None).unwrap();
881 add_custom_cli_to(&config_path, "second", "/bin/second", None).unwrap();
882
883 let config = load_config_file(&config_path).unwrap().unwrap();
884 assert_eq!(config.clis.len(), 2);
885 assert!(config.clis.contains_key("first"));
886 assert!(config.clis.contains_key("second"));
887 }
888
889 #[test]
890 fn add_cli_errors_when_command_not_on_path() {
891 let tmp = TempDir::new().unwrap();
892 let config_path = tmp.path().join("config.toml");
893
894 let err = add_custom_cli_to(&config_path, "bad", "surely-nonexistent-binary-xyz", None)
895 .unwrap_err();
896 assert!(err.to_string().contains("not found on PATH"));
897 }
898
899 #[test]
902 fn remove_cli_deletes_entry_from_config_file() {
903 let tmp = TempDir::new().unwrap();
904 let config_path = tmp.path().join("git-paw").join("config.toml");
905
906 add_custom_cli_to(&config_path, "keep-me", "/bin/keep", None).unwrap();
908 add_custom_cli_to(&config_path, "remove-me", "/bin/remove", None).unwrap();
909
910 remove_custom_cli_from(&config_path, "remove-me").unwrap();
912
913 let config = load_config_file(&config_path).unwrap().unwrap();
915 assert_eq!(config.clis.len(), 1);
916 assert!(config.clis.contains_key("keep-me"));
917 assert!(!config.clis.contains_key("remove-me"));
918 }
919
920 #[test]
921 fn remove_nonexistent_cli_returns_cli_not_found_error() {
922 let tmp = TempDir::new().unwrap();
923 let config_path = tmp.path().join("config.toml");
924 write_file(&config_path, "");
926
927 let err = remove_custom_cli_from(&config_path, "nonexistent").unwrap_err();
928 match err {
929 PawError::CliNotFound(name) => assert_eq!(name, "nonexistent"),
930 other => panic!("expected CliNotFound, got: {other}"),
931 }
932 }
933
934 #[test]
935 fn remove_cli_from_empty_config_returns_error() {
936 let tmp = TempDir::new().unwrap();
937 let config_path = tmp.path().join("config.toml");
938 let err = remove_custom_cli_from(&config_path, "ghost").unwrap_err();
941 match err {
942 PawError::CliNotFound(name) => assert_eq!(name, "ghost"),
943 other => panic!("expected CliNotFound, got: {other}"),
944 }
945 }
946
947 #[test]
952 fn parses_default_spec_cli_when_present() {
953 let tmp = TempDir::new().unwrap();
954 let path = tmp.path().join("config.toml");
955 write_file(&path, "default_spec_cli = \"claude\"\n");
956
957 let config = load_config_file(&path).unwrap().unwrap();
958 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
959 }
960
961 #[test]
962 fn default_spec_cli_defaults_to_none() {
963 let tmp = TempDir::new().unwrap();
964 let path = tmp.path().join("config.toml");
965 write_file(&path, "default_cli = \"claude\"\n");
966
967 let config = load_config_file(&path).unwrap().unwrap();
968 assert_eq!(config.default_spec_cli, None);
969 }
970
971 #[test]
972 fn repo_overrides_global_default_spec_cli() {
973 let tmp = TempDir::new().unwrap();
974 let global_path = tmp.path().join("global").join("config.toml");
975 let repo_root = tmp.path().join("repo");
976 fs::create_dir_all(&repo_root).unwrap();
977
978 write_file(&global_path, "default_spec_cli = \"claude\"\n");
979 write_file(
980 &repo_config_path(&repo_root),
981 "default_spec_cli = \"gemini\"\n",
982 );
983
984 let config = load_config_from(&global_path, &repo_root).unwrap();
985 assert_eq!(config.default_spec_cli.as_deref(), Some("gemini"));
986 }
987
988 #[test]
989 fn global_default_spec_cli_preserved_when_repo_absent() {
990 let tmp = TempDir::new().unwrap();
991 let global_path = tmp.path().join("global").join("config.toml");
992 let repo_root = tmp.path().join("repo");
993 fs::create_dir_all(&repo_root).unwrap();
994
995 write_file(&global_path, "default_spec_cli = \"claude\"\n");
996
997 let config = load_config_from(&global_path, &repo_root).unwrap();
998 assert_eq!(config.default_spec_cli.as_deref(), Some("claude"));
999 }
1000
1001 #[test]
1004 fn config_survives_save_and_load() {
1005 let tmp = TempDir::new().unwrap();
1006 let config_path = tmp.path().join("config.toml");
1007
1008 let original = PawConfig {
1009 default_cli: Some("claude".into()),
1010 default_spec_cli: None,
1011 branch_prefix: None,
1012 mouse: Some(true),
1013 clis: HashMap::from([(
1014 "test".into(),
1015 CustomCli {
1016 command: "/bin/test".into(),
1017 display_name: Some("Test CLI".into()),
1018 },
1019 )]),
1020 presets: HashMap::from([(
1021 "dev".into(),
1022 Preset {
1023 branches: vec!["main".into()],
1024 cli: "claude".into(),
1025 },
1026 )]),
1027 specs: None,
1028 logging: None,
1029 dashboard: None,
1030 broker: BrokerConfig::default(),
1031 supervisor: None,
1032 };
1033
1034 save_config_to(&config_path, &original).unwrap();
1035 let loaded = load_config_file(&config_path).unwrap().unwrap();
1036 assert_eq!(original, loaded);
1037 }
1038
1039 #[test]
1042 fn parses_specs_section_with_populated_fields() {
1043 let tmp = TempDir::new().unwrap();
1044 let path = tmp.path().join("config.toml");
1045 write_file(&path, "[specs]\ndir = \"my-specs\"\ntype = \"openspec\"\n");
1046
1047 let config = load_config_file(&path).unwrap().unwrap();
1048 let specs = config.specs.unwrap();
1049 assert_eq!(specs.dir.as_deref(), Some("my-specs"));
1050 assert_eq!(specs.spec_type.as_deref(), Some("openspec"));
1051 }
1052
1053 #[test]
1056 fn parses_logging_section_with_enabled() {
1057 let tmp = TempDir::new().unwrap();
1058 let path = tmp.path().join("config.toml");
1059 write_file(&path, "[logging]\nenabled = true\n");
1060
1061 let config = load_config_file(&path).unwrap().unwrap();
1062 let logging = config.logging.unwrap();
1063 assert!(logging.enabled);
1064 }
1065
1066 #[test]
1069 fn round_trip_with_specs_and_logging() {
1070 let tmp = TempDir::new().unwrap();
1071 let config_path = tmp.path().join("config.toml");
1072
1073 let original = PawConfig {
1074 specs: Some(SpecsConfig {
1075 dir: Some("specs".into()),
1076 spec_type: Some("openspec".into()),
1077 }),
1078 logging: Some(LoggingConfig { enabled: true }),
1079 ..Default::default()
1080 };
1081
1082 save_config_to(&config_path, &original).unwrap();
1083 let loaded = load_config_file(&config_path).unwrap().unwrap();
1084 assert_eq!(original, loaded);
1085 assert_eq!(loaded.specs.unwrap().dir.as_deref(), Some("specs"));
1086 assert!(loaded.logging.unwrap().enabled);
1087 }
1088
1089 #[test]
1092 fn generated_default_config_is_valid_toml() {
1093 let raw = generate_default_config();
1094 let stripped: String = raw
1095 .lines()
1096 .filter(|line| !line.trim_start().starts_with('#'))
1097 .collect::<Vec<&str>>()
1098 .join("\n");
1099
1100 let parsed: Result<PawConfig, _> = toml::from_str(&stripped);
1101 assert!(
1102 parsed.is_ok(),
1103 "generated config with comments stripped should be valid TOML, got: {:?}",
1104 parsed.unwrap_err()
1105 );
1106 }
1107
1108 #[test]
1111 fn branch_prefix_repo_overrides_global() {
1112 let tmp = TempDir::new().unwrap();
1113 let global_path = tmp.path().join("global").join("config.toml");
1114 let repo_root = tmp.path().join("repo");
1115 fs::create_dir_all(&repo_root).unwrap();
1116
1117 write_file(&global_path, "branch_prefix = \"feat/\"\n");
1118 write_file(&repo_config_path(&repo_root), "branch_prefix = \"spec/\"\n");
1119
1120 let config = load_config_from(&global_path, &repo_root).unwrap();
1121 assert_eq!(config.branch_prefix.as_deref(), Some("spec/"));
1122 }
1123
1124 #[test]
1125 fn generated_default_config_contains_commented_examples() {
1126 let output = generate_default_config();
1127 assert!(
1128 output.contains("default_spec_cli"),
1129 "should contain default_spec_cli"
1130 );
1131 assert!(
1132 output.contains("branch_prefix"),
1133 "should contain branch_prefix"
1134 );
1135 assert!(output.contains("[specs]"), "should contain [specs]");
1136 assert!(output.contains("[logging]"), "should contain [logging]");
1137 assert!(output.contains("[broker]"), "should contain [broker]");
1138 }
1139
1140 #[test]
1143 fn broker_config_defaults() {
1144 let config = BrokerConfig::default();
1145 assert!(!config.enabled);
1146 assert_eq!(config.port, 9119);
1147 assert_eq!(config.bind, "127.0.0.1");
1148 }
1149
1150 #[test]
1151 fn broker_config_url() {
1152 let config = BrokerConfig::default();
1153 assert_eq!(config.url(), "http://127.0.0.1:9119");
1154
1155 let custom = BrokerConfig {
1156 enabled: true,
1157 port: 8080,
1158 bind: "0.0.0.0".to_string(),
1159 };
1160 assert_eq!(custom.url(), "http://0.0.0.0:8080");
1161 }
1162
1163 #[test]
1164 fn empty_config_gets_broker_defaults() {
1165 let tmp = TempDir::new().unwrap();
1166 let path = tmp.path().join("config.toml");
1167 write_file(&path, "");
1168
1169 let config = load_config_file(&path).unwrap().unwrap();
1170 assert!(!config.broker.enabled);
1171 assert_eq!(config.broker.port, 9119);
1172 assert_eq!(config.broker.bind, "127.0.0.1");
1173 }
1174
1175 #[test]
1176 fn parses_full_broker_section() {
1177 let tmp = TempDir::new().unwrap();
1178 let path = tmp.path().join("config.toml");
1179 write_file(
1180 &path,
1181 "[broker]\nenabled = true\nport = 8080\nbind = \"0.0.0.0\"\n",
1182 );
1183
1184 let config = load_config_file(&path).unwrap().unwrap();
1185 assert!(config.broker.enabled);
1186 assert_eq!(config.broker.port, 8080);
1187 assert_eq!(config.broker.bind, "0.0.0.0");
1188 }
1189
1190 #[test]
1191 fn parses_partial_broker_section() {
1192 let tmp = TempDir::new().unwrap();
1193 let path = tmp.path().join("config.toml");
1194 write_file(&path, "[broker]\nenabled = true\n");
1195
1196 let config = load_config_file(&path).unwrap().unwrap();
1197 assert!(config.broker.enabled);
1198 assert_eq!(config.broker.port, 9119);
1199 assert_eq!(config.broker.bind, "127.0.0.1");
1200 }
1201
1202 #[test]
1205 fn supervisor_is_none_when_section_absent() {
1206 let tmp = TempDir::new().unwrap();
1207 let path = tmp.path().join("config.toml");
1208 write_file(&path, "default_cli = \"claude\"\n");
1209
1210 let config = load_config_file(&path).unwrap().unwrap();
1211 assert!(config.supervisor.is_none());
1212 }
1213
1214 #[test]
1215 fn parses_full_supervisor_section() {
1216 let tmp = TempDir::new().unwrap();
1217 let path = tmp.path().join("config.toml");
1218 write_file(
1219 &path,
1220 "[supervisor]\n\
1221 enabled = true\n\
1222 cli = \"claude\"\n\
1223 test_command = \"just check\"\n\
1224 agent_approval = \"full-auto\"\n",
1225 );
1226
1227 let config = load_config_file(&path).unwrap().unwrap();
1228 let supervisor = config.supervisor.unwrap();
1229 assert!(supervisor.enabled);
1230 assert_eq!(supervisor.cli.as_deref(), Some("claude"));
1231 assert_eq!(supervisor.test_command.as_deref(), Some("just check"));
1232 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
1233 }
1234
1235 #[test]
1236 fn parses_partial_supervisor_section() {
1237 let tmp = TempDir::new().unwrap();
1238 let path = tmp.path().join("config.toml");
1239 write_file(&path, "[supervisor]\nenabled = true\n");
1240
1241 let config = load_config_file(&path).unwrap().unwrap();
1242 let supervisor = config.supervisor.unwrap();
1243 assert!(supervisor.enabled);
1244 assert_eq!(supervisor.cli, None);
1245 assert_eq!(supervisor.test_command, None);
1246 assert_eq!(supervisor.agent_approval, ApprovalLevel::Auto);
1247 }
1248
1249 #[test]
1250 fn rejects_invalid_approval_level() {
1251 let tmp = TempDir::new().unwrap();
1252 let path = tmp.path().join("config.toml");
1253 write_file(&path, "[supervisor]\nagent_approval = \"yolo\"\n");
1254
1255 let err = load_config_file(&path).unwrap_err();
1256 assert!(
1257 err.to_string().contains("yolo"),
1258 "error should mention invalid value, got: {err}"
1259 );
1260 }
1261
1262 #[test]
1263 fn supervisor_round_trips_through_save_and_load() {
1264 let tmp = TempDir::new().unwrap();
1265 let config_path = tmp.path().join("config.toml");
1266
1267 let original = PawConfig {
1268 supervisor: Some(SupervisorConfig {
1269 enabled: true,
1270 cli: Some("claude".into()),
1271 test_command: Some("just check".into()),
1272 agent_approval: ApprovalLevel::FullAuto,
1273 auto_approve: None,
1274 }),
1275 ..Default::default()
1276 };
1277
1278 save_config_to(&config_path, &original).unwrap();
1279 let loaded = load_config_file(&config_path).unwrap().unwrap();
1280 assert_eq!(loaded.supervisor, original.supervisor);
1281 }
1282
1283 #[test]
1284 fn existing_v030_config_loads_without_supervisor() {
1285 let tmp = TempDir::new().unwrap();
1286 let path = tmp.path().join("config.toml");
1287 write_file(
1288 &path,
1289 "default_cli = \"claude\"\n\
1290 mouse = true\n\
1291 [broker]\n\
1292 enabled = true\n\
1293 [logging]\n\
1294 enabled = false\n",
1295 );
1296
1297 let config = load_config_file(&path).unwrap().unwrap();
1298 assert_eq!(config.default_cli.as_deref(), Some("claude"));
1299 assert!(config.broker.enabled);
1300 assert!(config.supervisor.is_none());
1301 }
1302
1303 #[test]
1304 fn generated_default_config_contains_commented_supervisor_section() {
1305 let output = generate_default_config();
1306 assert!(output.contains("[supervisor]"));
1307 assert!(output.contains("enabled"));
1308 assert!(output.contains("test_command"));
1309 assert!(output.contains("agent_approval"));
1310 }
1311
1312 #[test]
1315 fn dashboard_config_defaults_to_disabled() {
1316 let config = DashboardConfig::default();
1317 assert!(!config.show_message_log);
1318 }
1319
1320 #[test]
1321 fn parses_dashboard_section_with_show_message_log() {
1322 let tmp = TempDir::new().unwrap();
1323 let path = tmp.path().join("config.toml");
1324 write_file(&path, "[dashboard]\nshow_message_log = true\n");
1325
1326 let config = load_config_file(&path).unwrap().unwrap();
1327 let dashboard = config.dashboard.unwrap();
1328 assert!(dashboard.show_message_log);
1329 }
1330
1331 #[test]
1332 fn dashboard_is_none_when_section_absent() {
1333 let tmp = TempDir::new().unwrap();
1334 let path = tmp.path().join("config.toml");
1335 write_file(&path, "default_cli = \"claude\"\n");
1336
1337 let config = load_config_file(&path).unwrap().unwrap();
1338 assert!(config.dashboard.is_none());
1339 }
1340
1341 #[test]
1342 fn dashboard_merge_repo_wins() {
1343 let tmp = TempDir::new().unwrap();
1344 let global_path = tmp.path().join("global").join("config.toml");
1345 let repo_root = tmp.path().join("repo");
1346 fs::create_dir_all(&repo_root).unwrap();
1347
1348 write_file(&global_path, "[dashboard]\nshow_message_log = false\n");
1349 write_file(
1350 &repo_config_path(&repo_root),
1351 "[dashboard]\nshow_message_log = true\n",
1352 );
1353
1354 let config = load_config_from(&global_path, &repo_root).unwrap();
1355 let dashboard = config.dashboard.unwrap();
1356 assert!(dashboard.show_message_log);
1357 }
1358
1359 #[test]
1360 fn dashboard_round_trip_through_save_and_load() {
1361 let tmp = TempDir::new().unwrap();
1362 let config_path = tmp.path().join("config.toml");
1363
1364 let original = PawConfig {
1365 dashboard: Some(DashboardConfig {
1366 show_message_log: true,
1367 }),
1368 ..Default::default()
1369 };
1370
1371 save_config_to(&config_path, &original).unwrap();
1372 let loaded = load_config_file(&config_path).unwrap().unwrap();
1373 assert_eq!(loaded.dashboard, original.dashboard);
1374 assert!(loaded.dashboard.unwrap().show_message_log);
1375 }
1376
1377 #[test]
1378 fn get_dashboard_returns_none_when_not_configured() {
1379 let config = PawConfig::default();
1380 assert!(config.get_dashboard().is_none());
1381 }
1382
1383 #[test]
1384 fn get_dashboard_returns_config_when_present() {
1385 let config = PawConfig {
1386 dashboard: Some(DashboardConfig {
1387 show_message_log: true,
1388 }),
1389 ..Default::default()
1390 };
1391 let dashboard = config.get_dashboard().unwrap();
1392 assert!(dashboard.show_message_log);
1393 }
1394
1395 #[test]
1398 fn approval_flags_claude_full_auto() {
1399 assert_eq!(
1400 approval_flags("claude", &ApprovalLevel::FullAuto),
1401 "--dangerously-skip-permissions"
1402 );
1403 }
1404
1405 #[test]
1406 fn approval_flags_codex_auto() {
1407 assert_eq!(
1408 approval_flags("codex", &ApprovalLevel::Auto),
1409 "--approval-mode=auto-edit"
1410 );
1411 }
1412
1413 #[test]
1414 fn approval_flags_codex_full_auto() {
1415 assert_eq!(
1416 approval_flags("codex", &ApprovalLevel::FullAuto),
1417 "--approval-mode=full-auto"
1418 );
1419 }
1420
1421 #[test]
1422 fn approval_flags_unknown_cli_is_empty() {
1423 assert_eq!(approval_flags("some-agent", &ApprovalLevel::FullAuto), "");
1424 }
1425
1426 #[test]
1427 fn approval_flags_manual_is_empty() {
1428 assert_eq!(approval_flags("claude", &ApprovalLevel::Manual), "");
1429 assert_eq!(approval_flags("codex", &ApprovalLevel::Manual), "");
1430 }
1431
1432 #[test]
1433 fn approval_flags_is_deterministic() {
1434 let first = approval_flags("claude", &ApprovalLevel::FullAuto);
1435 let second = approval_flags("claude", &ApprovalLevel::FullAuto);
1436 assert_eq!(first, second);
1437 }
1438
1439 #[test]
1440 fn supervisor_merge_repo_wins() {
1441 let tmp = TempDir::new().unwrap();
1442 let global_path = tmp.path().join("global").join("config.toml");
1443 let repo_root = tmp.path().join("repo");
1444 fs::create_dir_all(&repo_root).unwrap();
1445
1446 write_file(
1447 &global_path,
1448 "[supervisor]\nenabled = false\nagent_approval = \"manual\"\n",
1449 );
1450 write_file(
1451 &repo_config_path(&repo_root),
1452 "[supervisor]\nenabled = true\nagent_approval = \"full-auto\"\n",
1453 );
1454
1455 let config = load_config_from(&global_path, &repo_root).unwrap();
1456 let supervisor = config.supervisor.unwrap();
1457 assert!(supervisor.enabled);
1458 assert_eq!(supervisor.agent_approval, ApprovalLevel::FullAuto);
1459 }
1460
1461 #[test]
1462 fn broker_config_round_trip() {
1463 let tmp = TempDir::new().unwrap();
1464 let config_path = tmp.path().join("config.toml");
1465
1466 let original = PawConfig {
1467 broker: BrokerConfig {
1468 enabled: true,
1469 port: 9200,
1470 bind: "127.0.0.1".to_string(),
1471 },
1472 ..Default::default()
1473 };
1474
1475 save_config_to(&config_path, &original).unwrap();
1476 let loaded = load_config_file(&config_path).unwrap().unwrap();
1477 assert_eq!(loaded.broker.enabled, original.broker.enabled);
1478 assert_eq!(loaded.broker.port, original.broker.port);
1479 assert_eq!(loaded.broker.bind, original.broker.bind);
1480 }
1481
1482 #[test]
1485 fn auto_approve_defaults_match_spec() {
1486 let cfg = AutoApproveConfig::default();
1487 assert!(cfg.enabled, "enabled defaults to true");
1488 assert!(
1489 cfg.safe_commands.is_empty(),
1490 "safe_commands defaults to empty"
1491 );
1492 assert_eq!(cfg.stall_threshold_seconds, 30);
1493 assert_eq!(cfg.approval_level, ApprovalLevelPreset::Safe);
1494 }
1495
1496 #[test]
1497 fn auto_approve_section_absent_keeps_supervisor_simple() {
1498 let tmp = TempDir::new().unwrap();
1499 let path = tmp.path().join("config.toml");
1500 write_file(&path, "[supervisor]\nenabled = true\n");
1501 let config = load_config_file(&path).unwrap().unwrap();
1502 let supervisor = config.supervisor.unwrap();
1503 assert!(supervisor.auto_approve.is_none());
1504 }
1505
1506 #[test]
1507 fn auto_approve_section_parses_full_body() {
1508 let tmp = TempDir::new().unwrap();
1509 let path = tmp.path().join("config.toml");
1510 write_file(
1511 &path,
1512 "[supervisor]\n\
1513 enabled = true\n\
1514 [supervisor.auto_approve]\n\
1515 enabled = false\n\
1516 safe_commands = [\"just smoke\"]\n\
1517 stall_threshold_seconds = 60\n\
1518 approval_level = \"conservative\"\n",
1519 );
1520 let config = load_config_file(&path).unwrap().unwrap();
1521 let aa = config.supervisor.unwrap().auto_approve.unwrap();
1522 assert!(!aa.enabled);
1523 assert_eq!(aa.safe_commands, vec!["just smoke".to_string()]);
1524 assert_eq!(aa.stall_threshold_seconds, 60);
1525 assert_eq!(aa.approval_level, ApprovalLevelPreset::Conservative);
1526 }
1527
1528 #[test]
1529 fn auto_approve_enabled_defaults_to_true_when_omitted() {
1530 let tmp = TempDir::new().unwrap();
1531 let path = tmp.path().join("config.toml");
1532 write_file(
1533 &path,
1534 "[supervisor]\n[supervisor.auto_approve]\nstall_threshold_seconds = 30\n",
1535 );
1536 let config = load_config_file(&path).unwrap().unwrap();
1537 let aa = config.supervisor.unwrap().auto_approve.unwrap();
1538 assert!(aa.enabled, "enabled should default to true");
1539 }
1540
1541 #[test]
1542 fn auto_approve_off_preset_forces_disabled() {
1543 let cfg = AutoApproveConfig {
1544 enabled: true,
1545 approval_level: ApprovalLevelPreset::Off,
1546 ..AutoApproveConfig::default()
1547 };
1548 let resolved = cfg.resolved();
1549 assert!(!resolved.enabled, "Off preset must force enabled = false");
1550 }
1551
1552 #[test]
1553 fn auto_approve_threshold_floor_clamps() {
1554 let cfg = AutoApproveConfig {
1555 stall_threshold_seconds: 0,
1556 ..AutoApproveConfig::default()
1557 };
1558 let resolved = cfg.resolved();
1559 assert_eq!(
1560 resolved.stall_threshold_seconds,
1561 AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS
1562 );
1563 }
1564
1565 #[test]
1566 fn auto_approve_safe_preset_keeps_defaults() {
1567 let cfg = AutoApproveConfig {
1568 approval_level: ApprovalLevelPreset::Safe,
1569 ..AutoApproveConfig::default()
1570 };
1571 let wl = cfg.effective_whitelist();
1572 assert!(wl.iter().any(|c| c == "cargo test"));
1573 assert!(wl.iter().any(|c| c == "git push"));
1574 assert!(wl.iter().any(|c| c.starts_with("curl")));
1575 }
1576
1577 #[test]
1578 fn auto_approve_conservative_drops_push_and_curl() {
1579 let cfg = AutoApproveConfig {
1580 approval_level: ApprovalLevelPreset::Conservative,
1581 ..AutoApproveConfig::default()
1582 };
1583 let wl = cfg.effective_whitelist();
1584 assert!(wl.iter().any(|c| c == "cargo test"));
1585 assert!(
1586 !wl.iter().any(|c| c.starts_with("git push")),
1587 "conservative drops git push"
1588 );
1589 assert!(
1590 !wl.iter().any(|c| c.starts_with("curl")),
1591 "conservative drops curl"
1592 );
1593 }
1594
1595 #[test]
1596 fn auto_approve_extras_are_unioned_with_defaults() {
1597 let cfg = AutoApproveConfig {
1598 safe_commands: vec!["just lint".to_string(), "just test".to_string()],
1599 ..AutoApproveConfig::default()
1600 };
1601 let wl = cfg.effective_whitelist();
1602 assert!(wl.iter().any(|c| c == "cargo fmt"));
1603 assert!(wl.iter().any(|c| c == "just lint"));
1604 assert!(wl.iter().any(|c| c == "just test"));
1605 }
1606
1607 #[test]
1608 fn auto_approve_empty_extras_keep_defaults() {
1609 let cfg = AutoApproveConfig::default();
1610 let wl = cfg.effective_whitelist();
1611 assert!(wl.iter().any(|c| c == "cargo test"));
1612 }
1613
1614 #[test]
1621 fn toml_extras_classify_via_is_safe_command_and_empty_extras_keep_defaults() {
1622 use crate::supervisor::auto_approve::is_safe_command;
1623
1624 let tmp = TempDir::new().unwrap();
1627 let extras_path = tmp.path().join("extras.toml");
1628 write_file(
1629 &extras_path,
1630 "[supervisor]\n\
1631 enabled = true\n\
1632 [supervisor.auto_approve]\n\
1633 safe_commands = [\"just smoke\"]\n",
1634 );
1635 let extras_config = load_config_file(&extras_path).unwrap().unwrap();
1636 let extras_aa = extras_config.supervisor.unwrap().auto_approve.unwrap();
1637 let extras_whitelist = extras_aa.effective_whitelist();
1638 assert!(
1639 is_safe_command("just smoke -v", &extras_whitelist),
1640 "TOML extra `just smoke` must accept `just smoke -v`"
1641 );
1642 assert!(
1644 is_safe_command("cargo test", &extras_whitelist),
1645 "extras must not displace built-in defaults"
1646 );
1647
1648 let empty_path = tmp.path().join("empty.toml");
1651 write_file(
1652 &empty_path,
1653 "[supervisor]\n\
1654 enabled = true\n\
1655 [supervisor.auto_approve]\n\
1656 safe_commands = []\n",
1657 );
1658 let empty_config = load_config_file(&empty_path).unwrap().unwrap();
1659 let empty_aa = empty_config.supervisor.unwrap().auto_approve.unwrap();
1660 let empty_whitelist = empty_aa.effective_whitelist();
1661 assert!(
1662 is_safe_command("cargo test", &empty_whitelist),
1663 "empty safe_commands must keep built-in defaults"
1664 );
1665 assert!(
1666 is_safe_command("cargo fmt --check", &empty_whitelist),
1667 "empty safe_commands must keep `cargo fmt` default"
1668 );
1669 assert!(
1671 !is_safe_command("rm -rf /tmp/foo", &empty_whitelist),
1672 "empty safe_commands must not whitelist arbitrary commands"
1673 );
1674 }
1675
1676 #[test]
1677 fn v030_config_loads_without_auto_approve() {
1678 let tmp = TempDir::new().unwrap();
1681 let path = tmp.path().join("config.toml");
1682 write_file(
1683 &path,
1684 "default_cli = \"claude\"\nmouse = true\n[broker]\nenabled = true\n",
1685 );
1686 let config = load_config_file(&path).unwrap().unwrap();
1687 assert!(config.supervisor.is_none());
1688 assert!(config.broker.enabled);
1689 }
1690}