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