1use std::path::PathBuf;
2
3use clap::{Parser, Subcommand, ValueEnum};
4
5#[derive(Parser, Debug)]
6#[command(
7 name = "batty",
8 about = "Hierarchical agent team system for software development",
9 version = concat!("0.1.0\nhttps://github.com/battysh/batty")
10)]
11pub struct Cli {
12 #[command(subcommand)]
13 pub command: Command,
14
15 #[arg(short, long, action = clap::ArgAction::Count, global = true)]
17 pub verbose: u8,
18}
19
20#[derive(Subcommand, Debug)]
21pub enum Command {
22 #[command(alias = "install")]
24 Init {
25 #[arg(long, value_enum, conflicts_with = "from")]
27 template: Option<InitTemplate>,
28 #[arg(long, conflicts_with = "template")]
30 from: Option<String>,
31 #[arg(long)]
33 force: bool,
34 #[arg(long)]
36 agent: Option<String>,
37 },
38
39 ExportTemplate {
41 name: String,
43 },
44
45 ExportRun,
47
48 Retro {
50 #[arg(long)]
52 events: Option<PathBuf>,
53 },
54
55 Start {
57 #[arg(long, default_value_t = false)]
59 attach: bool,
60 },
61
62 Stop,
64
65 Attach,
67
68 Status {
70 #[arg(long, default_value_t = false)]
72 json: bool,
73 },
74
75 Send {
77 #[arg(long, hide = true)]
79 from: Option<String>,
80 role: String,
82 message: String,
84 },
85
86 Assign {
88 engineer: String,
90 task: String,
92 },
93
94 Validate {
96 #[arg(long, default_value_t = false)]
98 show_checks: bool,
99 },
100
101 Config {
103 #[arg(long, default_value_t = false)]
105 json: bool,
106 },
107
108 Board {
110 #[command(subcommand)]
111 command: Option<BoardCommand>,
112 },
113
114 #[command(args_conflicts_with_subcommands = true)]
116 Inbox {
117 #[command(subcommand)]
118 command: Option<InboxCommand>,
119 member: Option<String>,
121 #[arg(
123 short = 'n',
124 long = "limit",
125 default_value_t = 20,
126 conflicts_with = "all"
127 )]
128 limit: usize,
129 #[arg(long, default_value_t = false)]
131 all: bool,
132 },
133
134 Read {
136 member: String,
138 id: String,
140 },
141
142 Ack {
144 member: String,
146 id: String,
148 },
149
150 Merge {
152 engineer: String,
154 },
155
156 Task {
158 #[command(subcommand)]
159 command: TaskCommand,
160 },
161
162 Review {
164 task_id: u32,
166 #[arg(value_enum)]
168 disposition: ReviewAction,
169 feedback: Option<String>,
171 #[arg(long, default_value = "human")]
173 reviewer: String,
174 },
175
176 Completions {
178 #[arg(value_enum)]
180 shell: CompletionShell,
181 },
182
183 Nudge {
185 #[command(subcommand)]
186 command: NudgeCommand,
187 },
188
189 Pause,
191
192 Resume,
194
195 Grafana {
197 #[command(subcommand)]
198 command: GrafanaCommand,
199 },
200
201 Telegram,
203
204 Load,
206
207 Queue,
209
210 Cost,
212
213 Scale {
215 #[command(subcommand)]
216 command: ScaleCommand,
217 },
218
219 Doctor {
221 #[arg(long, default_value_t = false)]
223 fix: bool,
224 #[arg(long, default_value_t = false, requires = "fix")]
226 yes: bool,
227 },
228
229 Metrics,
231
232 Telemetry {
234 #[command(subcommand)]
235 command: TelemetryCommand,
236 },
237
238 Chat {
240 #[arg(long, default_value = "generic")]
242 agent_type: String,
243
244 #[arg(long)]
246 cmd: Option<String>,
247
248 #[arg(long, default_value = ".")]
250 cwd: String,
251
252 #[arg(long, default_value_t = false)]
254 sdk_mode: bool,
255 },
256
257 #[command(hide = true)]
259 Shim {
260 #[arg(long)]
262 id: String,
263
264 #[arg(long)]
266 agent_type: String,
267
268 #[arg(long)]
270 cmd: String,
271
272 #[arg(long)]
274 cwd: String,
275
276 #[arg(long, default_value = "50")]
278 rows: u16,
279
280 #[arg(long, default_value = "220")]
282 cols: u16,
283
284 #[arg(long)]
286 pty_log_path: Option<String>,
287
288 #[arg(long, default_value_t = false)]
290 sdk_mode: bool,
291 },
292
293 #[command(hide = true)]
295 ConsolePane {
296 #[arg(long)]
298 project_root: String,
299
300 #[arg(long)]
302 member: String,
303
304 #[arg(long)]
306 events_log_path: String,
307
308 #[arg(long)]
310 pty_log_path: String,
311 },
312
313 #[command(hide = true)]
315 Daemon {
316 #[arg(long)]
318 project_root: String,
319 #[arg(long)]
321 resume: bool,
322 },
323}
324
325#[derive(Subcommand, Debug)]
326pub enum TelemetryCommand {
327 Summary,
329 Agents,
331 Tasks,
333 Reviews,
335 Events {
337 #[arg(short = 'n', long = "limit", default_value_t = 50)]
339 limit: usize,
340 },
341}
342
343#[derive(Subcommand, Debug)]
344pub enum GrafanaCommand {
345 Setup,
347 Status,
349 Open,
351}
352
353#[derive(Subcommand, Debug)]
354pub enum InboxCommand {
355 Purge {
357 #[arg(required_unless_present = "all_roles")]
359 role: Option<String>,
360 #[arg(long, default_value_t = false)]
362 all_roles: bool,
363 #[arg(long, conflicts_with_all = ["all", "older_than"])]
365 before: Option<u64>,
366 #[arg(long, conflicts_with_all = ["all", "before"])]
368 older_than: Option<String>,
369 #[arg(long, default_value_t = false, conflicts_with_all = ["before", "older_than"])]
371 all: bool,
372 },
373}
374
375#[derive(Subcommand, Debug)]
376pub enum BoardCommand {
377 List {
379 #[arg(long)]
381 status: Option<String>,
382 },
383 Summary,
385 Deps {
387 #[arg(long, value_enum, default_value_t = DepsFormatArg::Tree)]
389 format: DepsFormatArg,
390 },
391 Archive {
393 #[arg(long, default_value = "0s")]
395 older_than: String,
396
397 #[arg(long)]
399 dry_run: bool,
400 },
401 Health,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
406pub enum DepsFormatArg {
407 Tree,
408 Flat,
409 Dot,
410}
411
412#[derive(Subcommand, Debug)]
413pub enum TaskCommand {
414 Transition {
416 task_id: u32,
418 #[arg(value_enum)]
420 target_state: TaskStateArg,
421 },
422
423 Assign {
425 task_id: u32,
427 #[arg(long = "execution-owner")]
429 execution_owner: Option<String>,
430 #[arg(long = "review-owner")]
432 review_owner: Option<String>,
433 },
434
435 Review {
437 task_id: u32,
439 #[arg(long, value_enum)]
441 disposition: ReviewDispositionArg,
442 #[arg(long)]
444 feedback: Option<String>,
445 },
446
447 Update {
449 task_id: u32,
451 #[arg(long)]
453 branch: Option<String>,
454 #[arg(long)]
456 commit: Option<String>,
457 #[arg(long = "blocked-on")]
459 blocked_on: Option<String>,
460 #[arg(long = "clear-blocked", default_value_t = false)]
462 clear_blocked: bool,
463 },
464
465 #[command(name = "auto-merge")]
467 AutoMerge {
468 task_id: u32,
470 #[arg(value_enum)]
472 action: AutoMergeAction,
473 },
474
475 Schedule {
477 task_id: u32,
479 #[arg(long = "at")]
481 at: Option<String>,
482 #[arg(long = "cron")]
484 cron: Option<String>,
485 #[arg(long, default_value_t = false)]
487 clear: bool,
488 },
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
492pub enum InitTemplate {
493 Solo,
495 Pair,
497 Simple,
499 Squad,
501 Large,
503 Research,
505 Software,
507 Batty,
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
512pub enum CompletionShell {
513 Bash,
514 Zsh,
515 Fish,
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
519pub enum TaskStateArg {
520 Backlog,
521 Todo,
522 #[value(name = "in-progress")]
523 InProgress,
524 Review,
525 Blocked,
526 Done,
527 Archived,
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
531pub enum ReviewDispositionArg {
532 Approved,
533 #[value(name = "changes_requested")]
534 ChangesRequested,
535 Rejected,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
539pub enum ReviewAction {
540 Approve,
541 #[value(name = "request-changes")]
542 RequestChanges,
543 Reject,
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
547pub enum AutoMergeAction {
548 Enable,
549 Disable,
550}
551
552#[derive(Subcommand, Debug)]
553pub enum ScaleCommand {
554 Engineers {
556 count: u32,
558 },
559 AddManager {
561 name: String,
563 },
564 RemoveManager {
566 name: String,
568 },
569 Status,
571}
572
573#[derive(Subcommand, Debug)]
574pub enum NudgeCommand {
575 Disable {
577 #[arg(value_enum)]
579 name: NudgeIntervention,
580 },
581 Enable {
583 #[arg(value_enum)]
585 name: NudgeIntervention,
586 },
587 Status,
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
592pub enum NudgeIntervention {
593 Replenish,
594 Triage,
595 Review,
596 Dispatch,
597 Utilization,
598 #[value(name = "owned-task")]
599 OwnedTask,
600}
601
602impl NudgeIntervention {
603 #[allow(dead_code)]
605 pub fn marker_name(self) -> &'static str {
606 match self {
607 Self::Replenish => "replenish",
608 Self::Triage => "triage",
609 Self::Review => "review",
610 Self::Dispatch => "dispatch",
611 Self::Utilization => "utilization",
612 Self::OwnedTask => "owned-task",
613 }
614 }
615
616 #[allow(dead_code)]
618 pub const ALL: [NudgeIntervention; 6] = [
619 Self::Replenish,
620 Self::Triage,
621 Self::Review,
622 Self::Dispatch,
623 Self::Utilization,
624 Self::OwnedTask,
625 ];
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn board_command_defaults_to_tui() {
634 let cli = Cli::parse_from(["batty", "board"]);
635 match cli.command {
636 Command::Board { command } => assert!(command.is_none()),
637 other => panic!("expected board command, got {other:?}"),
638 }
639 }
640
641 #[test]
642 fn board_list_subcommand_parses() {
643 let cli = Cli::parse_from(["batty", "board", "list"]);
644 match cli.command {
645 Command::Board {
646 command: Some(BoardCommand::List { status }),
647 } => assert_eq!(status, None),
648 other => panic!("expected board list command, got {other:?}"),
649 }
650 }
651
652 #[test]
653 fn board_list_subcommand_parses_status_filter() {
654 let cli = Cli::parse_from(["batty", "board", "list", "--status", "review"]);
655 match cli.command {
656 Command::Board {
657 command: Some(BoardCommand::List { status }),
658 } => assert_eq!(status.as_deref(), Some("review")),
659 other => panic!("expected board list command, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn board_summary_subcommand_parses() {
665 let cli = Cli::parse_from(["batty", "board", "summary"]);
666 match cli.command {
667 Command::Board {
668 command: Some(BoardCommand::Summary),
669 } => {}
670 other => panic!("expected board summary command, got {other:?}"),
671 }
672 }
673
674 #[test]
675 fn board_deps_subcommand_defaults_to_tree() {
676 let cli = Cli::parse_from(["batty", "board", "deps"]);
677 match cli.command {
678 Command::Board {
679 command: Some(BoardCommand::Deps { format }),
680 } => assert_eq!(format, DepsFormatArg::Tree),
681 other => panic!("expected board deps command, got {other:?}"),
682 }
683 }
684
685 #[test]
686 fn board_deps_subcommand_parses_format_flag() {
687 for (arg, expected) in [
688 ("tree", DepsFormatArg::Tree),
689 ("flat", DepsFormatArg::Flat),
690 ("dot", DepsFormatArg::Dot),
691 ] {
692 let cli = Cli::parse_from(["batty", "board", "deps", "--format", arg]);
693 match cli.command {
694 Command::Board {
695 command: Some(BoardCommand::Deps { format }),
696 } => assert_eq!(format, expected, "format arg={arg}"),
697 other => panic!("expected board deps command for {arg}, got {other:?}"),
698 }
699 }
700 }
701
702 #[test]
703 fn board_archive_subcommand_parses() {
704 let cli = Cli::parse_from(["batty", "board", "archive"]);
705 match cli.command {
706 Command::Board {
707 command:
708 Some(BoardCommand::Archive {
709 older_than,
710 dry_run,
711 }),
712 } => {
713 assert_eq!(older_than, "0s");
714 assert!(!dry_run);
715 }
716 other => panic!("expected board archive command, got {other:?}"),
717 }
718 }
719
720 #[test]
721 fn board_archive_subcommand_parses_older_than() {
722 let cli = Cli::parse_from(["batty", "board", "archive", "--older-than", "7d"]);
723 match cli.command {
724 Command::Board {
725 command:
726 Some(BoardCommand::Archive {
727 older_than,
728 dry_run,
729 }),
730 } => {
731 assert_eq!(older_than, "7d");
732 assert!(!dry_run);
733 }
734 other => panic!("expected board archive command with older_than, got {other:?}"),
735 }
736 }
737
738 #[test]
739 fn board_archive_subcommand_parses_dry_run() {
740 let cli = Cli::parse_from(["batty", "board", "archive", "--dry-run"]);
741 match cli.command {
742 Command::Board {
743 command:
744 Some(BoardCommand::Archive {
745 older_than,
746 dry_run,
747 }),
748 } => {
749 assert_eq!(older_than, "0s");
750 assert!(dry_run);
751 }
752 other => panic!("expected board archive command with dry_run, got {other:?}"),
753 }
754 }
755
756 #[test]
757 fn board_health_subcommand_parses() {
758 let cli = Cli::parse_from(["batty", "board", "health"]);
759 match cli.command {
760 Command::Board {
761 command: Some(BoardCommand::Health),
762 } => {}
763 other => panic!("expected board health command, got {other:?}"),
764 }
765 }
766
767 #[test]
768 fn init_subcommand_defaults_to_simple() {
769 let cli = Cli::parse_from(["batty", "init"]);
770 match cli.command {
771 Command::Init {
772 template,
773 from,
774 agent,
775 ..
776 } => {
777 assert_eq!(template, None);
778 assert_eq!(from, None);
779 assert_eq!(agent, None);
780 }
781 other => panic!("expected init command, got {other:?}"),
782 }
783 }
784
785 #[test]
786 fn init_subcommand_accepts_large_template() {
787 let cli = Cli::parse_from(["batty", "init", "--template", "large"]);
788 match cli.command {
789 Command::Init { template, from, .. } => {
790 assert_eq!(template, Some(InitTemplate::Large));
791 assert_eq!(from, None);
792 }
793 other => panic!("expected init command, got {other:?}"),
794 }
795 }
796
797 #[test]
798 fn init_subcommand_accepts_from_template_name() {
799 let cli = Cli::parse_from(["batty", "init", "--from", "custom-team"]);
800 match cli.command {
801 Command::Init { template, from, .. } => {
802 assert_eq!(template, None);
803 assert_eq!(from.as_deref(), Some("custom-team"));
804 }
805 other => panic!("expected init command, got {other:?}"),
806 }
807 }
808
809 #[test]
810 fn init_subcommand_rejects_from_with_template() {
811 let result = Cli::try_parse_from(["batty", "init", "--template", "large", "--from", "x"]);
812 assert!(result.is_err());
813 }
814
815 #[test]
816 fn init_agent_flag_parses() {
817 let cli = Cli::parse_from(["batty", "init", "--agent", "codex"]);
818 match cli.command {
819 Command::Init { agent, .. } => {
820 assert_eq!(agent.as_deref(), Some("codex"));
821 }
822 other => panic!("expected init command, got {other:?}"),
823 }
824 }
825
826 #[test]
827 fn install_alias_maps_to_init() {
828 let cli = Cli::parse_from(["batty", "install"]);
829 match cli.command {
830 Command::Init {
831 template,
832 from,
833 agent,
834 ..
835 } => {
836 assert_eq!(template, None);
837 assert_eq!(from, None);
838 assert_eq!(agent, None);
839 }
840 other => panic!("expected init command via install alias, got {other:?}"),
841 }
842 }
843
844 #[test]
845 fn install_alias_with_agent_flag() {
846 let cli = Cli::parse_from(["batty", "install", "--agent", "kiro"]);
847 match cli.command {
848 Command::Init { agent, .. } => {
849 assert_eq!(agent.as_deref(), Some("kiro"));
850 }
851 other => panic!("expected init command via install alias, got {other:?}"),
852 }
853 }
854
855 #[test]
856 fn export_template_subcommand_parses() {
857 let cli = Cli::parse_from(["batty", "export-template", "myteam"]);
858 match cli.command {
859 Command::ExportTemplate { name } => assert_eq!(name, "myteam"),
860 other => panic!("expected export-template command, got {other:?}"),
861 }
862 }
863
864 #[test]
865 fn export_run_subcommand_parses() {
866 let cli = Cli::parse_from(["batty", "export-run"]);
867 match cli.command {
868 Command::ExportRun => {}
869 other => panic!("expected export-run command, got {other:?}"),
870 }
871 }
872
873 #[test]
874 fn retro_subcommand_parses() {
875 let cli = Cli::parse_from(["batty", "retro"]);
876 match cli.command {
877 Command::Retro { events } => assert!(events.is_none()),
878 other => panic!("expected retro command, got {other:?}"),
879 }
880 }
881
882 #[test]
883 fn retro_subcommand_parses_with_events_path() {
884 let cli = Cli::parse_from(["batty", "retro", "--events", "/tmp/events.jsonl"]);
885 match cli.command {
886 Command::Retro { events } => {
887 assert_eq!(events, Some(PathBuf::from("/tmp/events.jsonl")));
888 }
889 other => panic!("expected retro command, got {other:?}"),
890 }
891 }
892
893 #[test]
894 fn start_subcommand_defaults() {
895 let cli = Cli::parse_from(["batty", "start"]);
896 match cli.command {
897 Command::Start { attach } => assert!(!attach),
898 other => panic!("expected start command, got {other:?}"),
899 }
900 }
901
902 #[test]
903 fn start_subcommand_with_attach() {
904 let cli = Cli::parse_from(["batty", "start", "--attach"]);
905 match cli.command {
906 Command::Start { attach } => assert!(attach),
907 other => panic!("expected start command, got {other:?}"),
908 }
909 }
910
911 #[test]
912 fn stop_subcommand_parses() {
913 let cli = Cli::parse_from(["batty", "stop"]);
914 assert!(matches!(cli.command, Command::Stop));
915 }
916
917 #[test]
918 fn attach_subcommand_parses() {
919 let cli = Cli::parse_from(["batty", "attach"]);
920 assert!(matches!(cli.command, Command::Attach));
921 }
922
923 #[test]
924 fn status_subcommand_defaults() {
925 let cli = Cli::parse_from(["batty", "status"]);
926 match cli.command {
927 Command::Status { json } => assert!(!json),
928 other => panic!("expected status command, got {other:?}"),
929 }
930 }
931
932 #[test]
933 fn status_subcommand_json_flag() {
934 let cli = Cli::parse_from(["batty", "status", "--json"]);
935 match cli.command {
936 Command::Status { json } => assert!(json),
937 other => panic!("expected status command, got {other:?}"),
938 }
939 }
940
941 #[test]
942 fn send_subcommand_parses_role_and_message() {
943 let cli = Cli::parse_from(["batty", "send", "architect", "hello world"]);
944 match cli.command {
945 Command::Send {
946 from,
947 role,
948 message,
949 } => {
950 assert!(from.is_none());
951 assert_eq!(role, "architect");
952 assert_eq!(message, "hello world");
953 }
954 other => panic!("expected send command, got {other:?}"),
955 }
956 }
957
958 #[test]
959 fn assign_subcommand_parses_engineer_and_task() {
960 let cli = Cli::parse_from(["batty", "assign", "eng-1-1", "fix auth bug"]);
961 match cli.command {
962 Command::Assign { engineer, task } => {
963 assert_eq!(engineer, "eng-1-1");
964 assert_eq!(task, "fix auth bug");
965 }
966 other => panic!("expected assign command, got {other:?}"),
967 }
968 }
969
970 #[test]
971 fn validate_subcommand_parses() {
972 let cli = Cli::parse_from(["batty", "validate"]);
973 match cli.command {
974 Command::Validate { show_checks } => assert!(!show_checks),
975 other => panic!("expected validate command, got {other:?}"),
976 }
977 }
978
979 #[test]
980 fn validate_subcommand_show_checks_flag() {
981 let cli = Cli::parse_from(["batty", "validate", "--show-checks"]);
982 match cli.command {
983 Command::Validate { show_checks } => assert!(show_checks),
984 other => panic!("expected validate command with show_checks, got {other:?}"),
985 }
986 }
987
988 #[test]
989 fn config_subcommand_json_flag() {
990 let cli = Cli::parse_from(["batty", "config", "--json"]);
991 match cli.command {
992 Command::Config { json } => assert!(json),
993 other => panic!("expected config command, got {other:?}"),
994 }
995 }
996
997 #[test]
998 fn merge_subcommand_parses_engineer() {
999 let cli = Cli::parse_from(["batty", "merge", "eng-1-1"]);
1000 match cli.command {
1001 Command::Merge { engineer } => assert_eq!(engineer, "eng-1-1"),
1002 other => panic!("expected merge command, got {other:?}"),
1003 }
1004 }
1005
1006 #[test]
1007 fn completions_subcommand_parses_shell() {
1008 let cli = Cli::parse_from(["batty", "completions", "zsh"]);
1009 match cli.command {
1010 Command::Completions { shell } => assert_eq!(shell, CompletionShell::Zsh),
1011 other => panic!("expected completions command, got {other:?}"),
1012 }
1013 }
1014
1015 #[test]
1016 fn inbox_subcommand_parses_defaults() {
1017 let cli = Cli::parse_from(["batty", "inbox", "architect"]);
1018 match cli.command {
1019 Command::Inbox {
1020 command,
1021 member,
1022 limit,
1023 all,
1024 } => {
1025 assert!(command.is_none());
1026 assert_eq!(member.as_deref(), Some("architect"));
1027 assert_eq!(limit, 20);
1028 assert!(!all);
1029 }
1030 other => panic!("expected inbox command, got {other:?}"),
1031 }
1032 }
1033
1034 #[test]
1035 fn inbox_subcommand_parses_limit_flag() {
1036 let cli = Cli::parse_from(["batty", "inbox", "architect", "-n", "50"]);
1037 match cli.command {
1038 Command::Inbox {
1039 command,
1040 member,
1041 limit,
1042 all,
1043 } => {
1044 assert!(command.is_none());
1045 assert_eq!(member.as_deref(), Some("architect"));
1046 assert_eq!(limit, 50);
1047 assert!(!all);
1048 }
1049 other => panic!("expected inbox command, got {other:?}"),
1050 }
1051 }
1052
1053 #[test]
1054 fn inbox_subcommand_parses_all_flag() {
1055 let cli = Cli::parse_from(["batty", "inbox", "architect", "--all"]);
1056 match cli.command {
1057 Command::Inbox {
1058 command,
1059 member,
1060 limit,
1061 all,
1062 } => {
1063 assert!(command.is_none());
1064 assert_eq!(member.as_deref(), Some("architect"));
1065 assert_eq!(limit, 20);
1066 assert!(all);
1067 }
1068 other => panic!("expected inbox command, got {other:?}"),
1069 }
1070 }
1071
1072 #[test]
1073 fn inbox_purge_subcommand_parses_role_and_before() {
1074 let cli = Cli::parse_from(["batty", "inbox", "purge", "architect", "--before", "123"]);
1075 match cli.command {
1076 Command::Inbox {
1077 command:
1078 Some(InboxCommand::Purge {
1079 role,
1080 all_roles,
1081 before,
1082 older_than,
1083 all,
1084 }),
1085 member,
1086 ..
1087 } => {
1088 assert!(member.is_none());
1089 assert_eq!(role.as_deref(), Some("architect"));
1090 assert!(!all_roles);
1091 assert_eq!(before, Some(123));
1092 assert!(older_than.is_none());
1093 assert!(!all);
1094 }
1095 other => panic!("expected inbox purge command, got {other:?}"),
1096 }
1097 }
1098
1099 #[test]
1100 fn inbox_purge_subcommand_parses_all_roles_and_all() {
1101 let cli = Cli::parse_from(["batty", "inbox", "purge", "--all-roles", "--all"]);
1102 match cli.command {
1103 Command::Inbox {
1104 command:
1105 Some(InboxCommand::Purge {
1106 role,
1107 all_roles,
1108 before,
1109 older_than,
1110 all,
1111 }),
1112 member,
1113 ..
1114 } => {
1115 assert!(member.is_none());
1116 assert!(role.is_none());
1117 assert!(all_roles);
1118 assert_eq!(before, None);
1119 assert!(older_than.is_none());
1120 assert!(all);
1121 }
1122 other => panic!("expected inbox purge command, got {other:?}"),
1123 }
1124 }
1125
1126 #[test]
1127 fn inbox_purge_subcommand_parses_older_than() {
1128 let cli = Cli::parse_from(["batty", "inbox", "purge", "eng-1", "--older-than", "24h"]);
1129 match cli.command {
1130 Command::Inbox {
1131 command:
1132 Some(InboxCommand::Purge {
1133 role,
1134 all_roles,
1135 before,
1136 older_than,
1137 all,
1138 }),
1139 ..
1140 } => {
1141 assert_eq!(role.as_deref(), Some("eng-1"));
1142 assert!(!all_roles);
1143 assert_eq!(before, None);
1144 assert_eq!(older_than.as_deref(), Some("24h"));
1145 assert!(!all);
1146 }
1147 other => panic!("expected inbox purge command, got {other:?}"),
1148 }
1149 }
1150
1151 #[test]
1152 fn inbox_purge_rejects_older_than_with_before() {
1153 let result = Cli::try_parse_from([
1154 "batty",
1155 "inbox",
1156 "purge",
1157 "eng-1",
1158 "--older-than",
1159 "24h",
1160 "--before",
1161 "100",
1162 ]);
1163 assert!(result.is_err());
1164 }
1165
1166 #[test]
1167 fn inbox_purge_rejects_older_than_with_all() {
1168 let result = Cli::try_parse_from([
1169 "batty",
1170 "inbox",
1171 "purge",
1172 "eng-1",
1173 "--older-than",
1174 "24h",
1175 "--all",
1176 ]);
1177 assert!(result.is_err());
1178 }
1179
1180 #[test]
1181 fn read_subcommand_parses_member_and_id() {
1182 let cli = Cli::parse_from(["batty", "read", "architect", "abc123"]);
1183 match cli.command {
1184 Command::Read { member, id } => {
1185 assert_eq!(member, "architect");
1186 assert_eq!(id, "abc123");
1187 }
1188 other => panic!("expected read command, got {other:?}"),
1189 }
1190 }
1191
1192 #[test]
1193 fn ack_subcommand_parses_member_and_id() {
1194 let cli = Cli::parse_from(["batty", "ack", "eng-1-1", "abc123"]);
1195 match cli.command {
1196 Command::Ack { member, id } => {
1197 assert_eq!(member, "eng-1-1");
1198 assert_eq!(id, "abc123");
1199 }
1200 other => panic!("expected ack command, got {other:?}"),
1201 }
1202 }
1203
1204 #[test]
1205 fn pause_subcommand_parses() {
1206 let cli = Cli::parse_from(["batty", "pause"]);
1207 assert!(matches!(cli.command, Command::Pause));
1208 }
1209
1210 #[test]
1211 fn resume_subcommand_parses() {
1212 let cli = Cli::parse_from(["batty", "resume"]);
1213 assert!(matches!(cli.command, Command::Resume));
1214 }
1215
1216 #[test]
1217 fn telegram_subcommand_parses() {
1218 let cli = Cli::parse_from(["batty", "telegram"]);
1219 assert!(matches!(cli.command, Command::Telegram));
1220 }
1221
1222 #[test]
1223 fn doctor_subcommand_parses() {
1224 let cli = Cli::parse_from(["batty", "doctor"]);
1225 assert!(matches!(
1226 cli.command,
1227 Command::Doctor {
1228 fix: false,
1229 yes: false
1230 }
1231 ));
1232 }
1233
1234 #[test]
1235 fn doctor_subcommand_parses_fix_flag() {
1236 let cli = Cli::parse_from(["batty", "doctor", "--fix"]);
1237 assert!(matches!(
1238 cli.command,
1239 Command::Doctor {
1240 fix: true,
1241 yes: false
1242 }
1243 ));
1244 }
1245
1246 #[test]
1247 fn doctor_subcommand_parses_fix_yes_flags() {
1248 let cli = Cli::parse_from(["batty", "doctor", "--fix", "--yes"]);
1249 assert!(matches!(
1250 cli.command,
1251 Command::Doctor {
1252 fix: true,
1253 yes: true
1254 }
1255 ));
1256 }
1257
1258 #[test]
1259 fn load_subcommand_parses() {
1260 let cli = Cli::parse_from(["batty", "load"]);
1261 assert!(matches!(cli.command, Command::Load));
1262 }
1263
1264 #[test]
1265 fn queue_subcommand_parses() {
1266 let cli = Cli::parse_from(["batty", "queue"]);
1267 assert!(matches!(cli.command, Command::Queue));
1268 }
1269
1270 #[test]
1271 fn cost_subcommand_parses() {
1272 let cli = Cli::parse_from(["batty", "cost"]);
1273 assert!(matches!(cli.command, Command::Cost));
1274 }
1275
1276 #[test]
1277 fn verbose_flag_is_global() {
1278 let cli = Cli::parse_from(["batty", "-vv", "status"]);
1279 assert_eq!(cli.verbose, 2);
1280 }
1281
1282 #[test]
1283 fn task_transition_subcommand_parses() {
1284 let cli = Cli::parse_from(["batty", "task", "transition", "24", "in-progress"]);
1285 match cli.command {
1286 Command::Task {
1287 command:
1288 TaskCommand::Transition {
1289 task_id,
1290 target_state,
1291 },
1292 } => {
1293 assert_eq!(task_id, 24);
1294 assert_eq!(target_state, TaskStateArg::InProgress);
1295 }
1296 other => panic!("expected task transition command, got {other:?}"),
1297 }
1298 }
1299
1300 #[test]
1301 fn task_assign_subcommand_parses() {
1302 let cli = Cli::parse_from([
1303 "batty",
1304 "task",
1305 "assign",
1306 "24",
1307 "--execution-owner",
1308 "eng-1-2",
1309 "--review-owner",
1310 "manager-1",
1311 ]);
1312 match cli.command {
1313 Command::Task {
1314 command:
1315 TaskCommand::Assign {
1316 task_id,
1317 execution_owner,
1318 review_owner,
1319 },
1320 } => {
1321 assert_eq!(task_id, 24);
1322 assert_eq!(execution_owner.as_deref(), Some("eng-1-2"));
1323 assert_eq!(review_owner.as_deref(), Some("manager-1"));
1324 }
1325 other => panic!("expected task assign command, got {other:?}"),
1326 }
1327 }
1328
1329 #[test]
1330 fn task_review_subcommand_parses() {
1331 let cli = Cli::parse_from([
1332 "batty",
1333 "task",
1334 "review",
1335 "24",
1336 "--disposition",
1337 "changes_requested",
1338 ]);
1339 match cli.command {
1340 Command::Task {
1341 command:
1342 TaskCommand::Review {
1343 task_id,
1344 disposition,
1345 feedback,
1346 },
1347 } => {
1348 assert_eq!(task_id, 24);
1349 assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
1350 assert!(feedback.is_none());
1351 }
1352 other => panic!("expected task review command, got {other:?}"),
1353 }
1354 }
1355
1356 #[test]
1357 fn task_update_subcommand_parses() {
1358 let cli = Cli::parse_from([
1359 "batty",
1360 "task",
1361 "update",
1362 "24",
1363 "--branch",
1364 "eng-1-2/task-24",
1365 "--commit",
1366 "abc1234",
1367 "--blocked-on",
1368 "waiting for review",
1369 "--clear-blocked",
1370 ]);
1371 match cli.command {
1372 Command::Task {
1373 command:
1374 TaskCommand::Update {
1375 task_id,
1376 branch,
1377 commit,
1378 blocked_on,
1379 clear_blocked,
1380 },
1381 } => {
1382 assert_eq!(task_id, 24);
1383 assert_eq!(branch.as_deref(), Some("eng-1-2/task-24"));
1384 assert_eq!(commit.as_deref(), Some("abc1234"));
1385 assert_eq!(blocked_on.as_deref(), Some("waiting for review"));
1386 assert!(clear_blocked);
1387 }
1388 other => panic!("expected task update command, got {other:?}"),
1389 }
1390 }
1391
1392 #[test]
1393 fn nudge_disable_parses() {
1394 let cli = Cli::parse_from(["batty", "nudge", "disable", "triage"]);
1395 match cli.command {
1396 Command::Nudge {
1397 command: NudgeCommand::Disable { name },
1398 } => assert_eq!(name, NudgeIntervention::Triage),
1399 other => panic!("expected nudge disable, got {other:?}"),
1400 }
1401 }
1402
1403 #[test]
1404 fn nudge_enable_parses() {
1405 let cli = Cli::parse_from(["batty", "nudge", "enable", "replenish"]);
1406 match cli.command {
1407 Command::Nudge {
1408 command: NudgeCommand::Enable { name },
1409 } => assert_eq!(name, NudgeIntervention::Replenish),
1410 other => panic!("expected nudge enable, got {other:?}"),
1411 }
1412 }
1413
1414 #[test]
1415 fn nudge_status_parses() {
1416 let cli = Cli::parse_from(["batty", "nudge", "status"]);
1417 match cli.command {
1418 Command::Nudge {
1419 command: NudgeCommand::Status,
1420 } => {}
1421 other => panic!("expected nudge status, got {other:?}"),
1422 }
1423 }
1424
1425 #[test]
1426 fn nudge_disable_owned_task_parses() {
1427 let cli = Cli::parse_from(["batty", "nudge", "disable", "owned-task"]);
1428 match cli.command {
1429 Command::Nudge {
1430 command: NudgeCommand::Disable { name },
1431 } => assert_eq!(name, NudgeIntervention::OwnedTask),
1432 other => panic!("expected nudge disable owned-task, got {other:?}"),
1433 }
1434 }
1435
1436 #[test]
1437 fn nudge_rejects_unknown_intervention() {
1438 let result = Cli::try_parse_from(["batty", "nudge", "disable", "unknown"]);
1439 assert!(result.is_err());
1440 }
1441
1442 #[test]
1443 fn nudge_intervention_marker_names() {
1444 assert_eq!(NudgeIntervention::Replenish.marker_name(), "replenish");
1445 assert_eq!(NudgeIntervention::Triage.marker_name(), "triage");
1446 assert_eq!(NudgeIntervention::Review.marker_name(), "review");
1447 assert_eq!(NudgeIntervention::Dispatch.marker_name(), "dispatch");
1448 assert_eq!(NudgeIntervention::Utilization.marker_name(), "utilization");
1449 assert_eq!(NudgeIntervention::OwnedTask.marker_name(), "owned-task");
1450 }
1451
1452 #[test]
1453 fn parse_task_schedule_at() {
1454 let cli = Cli::parse_from([
1455 "batty",
1456 "task",
1457 "schedule",
1458 "50",
1459 "--at",
1460 "2026-03-25T09:00:00-04:00",
1461 ]);
1462 match cli.command {
1463 Command::Task {
1464 command:
1465 TaskCommand::Schedule {
1466 task_id,
1467 at,
1468 cron,
1469 clear,
1470 },
1471 } => {
1472 assert_eq!(task_id, 50);
1473 assert_eq!(at.as_deref(), Some("2026-03-25T09:00:00-04:00"));
1474 assert!(cron.is_none());
1475 assert!(!clear);
1476 }
1477 other => panic!("expected task schedule command, got {other:?}"),
1478 }
1479 }
1480
1481 #[test]
1482 fn parse_task_schedule_cron() {
1483 let cli = Cli::parse_from(["batty", "task", "schedule", "51", "--cron", "0 9 * * *"]);
1484 match cli.command {
1485 Command::Task {
1486 command:
1487 TaskCommand::Schedule {
1488 task_id,
1489 at,
1490 cron,
1491 clear,
1492 },
1493 } => {
1494 assert_eq!(task_id, 51);
1495 assert!(at.is_none());
1496 assert_eq!(cron.as_deref(), Some("0 9 * * *"));
1497 assert!(!clear);
1498 }
1499 other => panic!("expected task schedule command, got {other:?}"),
1500 }
1501 }
1502
1503 #[test]
1504 fn parse_task_schedule_clear() {
1505 let cli = Cli::parse_from(["batty", "task", "schedule", "52", "--clear"]);
1506 match cli.command {
1507 Command::Task {
1508 command:
1509 TaskCommand::Schedule {
1510 task_id,
1511 at,
1512 cron,
1513 clear,
1514 },
1515 } => {
1516 assert_eq!(task_id, 52);
1517 assert!(at.is_none());
1518 assert!(cron.is_none());
1519 assert!(clear);
1520 }
1521 other => panic!("expected task schedule command, got {other:?}"),
1522 }
1523 }
1524
1525 #[test]
1526 fn parse_task_schedule_both() {
1527 let cli = Cli::parse_from([
1528 "batty",
1529 "task",
1530 "schedule",
1531 "53",
1532 "--at",
1533 "2026-04-01T00:00:00Z",
1534 "--cron",
1535 "0 9 * * 1",
1536 ]);
1537 match cli.command {
1538 Command::Task {
1539 command:
1540 TaskCommand::Schedule {
1541 task_id,
1542 at,
1543 cron,
1544 clear,
1545 },
1546 } => {
1547 assert_eq!(task_id, 53);
1548 assert_eq!(at.as_deref(), Some("2026-04-01T00:00:00Z"));
1549 assert_eq!(cron.as_deref(), Some("0 9 * * 1"));
1550 assert!(!clear);
1551 }
1552 other => panic!("expected task schedule command, got {other:?}"),
1553 }
1554 }
1555
1556 #[test]
1557 fn review_approve_parses() {
1558 let cli = Cli::parse_from(["batty", "review", "42", "approve"]);
1559 match cli.command {
1560 Command::Review {
1561 task_id,
1562 disposition,
1563 feedback,
1564 reviewer,
1565 } => {
1566 assert_eq!(task_id, 42);
1567 assert_eq!(disposition, ReviewAction::Approve);
1568 assert!(feedback.is_none());
1569 assert_eq!(reviewer, "human");
1570 }
1571 other => panic!("expected review command, got {other:?}"),
1572 }
1573 }
1574
1575 #[test]
1576 fn review_request_changes_with_feedback_parses() {
1577 let cli = Cli::parse_from([
1578 "batty",
1579 "review",
1580 "99",
1581 "request-changes",
1582 "fix the error handling",
1583 ]);
1584 match cli.command {
1585 Command::Review {
1586 task_id,
1587 disposition,
1588 feedback,
1589 reviewer,
1590 } => {
1591 assert_eq!(task_id, 99);
1592 assert_eq!(disposition, ReviewAction::RequestChanges);
1593 assert_eq!(feedback.as_deref(), Some("fix the error handling"));
1594 assert_eq!(reviewer, "human");
1595 }
1596 other => panic!("expected review command, got {other:?}"),
1597 }
1598 }
1599
1600 #[test]
1601 fn review_reject_with_reviewer_flag_parses() {
1602 let cli = Cli::parse_from([
1603 "batty",
1604 "review",
1605 "7",
1606 "reject",
1607 "does not meet requirements",
1608 "--reviewer",
1609 "manager-1",
1610 ]);
1611 match cli.command {
1612 Command::Review {
1613 task_id,
1614 disposition,
1615 feedback,
1616 reviewer,
1617 } => {
1618 assert_eq!(task_id, 7);
1619 assert_eq!(disposition, ReviewAction::Reject);
1620 assert_eq!(feedback.as_deref(), Some("does not meet requirements"));
1621 assert_eq!(reviewer, "manager-1");
1622 }
1623 other => panic!("expected review command, got {other:?}"),
1624 }
1625 }
1626
1627 #[test]
1628 fn review_rejects_invalid_disposition() {
1629 let result = Cli::try_parse_from(["batty", "review", "42", "maybe"]);
1630 assert!(result.is_err());
1631 }
1632
1633 #[test]
1636 fn send_rejects_missing_role() {
1637 let result = Cli::try_parse_from(["batty", "send"]);
1638 assert!(result.is_err());
1639 }
1640
1641 #[test]
1642 fn send_rejects_missing_message() {
1643 let result = Cli::try_parse_from(["batty", "send", "architect"]);
1644 assert!(result.is_err());
1645 }
1646
1647 #[test]
1650 fn assign_rejects_missing_engineer() {
1651 let result = Cli::try_parse_from(["batty", "assign"]);
1652 assert!(result.is_err());
1653 }
1654
1655 #[test]
1656 fn assign_rejects_missing_task() {
1657 let result = Cli::try_parse_from(["batty", "assign", "eng-1-1"]);
1658 assert!(result.is_err());
1659 }
1660
1661 #[test]
1664 fn review_rejects_missing_task_id() {
1665 let result = Cli::try_parse_from(["batty", "review"]);
1666 assert!(result.is_err());
1667 }
1668
1669 #[test]
1670 fn review_rejects_missing_disposition() {
1671 let result = Cli::try_parse_from(["batty", "review", "42"]);
1672 assert!(result.is_err());
1673 }
1674
1675 #[test]
1678 fn merge_rejects_missing_engineer() {
1679 let result = Cli::try_parse_from(["batty", "merge"]);
1680 assert!(result.is_err());
1681 }
1682
1683 #[test]
1686 fn read_rejects_missing_member() {
1687 let result = Cli::try_parse_from(["batty", "read"]);
1688 assert!(result.is_err());
1689 }
1690
1691 #[test]
1692 fn read_rejects_missing_id() {
1693 let result = Cli::try_parse_from(["batty", "read", "architect"]);
1694 assert!(result.is_err());
1695 }
1696
1697 #[test]
1698 fn ack_rejects_missing_args() {
1699 let result = Cli::try_parse_from(["batty", "ack"]);
1700 assert!(result.is_err());
1701 }
1702
1703 #[test]
1706 fn telemetry_summary_parses() {
1707 let cli = Cli::parse_from(["batty", "telemetry", "summary"]);
1708 match cli.command {
1709 Command::Telemetry {
1710 command: TelemetryCommand::Summary,
1711 } => {}
1712 other => panic!("expected telemetry summary, got {other:?}"),
1713 }
1714 }
1715
1716 #[test]
1717 fn telemetry_agents_parses() {
1718 let cli = Cli::parse_from(["batty", "telemetry", "agents"]);
1719 match cli.command {
1720 Command::Telemetry {
1721 command: TelemetryCommand::Agents,
1722 } => {}
1723 other => panic!("expected telemetry agents, got {other:?}"),
1724 }
1725 }
1726
1727 #[test]
1728 fn telemetry_tasks_parses() {
1729 let cli = Cli::parse_from(["batty", "telemetry", "tasks"]);
1730 match cli.command {
1731 Command::Telemetry {
1732 command: TelemetryCommand::Tasks,
1733 } => {}
1734 other => panic!("expected telemetry tasks, got {other:?}"),
1735 }
1736 }
1737
1738 #[test]
1739 fn telemetry_reviews_parses() {
1740 let cli = Cli::parse_from(["batty", "telemetry", "reviews"]);
1741 match cli.command {
1742 Command::Telemetry {
1743 command: TelemetryCommand::Reviews,
1744 } => {}
1745 other => panic!("expected telemetry reviews, got {other:?}"),
1746 }
1747 }
1748
1749 #[test]
1750 fn telemetry_events_default_limit() {
1751 let cli = Cli::parse_from(["batty", "telemetry", "events"]);
1752 match cli.command {
1753 Command::Telemetry {
1754 command: TelemetryCommand::Events { limit },
1755 } => assert_eq!(limit, 50),
1756 other => panic!("expected telemetry events, got {other:?}"),
1757 }
1758 }
1759
1760 #[test]
1761 fn telemetry_events_custom_limit() {
1762 let cli = Cli::parse_from(["batty", "telemetry", "events", "-n", "10"]);
1763 match cli.command {
1764 Command::Telemetry {
1765 command: TelemetryCommand::Events { limit },
1766 } => assert_eq!(limit, 10),
1767 other => panic!("expected telemetry events with limit, got {other:?}"),
1768 }
1769 }
1770
1771 #[test]
1772 fn telemetry_rejects_missing_subcommand() {
1773 let result = Cli::try_parse_from(["batty", "telemetry"]);
1774 assert!(result.is_err());
1775 }
1776
1777 #[test]
1780 fn grafana_setup_parses() {
1781 let cli = Cli::parse_from(["batty", "grafana", "setup"]);
1782 assert!(matches!(
1783 cli.command,
1784 Command::Grafana {
1785 command: GrafanaCommand::Setup
1786 }
1787 ));
1788 }
1789
1790 #[test]
1791 fn grafana_status_parses() {
1792 let cli = Cli::parse_from(["batty", "grafana", "status"]);
1793 assert!(matches!(
1794 cli.command,
1795 Command::Grafana {
1796 command: GrafanaCommand::Status
1797 }
1798 ));
1799 }
1800
1801 #[test]
1802 fn grafana_open_parses() {
1803 let cli = Cli::parse_from(["batty", "grafana", "open"]);
1804 assert!(matches!(
1805 cli.command,
1806 Command::Grafana {
1807 command: GrafanaCommand::Open
1808 }
1809 ));
1810 }
1811
1812 #[test]
1813 fn grafana_rejects_missing_subcommand() {
1814 let result = Cli::try_parse_from(["batty", "grafana"]);
1815 assert!(result.is_err());
1816 }
1817
1818 #[test]
1821 fn task_auto_merge_enable_parses() {
1822 let cli = Cli::parse_from(["batty", "task", "auto-merge", "30", "enable"]);
1823 match cli.command {
1824 Command::Task {
1825 command: TaskCommand::AutoMerge { task_id, action },
1826 } => {
1827 assert_eq!(task_id, 30);
1828 assert_eq!(action, AutoMergeAction::Enable);
1829 }
1830 other => panic!("expected task auto-merge enable, got {other:?}"),
1831 }
1832 }
1833
1834 #[test]
1835 fn task_auto_merge_disable_parses() {
1836 let cli = Cli::parse_from(["batty", "task", "auto-merge", "31", "disable"]);
1837 match cli.command {
1838 Command::Task {
1839 command: TaskCommand::AutoMerge { task_id, action },
1840 } => {
1841 assert_eq!(task_id, 31);
1842 assert_eq!(action, AutoMergeAction::Disable);
1843 }
1844 other => panic!("expected task auto-merge disable, got {other:?}"),
1845 }
1846 }
1847
1848 #[test]
1849 fn task_auto_merge_rejects_invalid_action() {
1850 let result = Cli::try_parse_from(["batty", "task", "auto-merge", "30", "toggle"]);
1851 assert!(result.is_err());
1852 }
1853
1854 #[test]
1857 fn task_assign_execution_owner_only() {
1858 let cli = Cli::parse_from([
1859 "batty",
1860 "task",
1861 "assign",
1862 "10",
1863 "--execution-owner",
1864 "eng-1-3",
1865 ]);
1866 match cli.command {
1867 Command::Task {
1868 command:
1869 TaskCommand::Assign {
1870 task_id,
1871 execution_owner,
1872 review_owner,
1873 },
1874 } => {
1875 assert_eq!(task_id, 10);
1876 assert_eq!(execution_owner.as_deref(), Some("eng-1-3"));
1877 assert!(review_owner.is_none());
1878 }
1879 other => panic!("expected task assign command, got {other:?}"),
1880 }
1881 }
1882
1883 #[test]
1886 fn task_rejects_missing_subcommand() {
1887 let result = Cli::try_parse_from(["batty", "task"]);
1888 assert!(result.is_err());
1889 }
1890
1891 #[test]
1894 fn doctor_rejects_yes_without_fix() {
1895 let result = Cli::try_parse_from(["batty", "doctor", "--yes"]);
1896 assert!(result.is_err());
1897 }
1898
1899 #[test]
1902 fn daemon_subcommand_parses() {
1903 let cli = Cli::parse_from(["batty", "daemon", "--project-root", "/tmp/project"]);
1904 match cli.command {
1905 Command::Daemon {
1906 project_root,
1907 resume,
1908 } => {
1909 assert_eq!(project_root, "/tmp/project");
1910 assert!(!resume);
1911 }
1912 other => panic!("expected daemon command, got {other:?}"),
1913 }
1914 }
1915
1916 #[test]
1917 fn daemon_subcommand_parses_resume_flag() {
1918 let cli = Cli::parse_from([
1919 "batty",
1920 "daemon",
1921 "--project-root",
1922 "/tmp/project",
1923 "--resume",
1924 ]);
1925 match cli.command {
1926 Command::Daemon {
1927 project_root,
1928 resume,
1929 } => {
1930 assert_eq!(project_root, "/tmp/project");
1931 assert!(resume);
1932 }
1933 other => panic!("expected daemon command with resume, got {other:?}"),
1934 }
1935 }
1936
1937 #[test]
1940 fn completions_all_shells_parse() {
1941 for (arg, expected) in [
1942 ("bash", CompletionShell::Bash),
1943 ("zsh", CompletionShell::Zsh),
1944 ("fish", CompletionShell::Fish),
1945 ] {
1946 let cli = Cli::parse_from(["batty", "completions", arg]);
1947 match cli.command {
1948 Command::Completions { shell } => assert_eq!(shell, expected, "shell arg={arg}"),
1949 other => panic!("expected completions command for {arg}, got {other:?}"),
1950 }
1951 }
1952 }
1953
1954 #[test]
1955 fn completions_rejects_unknown_shell() {
1956 let result = Cli::try_parse_from(["batty", "completions", "powershell"]);
1957 assert!(result.is_err());
1958 }
1959
1960 #[test]
1963 fn init_all_template_variants() {
1964 for (arg, expected) in [
1965 ("solo", InitTemplate::Solo),
1966 ("pair", InitTemplate::Pair),
1967 ("simple", InitTemplate::Simple),
1968 ("squad", InitTemplate::Squad),
1969 ("large", InitTemplate::Large),
1970 ("research", InitTemplate::Research),
1971 ("software", InitTemplate::Software),
1972 ("batty", InitTemplate::Batty),
1973 ] {
1974 let cli = Cli::parse_from(["batty", "init", "--template", arg]);
1975 match cli.command {
1976 Command::Init { template, from, .. } => {
1977 assert_eq!(template, Some(expected), "template arg={arg}");
1978 assert!(from.is_none());
1979 }
1980 other => panic!("expected init command for template {arg}, got {other:?}"),
1981 }
1982 }
1983 }
1984
1985 #[test]
1988 fn task_review_with_feedback_parses() {
1989 let cli = Cli::parse_from([
1990 "batty",
1991 "task",
1992 "review",
1993 "15",
1994 "--disposition",
1995 "changes_requested",
1996 "--feedback",
1997 "please fix tests",
1998 ]);
1999 match cli.command {
2000 Command::Task {
2001 command:
2002 TaskCommand::Review {
2003 task_id,
2004 disposition,
2005 feedback,
2006 },
2007 } => {
2008 assert_eq!(task_id, 15);
2009 assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
2010 assert_eq!(feedback.as_deref(), Some("please fix tests"));
2011 }
2012 other => panic!("expected task review command, got {other:?}"),
2013 }
2014 }
2015
2016 #[test]
2019 fn task_transition_all_states() {
2020 for (arg, expected) in [
2021 ("backlog", TaskStateArg::Backlog),
2022 ("todo", TaskStateArg::Todo),
2023 ("in-progress", TaskStateArg::InProgress),
2024 ("review", TaskStateArg::Review),
2025 ("blocked", TaskStateArg::Blocked),
2026 ("done", TaskStateArg::Done),
2027 ("archived", TaskStateArg::Archived),
2028 ] {
2029 let cli = Cli::parse_from(["batty", "task", "transition", "1", arg]);
2030 match cli.command {
2031 Command::Task {
2032 command:
2033 TaskCommand::Transition {
2034 task_id,
2035 target_state,
2036 },
2037 } => {
2038 assert_eq!(task_id, 1);
2039 assert_eq!(target_state, expected, "state arg={arg}");
2040 }
2041 other => panic!("expected task transition for {arg}, got {other:?}"),
2042 }
2043 }
2044 }
2045
2046 #[test]
2047 fn task_transition_rejects_invalid_state() {
2048 let result = Cli::try_parse_from(["batty", "task", "transition", "1", "cancelled"]);
2049 assert!(result.is_err());
2050 }
2051
2052 #[test]
2055 fn rejects_unknown_subcommand() {
2056 let result = Cli::try_parse_from(["batty", "foobar"]);
2057 assert!(result.is_err());
2058 }
2059
2060 #[test]
2063 fn rejects_no_subcommand() {
2064 let result = Cli::try_parse_from(["batty"]);
2065 assert!(result.is_err());
2066 }
2067
2068 #[test]
2071 fn inbox_purge_rejects_missing_role_and_all_roles() {
2072 let result = Cli::try_parse_from(["batty", "inbox", "purge", "--all"]);
2073 assert!(result.is_err());
2074 }
2075
2076 #[test]
2079 fn nudge_enable_all_interventions() {
2080 for (arg, expected) in [
2081 ("replenish", NudgeIntervention::Replenish),
2082 ("triage", NudgeIntervention::Triage),
2083 ("review", NudgeIntervention::Review),
2084 ("dispatch", NudgeIntervention::Dispatch),
2085 ("utilization", NudgeIntervention::Utilization),
2086 ("owned-task", NudgeIntervention::OwnedTask),
2087 ] {
2088 let cli = Cli::parse_from(["batty", "nudge", "enable", arg]);
2089 match cli.command {
2090 Command::Nudge {
2091 command: NudgeCommand::Enable { name },
2092 } => assert_eq!(name, expected, "nudge enable arg={arg}"),
2093 other => panic!("expected nudge enable for {arg}, got {other:?}"),
2094 }
2095 }
2096 }
2097
2098 #[test]
2101 fn config_subcommand_defaults_no_json() {
2102 let cli = Cli::parse_from(["batty", "config"]);
2103 match cli.command {
2104 Command::Config { json } => assert!(!json),
2105 other => panic!("expected config command, got {other:?}"),
2106 }
2107 }
2108
2109 fn generate_completions(shell: clap_complete::Shell) -> String {
2113 use clap::CommandFactory;
2114 let mut buf = Vec::new();
2115 clap_complete::generate(shell, &mut Cli::command(), "batty", &mut buf);
2116 String::from_utf8(buf).expect("completions should be valid UTF-8")
2117 }
2118
2119 #[test]
2120 fn completions_bash_generates() {
2121 let output = generate_completions(clap_complete::Shell::Bash);
2122 assert!(!output.is_empty(), "bash completions should not be empty");
2123 assert!(
2124 output.contains("_batty"),
2125 "bash completions should define _batty function"
2126 );
2127 }
2128
2129 #[test]
2130 fn completions_zsh_generates() {
2131 let output = generate_completions(clap_complete::Shell::Zsh);
2132 assert!(!output.is_empty(), "zsh completions should not be empty");
2133 assert!(
2134 output.contains("#compdef batty"),
2135 "zsh completions should start with #compdef"
2136 );
2137 }
2138
2139 #[test]
2140 fn completions_fish_generates() {
2141 let output = generate_completions(clap_complete::Shell::Fish);
2142 assert!(!output.is_empty(), "fish completions should not be empty");
2143 assert!(
2144 output.contains("complete -c batty"),
2145 "fish completions should contain complete -c batty"
2146 );
2147 }
2148
2149 #[test]
2150 fn completions_include_grafana_subcommands() {
2151 let output = generate_completions(clap_complete::Shell::Fish);
2152 assert!(
2154 output.contains("grafana"),
2155 "completions should include grafana command"
2156 );
2157 assert!(
2159 output.contains("setup"),
2160 "completions should include grafana setup"
2161 );
2162 assert!(
2163 output.contains("status"),
2164 "completions should include grafana status"
2165 );
2166 assert!(
2167 output.contains("open"),
2168 "completions should include grafana open"
2169 );
2170 }
2171
2172 #[test]
2173 fn completions_include_all_recent_commands() {
2174 let output = generate_completions(clap_complete::Shell::Fish);
2175 let expected_commands = [
2176 "task",
2177 "metrics",
2178 "grafana",
2179 "telemetry",
2180 "nudge",
2181 "load",
2182 "queue",
2183 "cost",
2184 "doctor",
2185 "pause",
2186 "resume",
2187 ];
2188 for cmd in &expected_commands {
2189 assert!(
2190 output.contains(cmd),
2191 "completions should include '{cmd}' command"
2192 );
2193 }
2194 }
2195}