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