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 #[arg(long, default_value_t = false)]
75 detail: bool,
76 #[arg(long, default_value_t = false)]
78 health: bool,
79 },
80
81 Bench {
83 engineer: String,
85 #[arg(long)]
87 reason: Option<String>,
88 },
89
90 Unbench {
92 engineer: String,
94 },
95
96 #[command(name = "openclaw")]
98 OpenClaw {
99 #[command(subcommand)]
100 command: OpenClawCommand,
101 },
102
103 Send {
105 #[arg(long, hide = true)]
107 from: Option<String>,
108 role: String,
110 message: String,
112 },
113
114 Assign {
116 engineer: String,
118 task: String,
120 },
121
122 Validate {
124 #[arg(long, default_value_t = false)]
126 show_checks: bool,
127 },
128
129 Config {
131 #[arg(long, default_value_t = false)]
133 json: bool,
134 },
135
136 Board {
138 #[command(subcommand)]
139 command: Option<BoardCommand>,
140 },
141
142 #[command(args_conflicts_with_subcommands = true)]
144 Inbox {
145 #[command(subcommand)]
146 command: Option<InboxCommand>,
147 member: Option<String>,
149 #[arg(
151 short = 'n',
152 long = "limit",
153 default_value_t = 20,
154 conflicts_with = "all"
155 )]
156 limit: usize,
157 #[arg(long, default_value_t = false)]
159 all: bool,
160 #[arg(long, default_value_t = false)]
162 raw: bool,
163 },
164
165 Read {
167 member: String,
169 id: String,
171 },
172
173 Ack {
175 member: String,
177 id: String,
179 },
180
181 Merge {
183 engineer: String,
185 },
186
187 Task {
189 #[command(subcommand)]
190 command: TaskCommand,
191 },
192
193 Review {
195 task_id: u32,
197 #[arg(value_enum)]
199 disposition: ReviewAction,
200 feedback: Option<String>,
202 #[arg(long, default_value = "human")]
204 reviewer: String,
205 },
206
207 Completions {
209 #[arg(value_enum)]
211 shell: CompletionShell,
212 },
213
214 Nudge {
216 #[command(subcommand)]
217 command: NudgeCommand,
218 },
219
220 Pause,
222
223 Resume,
225
226 Grafana {
228 #[command(subcommand)]
229 command: GrafanaCommand,
230 },
231
232 Discord {
234 #[command(subcommand)]
235 command: Option<DiscordCommand>,
236 },
237
238 Telegram,
240
241 Project {
243 #[command(subcommand)]
244 command: ProjectCommand,
245 },
246
247 Load,
249
250 Parity {
252 #[arg(long, default_value_t = false, conflicts_with = "gaps")]
254 detail: bool,
255 #[arg(long, default_value_t = false)]
257 gaps: bool,
258 },
259
260 Verify,
262
263 Release {
265 #[arg(long)]
267 tag: Option<String>,
268 },
269
270 Queue,
272
273 Dispatch {
275 #[arg(long, default_value_t = false)]
277 explain: bool,
278 #[arg(long)]
280 task: Option<u32>,
281 },
282
283 Cost,
285
286 Scale {
288 #[command(subcommand)]
289 command: ScaleCommand,
290 },
291
292 Reload,
294
295 Research {
297 #[command(subcommand)]
298 command: ResearchCommand,
299 },
300
301 Doctor {
303 #[arg(long, default_value_t = false)]
305 fix: bool,
306 #[arg(long, default_value_t = false, requires = "fix")]
308 yes: bool,
309 },
310
311 Worktree {
313 #[arg(long, default_value_t = false)]
315 health: bool,
316 },
317
318 Metrics,
320
321 StressTest {
323 #[arg(long, default_value_t = false)]
325 compact: bool,
326
327 #[arg(long, default_value_t = 8)]
329 duration_hours: u64,
330
331 #[arg(long, default_value_t = 1)]
333 seed: u64,
334
335 #[arg(long)]
337 json_out: Option<PathBuf>,
338
339 #[arg(long)]
341 markdown_out: Option<PathBuf>,
342 },
343
344 Telemetry {
346 #[command(subcommand)]
347 command: TelemetryCommand,
348 },
349
350 Chat {
352 #[arg(long, default_value = "generic")]
354 agent_type: String,
355
356 #[arg(long)]
358 cmd: Option<String>,
359
360 #[arg(long, default_value = ".")]
362 cwd: String,
363
364 #[arg(long, default_value_t = false)]
366 sdk_mode: bool,
367 },
368
369 #[command(hide = true)]
371 Shim {
372 #[arg(long)]
374 id: String,
375
376 #[arg(long)]
378 agent_type: String,
379
380 #[arg(long)]
382 cmd: String,
383
384 #[arg(long)]
386 cwd: String,
387
388 #[arg(long, default_value = "50")]
390 rows: u16,
391
392 #[arg(long, default_value = "220")]
394 cols: u16,
395
396 #[arg(long)]
398 pty_log_path: Option<String>,
399
400 #[arg(long, default_value_t = 5)]
402 graceful_shutdown_timeout_secs: u64,
403
404 #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
406 auto_commit_on_restart: bool,
407
408 #[arg(long, default_value_t = false)]
410 sdk_mode: bool,
411 },
412
413 #[command(hide = true)]
415 ConsolePane {
416 #[arg(long)]
418 project_root: String,
419
420 #[arg(long)]
422 member: String,
423
424 #[arg(long)]
426 events_log_path: String,
427
428 #[arg(long)]
430 pty_log_path: String,
431 },
432
433 #[command(hide = true)]
435 GrafanaWebhook {
436 #[arg(long)]
438 project_root: String,
439
440 #[arg(long, default_value_t = 8787)]
442 port: u16,
443 },
444
445 #[command(hide = true)]
447 Daemon {
448 #[arg(long)]
450 project_root: String,
451 #[arg(long)]
453 resume: bool,
454 },
455
456 #[command(hide = true)]
458 Watchdog {
459 #[arg(long)]
461 project_root: String,
462 #[arg(long)]
464 resume: bool,
465 },
466}
467
468#[derive(Subcommand, Debug)]
469pub enum TelemetryCommand {
470 Summary,
472 Agents,
474 Tasks,
476 Reviews,
478 Events {
480 #[arg(short = 'n', long = "limit", default_value_t = 50)]
482 limit: usize,
483 },
484}
485
486#[derive(Subcommand, Debug)]
487pub enum GrafanaCommand {
488 Setup,
490 Status,
492 Open,
494}
495
496#[derive(Subcommand, Debug)]
497pub enum DiscordCommand {
498 Setup,
500 Status,
502}
503
504#[derive(Subcommand, Debug)]
505pub enum ProjectCommand {
506 Register {
508 #[arg(long = "project-id")]
510 project_id: String,
511 #[arg(long)]
513 name: String,
514 #[arg(long = "alias")]
516 aliases: Vec<String>,
517 #[arg(long = "project-root")]
519 project_root: PathBuf,
520 #[arg(long = "board-dir")]
522 board_dir: PathBuf,
523 #[arg(long = "team-name")]
525 team_name: String,
526 #[arg(long = "session-name")]
528 session_name: String,
529 #[arg(long)]
531 owner: Option<String>,
532 #[arg(long = "tag")]
534 tags: Vec<String>,
535 #[arg(long = "channel-binding")]
537 channel_bindings: Vec<String>,
538 #[arg(long = "thread-binding")]
540 thread_bindings: Vec<String>,
541 #[arg(long, default_value_t = false)]
543 allow_openclaw_supervision: bool,
544 #[arg(long, default_value_t = false)]
546 allow_cross_project_routing: bool,
547 #[arg(long, default_value_t = false)]
549 allow_shared_service_routing: bool,
550 #[arg(long, default_value_t = false)]
552 archived: bool,
553 #[arg(long, default_value_t = false)]
555 json: bool,
556 },
557 Unregister {
559 project_id: String,
561 #[arg(long, default_value_t = false)]
563 json: bool,
564 },
565 List {
567 #[arg(long, default_value_t = false)]
569 json: bool,
570 },
571 Get {
573 project_id: String,
575 #[arg(long, default_value_t = false)]
577 json: bool,
578 },
579 Start {
581 project_id: String,
583 #[arg(long, default_value_t = false)]
585 json: bool,
586 },
587 Stop {
589 project_id: String,
591 #[arg(long, default_value_t = false)]
593 json: bool,
594 },
595 Restart {
597 project_id: String,
599 #[arg(long, default_value_t = false)]
601 json: bool,
602 },
603 Status {
605 project_id: String,
607 #[arg(long, default_value_t = false)]
609 json: bool,
610 },
611 SetActive {
613 project_id: String,
615 #[arg(long)]
617 channel: Option<String>,
618 #[arg(long)]
620 binding: Option<String>,
621 #[arg(long = "thread-binding")]
623 thread_binding: Option<String>,
624 #[arg(long, default_value_t = false)]
626 json: bool,
627 },
628 Resolve {
630 message: String,
632 #[arg(long)]
634 channel: Option<String>,
635 #[arg(long)]
637 binding: Option<String>,
638 #[arg(long = "thread-binding")]
640 thread_binding: Option<String>,
641 #[arg(long, default_value_t = false)]
643 json: bool,
644 },
645}
646
647#[derive(Subcommand, Debug)]
648pub enum InboxCommand {
649 Purge {
651 #[arg(required_unless_present = "all_roles")]
653 role: Option<String>,
654 #[arg(long, default_value_t = false)]
656 all_roles: bool,
657 #[arg(long, conflicts_with_all = ["all", "older_than"])]
659 before: Option<u64>,
660 #[arg(long, conflicts_with_all = ["all", "before"])]
662 older_than: Option<String>,
663 #[arg(long, default_value_t = false, conflicts_with_all = ["before", "older_than"])]
665 all: bool,
666 },
667}
668
669#[derive(Subcommand, Debug)]
670pub enum BoardCommand {
671 List {
673 #[arg(long)]
675 status: Option<String>,
676 },
677 Summary,
679 Deps {
681 #[arg(long, value_enum, default_value_t = DepsFormatArg::Tree)]
683 format: DepsFormatArg,
684 },
685 Archive {
687 #[arg(long, default_value = "0s")]
689 older_than: String,
690
691 #[arg(long)]
693 dry_run: bool,
694 },
695 Health,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
700pub enum DepsFormatArg {
701 Tree,
702 Flat,
703 Dot,
704}
705
706#[derive(Subcommand, Debug)]
707pub enum TaskCommand {
708 Transition {
710 task_id: u32,
712 #[arg(value_enum)]
714 target_state: TaskStateArg,
715 },
716
717 Assign {
719 task_id: u32,
721 #[arg(long = "execution-owner")]
723 execution_owner: Option<String>,
724 #[arg(long = "review-owner")]
726 review_owner: Option<String>,
727 },
728
729 Review {
731 task_id: u32,
733 #[arg(long, value_enum)]
735 disposition: ReviewDispositionArg,
736 #[arg(long)]
738 feedback: Option<String>,
739 },
740
741 Update {
743 task_id: u32,
745 #[arg(long)]
747 branch: Option<String>,
748 #[arg(long)]
750 commit: Option<String>,
751 #[arg(long = "blocked-on")]
753 blocked_on: Option<String>,
754 #[arg(long = "clear-blocked", default_value_t = false)]
756 clear_blocked: bool,
757 },
758
759 #[command(name = "auto-merge")]
761 AutoMerge {
762 task_id: u32,
764 #[arg(value_enum)]
766 action: AutoMergeAction,
767 },
768
769 Schedule {
771 task_id: u32,
773 #[arg(long = "at")]
775 at: Option<String>,
776 #[arg(long = "cron")]
778 cron: Option<String>,
779 #[arg(long, default_value_t = false)]
781 clear: bool,
782 },
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
786pub enum InitTemplate {
787 Solo,
789 Pair,
791 Simple,
793 Squad,
795 Large,
797 Research,
799 Software,
801 Cleanroom,
803 Batty,
805}
806
807#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
808pub enum CompletionShell {
809 Bash,
810 Zsh,
811 Fish,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
815pub enum TaskStateArg {
816 Backlog,
817 Todo,
818 #[value(name = "in-progress")]
819 InProgress,
820 Review,
821 Blocked,
822 Done,
823 Archived,
824}
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
827pub enum ReviewDispositionArg {
828 Approved,
829 #[value(name = "changes_requested")]
830 ChangesRequested,
831 Rejected,
832}
833
834#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
835pub enum ReviewAction {
836 Approve,
837 #[value(name = "request-changes")]
838 RequestChanges,
839 Reject,
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
843pub enum AutoMergeAction {
844 Enable,
845 Disable,
846}
847
848#[derive(Subcommand, Debug)]
849pub enum ScaleCommand {
850 Engineers {
852 count: u32,
854 },
855 AddManager {
857 name: String,
859 },
860 RemoveManager {
862 name: String,
864 },
865 Status,
867}
868
869#[derive(Subcommand, Debug)]
870pub enum ResearchCommand {
871 Start {
873 hypothesis: String,
875 #[arg(long)]
877 evaluator: String,
878 #[arg(long, value_enum, default_value_t = ResearchFormatArg::Json)]
880 format: ResearchFormatArg,
881 #[arg(long, value_enum, default_value_t = ResearchKeepPolicyArg::PassOnly)]
883 keep_policy: ResearchKeepPolicyArg,
884 #[arg(long, default_value_t = 10)]
886 max_iterations: u32,
887 #[arg(long, default_value = ".")]
889 worktree: PathBuf,
890 },
891 Status,
893 Ledger,
895 Stop,
897}
898
899#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
900pub enum ResearchFormatArg {
901 Json,
902 #[value(name = "exit-code")]
903 ExitCode,
904}
905
906#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
907pub enum ResearchKeepPolicyArg {
908 #[value(name = "pass-only")]
909 PassOnly,
910 #[value(name = "score-improvement")]
911 ScoreImprovement,
912 #[value(name = "parity-improvement")]
913 ParityImprovement,
914}
915
916#[derive(Subcommand, Debug)]
917pub enum NudgeCommand {
918 Disable {
920 #[arg(value_enum)]
922 name: NudgeIntervention,
923 },
924 Enable {
926 #[arg(value_enum)]
928 name: NudgeIntervention,
929 },
930 Status,
932}
933
934#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
935pub enum NudgeIntervention {
936 Replenish,
937 Triage,
938 Review,
939 Dispatch,
940 Utilization,
941 #[value(name = "owned-task")]
942 OwnedTask,
943}
944
945#[derive(Subcommand, Debug)]
946pub enum OpenClawCommand {
947 Register {
949 #[arg(long, default_value_t = false)]
951 force: bool,
952 },
953 Status {
955 #[arg(long, default_value_t = false)]
957 json: bool,
958 },
959 Instruct {
961 role: String,
963 message: String,
965 },
966 Events {
968 #[arg(long = "project-id", conflicts_with = "all_projects")]
970 project_id: Option<String>,
971 #[arg(
973 long = "all-projects",
974 default_value_t = false,
975 conflicts_with = "project_id"
976 )]
977 all_projects: bool,
978 #[arg(long, default_value_t = false)]
980 json: bool,
981 #[arg(long = "topic", value_enum)]
983 topics: Vec<OpenClawEventTopicArg>,
984 #[arg(long = "role")]
986 roles: Vec<String>,
987 #[arg(long = "task-id")]
989 task_ids: Vec<String>,
990 #[arg(long = "event-type")]
992 event_types: Vec<String>,
993 #[arg(long = "session-name")]
995 session_names: Vec<String>,
996 #[arg(long = "since-ts")]
998 since_ts: Option<u64>,
999 #[arg(long)]
1001 limit: Option<usize>,
1002 #[arg(long, default_value_t = false)]
1004 include_archived: bool,
1005 },
1006 #[command(name = "follow-up")]
1008 FollowUp {
1009 #[command(subcommand)]
1010 command: OpenClawFollowUpCommand,
1011 },
1012}
1013
1014#[derive(Subcommand, Debug)]
1015pub enum OpenClawFollowUpCommand {
1016 Run {
1018 #[arg(long, default_value_t = false)]
1020 json: bool,
1021 },
1022}
1023
1024#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
1025pub enum OpenClawEventTopicArg {
1026 Completion,
1027 Review,
1028 Stall,
1029 Merge,
1030 Escalation,
1031 #[value(name = "delivery-failure")]
1032 DeliveryFailure,
1033 Lifecycle,
1034}
1035
1036impl NudgeIntervention {
1037 #[allow(dead_code)]
1039 pub fn marker_name(self) -> &'static str {
1040 match self {
1041 Self::Replenish => "replenish",
1042 Self::Triage => "triage",
1043 Self::Review => "review",
1044 Self::Dispatch => "dispatch",
1045 Self::Utilization => "utilization",
1046 Self::OwnedTask => "owned-task",
1047 }
1048 }
1049
1050 #[allow(dead_code)]
1052 pub const ALL: [NudgeIntervention; 6] = [
1053 Self::Replenish,
1054 Self::Triage,
1055 Self::Review,
1056 Self::Dispatch,
1057 Self::Utilization,
1058 Self::OwnedTask,
1059 ];
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 #[test]
1067 fn board_command_defaults_to_tui() {
1068 let cli = Cli::parse_from(["batty", "board"]);
1069 match cli.command {
1070 Command::Board { command } => assert!(command.is_none()),
1071 other => panic!("expected board command, got {other:?}"),
1072 }
1073 }
1074
1075 #[test]
1076 fn board_list_subcommand_parses() {
1077 let cli = Cli::parse_from(["batty", "board", "list"]);
1078 match cli.command {
1079 Command::Board {
1080 command: Some(BoardCommand::List { status }),
1081 } => assert_eq!(status, None),
1082 other => panic!("expected board list command, got {other:?}"),
1083 }
1084 }
1085
1086 #[test]
1087 fn board_list_subcommand_parses_status_filter() {
1088 let cli = Cli::parse_from(["batty", "board", "list", "--status", "review"]);
1089 match cli.command {
1090 Command::Board {
1091 command: Some(BoardCommand::List { status }),
1092 } => assert_eq!(status.as_deref(), Some("review")),
1093 other => panic!("expected board list command, got {other:?}"),
1094 }
1095 }
1096
1097 #[test]
1098 fn board_summary_subcommand_parses() {
1099 let cli = Cli::parse_from(["batty", "board", "summary"]);
1100 match cli.command {
1101 Command::Board {
1102 command: Some(BoardCommand::Summary),
1103 } => {}
1104 other => panic!("expected board summary command, got {other:?}"),
1105 }
1106 }
1107
1108 #[test]
1109 fn board_deps_subcommand_defaults_to_tree() {
1110 let cli = Cli::parse_from(["batty", "board", "deps"]);
1111 match cli.command {
1112 Command::Board {
1113 command: Some(BoardCommand::Deps { format }),
1114 } => assert_eq!(format, DepsFormatArg::Tree),
1115 other => panic!("expected board deps command, got {other:?}"),
1116 }
1117 }
1118
1119 #[test]
1120 fn reload_subcommand_parses() {
1121 let cli = Cli::parse_from(["batty", "reload"]);
1122 match cli.command {
1123 Command::Reload => {}
1124 other => panic!("expected reload command, got {other:?}"),
1125 }
1126 }
1127
1128 #[test]
1129 fn research_start_subcommand_parses() {
1130 let cli = Cli::parse_from([
1131 "batty",
1132 "research",
1133 "start",
1134 "improve baseline",
1135 "--evaluator",
1136 "cargo test",
1137 "--format",
1138 "exit-code",
1139 "--keep-policy",
1140 "parity-improvement",
1141 "--max-iterations",
1142 "3",
1143 "--worktree",
1144 "tmp/research",
1145 ]);
1146 match cli.command {
1147 Command::Research {
1148 command:
1149 ResearchCommand::Start {
1150 hypothesis,
1151 evaluator,
1152 format,
1153 keep_policy,
1154 max_iterations,
1155 worktree,
1156 },
1157 } => {
1158 assert_eq!(hypothesis, "improve baseline");
1159 assert_eq!(evaluator, "cargo test");
1160 assert_eq!(format, ResearchFormatArg::ExitCode);
1161 assert_eq!(keep_policy, ResearchKeepPolicyArg::ParityImprovement);
1162 assert_eq!(max_iterations, 3);
1163 assert_eq!(worktree, PathBuf::from("tmp/research"));
1164 }
1165 other => panic!("expected research start command, got {other:?}"),
1166 }
1167 }
1168
1169 #[test]
1170 fn research_status_subcommand_parses() {
1171 let cli = Cli::parse_from(["batty", "research", "status"]);
1172 match cli.command {
1173 Command::Research {
1174 command: ResearchCommand::Status,
1175 } => {}
1176 other => panic!("expected research status command, got {other:?}"),
1177 }
1178 }
1179
1180 #[test]
1181 fn board_deps_subcommand_parses_format_flag() {
1182 for (arg, expected) in [
1183 ("tree", DepsFormatArg::Tree),
1184 ("flat", DepsFormatArg::Flat),
1185 ("dot", DepsFormatArg::Dot),
1186 ] {
1187 let cli = Cli::parse_from(["batty", "board", "deps", "--format", arg]);
1188 match cli.command {
1189 Command::Board {
1190 command: Some(BoardCommand::Deps { format }),
1191 } => assert_eq!(format, expected, "format arg={arg}"),
1192 other => panic!("expected board deps command for {arg}, got {other:?}"),
1193 }
1194 }
1195 }
1196
1197 #[test]
1198 fn board_archive_subcommand_parses() {
1199 let cli = Cli::parse_from(["batty", "board", "archive"]);
1200 match cli.command {
1201 Command::Board {
1202 command:
1203 Some(BoardCommand::Archive {
1204 older_than,
1205 dry_run,
1206 }),
1207 } => {
1208 assert_eq!(older_than, "0s");
1209 assert!(!dry_run);
1210 }
1211 other => panic!("expected board archive command, got {other:?}"),
1212 }
1213 }
1214
1215 #[test]
1216 fn board_archive_subcommand_parses_older_than() {
1217 let cli = Cli::parse_from(["batty", "board", "archive", "--older-than", "7d"]);
1218 match cli.command {
1219 Command::Board {
1220 command:
1221 Some(BoardCommand::Archive {
1222 older_than,
1223 dry_run,
1224 }),
1225 } => {
1226 assert_eq!(older_than, "7d");
1227 assert!(!dry_run);
1228 }
1229 other => panic!("expected board archive command with older_than, got {other:?}"),
1230 }
1231 }
1232
1233 #[test]
1234 fn board_archive_subcommand_parses_dry_run() {
1235 let cli = Cli::parse_from(["batty", "board", "archive", "--dry-run"]);
1236 match cli.command {
1237 Command::Board {
1238 command:
1239 Some(BoardCommand::Archive {
1240 older_than,
1241 dry_run,
1242 }),
1243 } => {
1244 assert_eq!(older_than, "0s");
1245 assert!(dry_run);
1246 }
1247 other => panic!("expected board archive command with dry_run, got {other:?}"),
1248 }
1249 }
1250
1251 #[test]
1252 fn board_health_subcommand_parses() {
1253 let cli = Cli::parse_from(["batty", "board", "health"]);
1254 match cli.command {
1255 Command::Board {
1256 command: Some(BoardCommand::Health),
1257 } => {}
1258 other => panic!("expected board health command, got {other:?}"),
1259 }
1260 }
1261
1262 #[test]
1263 fn init_subcommand_defaults_to_simple() {
1264 let cli = Cli::parse_from(["batty", "init"]);
1265 match cli.command {
1266 Command::Init {
1267 template,
1268 from,
1269 agent,
1270 ..
1271 } => {
1272 assert_eq!(template, None);
1273 assert_eq!(from, None);
1274 assert_eq!(agent, None);
1275 }
1276 other => panic!("expected init command, got {other:?}"),
1277 }
1278 }
1279
1280 #[test]
1281 fn init_subcommand_accepts_large_template() {
1282 let cli = Cli::parse_from(["batty", "init", "--template", "large"]);
1283 match cli.command {
1284 Command::Init { template, from, .. } => {
1285 assert_eq!(template, Some(InitTemplate::Large));
1286 assert_eq!(from, None);
1287 }
1288 other => panic!("expected init command, got {other:?}"),
1289 }
1290 }
1291
1292 #[test]
1293 fn init_subcommand_accepts_from_template_name() {
1294 let cli = Cli::parse_from(["batty", "init", "--from", "custom-team"]);
1295 match cli.command {
1296 Command::Init { template, from, .. } => {
1297 assert_eq!(template, None);
1298 assert_eq!(from.as_deref(), Some("custom-team"));
1299 }
1300 other => panic!("expected init command, got {other:?}"),
1301 }
1302 }
1303
1304 #[test]
1305 fn init_subcommand_rejects_from_with_template() {
1306 let result = Cli::try_parse_from(["batty", "init", "--template", "large", "--from", "x"]);
1307 assert!(result.is_err());
1308 }
1309
1310 #[test]
1311 fn init_agent_flag_parses() {
1312 let cli = Cli::parse_from(["batty", "init", "--agent", "codex"]);
1313 match cli.command {
1314 Command::Init { agent, .. } => {
1315 assert_eq!(agent.as_deref(), Some("codex"));
1316 }
1317 other => panic!("expected init command, got {other:?}"),
1318 }
1319 }
1320
1321 #[test]
1322 fn install_alias_maps_to_init() {
1323 let cli = Cli::parse_from(["batty", "install"]);
1324 match cli.command {
1325 Command::Init {
1326 template,
1327 from,
1328 agent,
1329 ..
1330 } => {
1331 assert_eq!(template, None);
1332 assert_eq!(from, None);
1333 assert_eq!(agent, None);
1334 }
1335 other => panic!("expected init command via install alias, got {other:?}"),
1336 }
1337 }
1338
1339 #[test]
1340 fn install_alias_with_agent_flag() {
1341 let cli = Cli::parse_from(["batty", "install", "--agent", "kiro"]);
1342 match cli.command {
1343 Command::Init { agent, .. } => {
1344 assert_eq!(agent.as_deref(), Some("kiro"));
1345 }
1346 other => panic!("expected init command via install alias, got {other:?}"),
1347 }
1348 }
1349
1350 #[test]
1351 fn export_template_subcommand_parses() {
1352 let cli = Cli::parse_from(["batty", "export-template", "myteam"]);
1353 match cli.command {
1354 Command::ExportTemplate { name } => assert_eq!(name, "myteam"),
1355 other => panic!("expected export-template command, got {other:?}"),
1356 }
1357 }
1358
1359 #[test]
1360 fn export_run_subcommand_parses() {
1361 let cli = Cli::parse_from(["batty", "export-run"]);
1362 match cli.command {
1363 Command::ExportRun => {}
1364 other => panic!("expected export-run command, got {other:?}"),
1365 }
1366 }
1367
1368 #[test]
1369 fn retro_subcommand_parses() {
1370 let cli = Cli::parse_from(["batty", "retro"]);
1371 match cli.command {
1372 Command::Retro { events } => assert!(events.is_none()),
1373 other => panic!("expected retro command, got {other:?}"),
1374 }
1375 }
1376
1377 #[test]
1378 fn retro_subcommand_parses_with_events_path() {
1379 let cli = Cli::parse_from(["batty", "retro", "--events", "/tmp/events.jsonl"]);
1380 match cli.command {
1381 Command::Retro { events } => {
1382 assert_eq!(events, Some(PathBuf::from("/tmp/events.jsonl")));
1383 }
1384 other => panic!("expected retro command, got {other:?}"),
1385 }
1386 }
1387
1388 #[test]
1389 fn start_subcommand_defaults() {
1390 let cli = Cli::parse_from(["batty", "start"]);
1391 match cli.command {
1392 Command::Start { attach } => assert!(!attach),
1393 other => panic!("expected start command, got {other:?}"),
1394 }
1395 }
1396
1397 #[test]
1398 fn start_subcommand_with_attach() {
1399 let cli = Cli::parse_from(["batty", "start", "--attach"]);
1400 match cli.command {
1401 Command::Start { attach } => assert!(attach),
1402 other => panic!("expected start command, got {other:?}"),
1403 }
1404 }
1405
1406 #[test]
1407 fn stop_subcommand_parses() {
1408 let cli = Cli::parse_from(["batty", "stop"]);
1409 assert!(matches!(cli.command, Command::Stop));
1410 }
1411
1412 #[test]
1413 fn attach_subcommand_parses() {
1414 let cli = Cli::parse_from(["batty", "attach"]);
1415 assert!(matches!(cli.command, Command::Attach));
1416 }
1417
1418 #[test]
1419 fn status_subcommand_defaults() {
1420 let cli = Cli::parse_from(["batty", "status"]);
1421 match cli.command {
1422 Command::Status {
1423 json,
1424 detail,
1425 health,
1426 } => {
1427 assert!(!json);
1428 assert!(!detail);
1429 assert!(!health);
1430 }
1431 other => panic!("expected status command, got {other:?}"),
1432 }
1433 }
1434
1435 #[test]
1436 fn status_subcommand_json_flag() {
1437 let cli = Cli::parse_from(["batty", "status", "--json"]);
1438 match cli.command {
1439 Command::Status {
1440 json,
1441 detail,
1442 health,
1443 } => {
1444 assert!(json);
1445 assert!(!detail);
1446 assert!(!health);
1447 }
1448 other => panic!("expected status command, got {other:?}"),
1449 }
1450 }
1451
1452 #[test]
1453 fn status_subcommand_detail_flag() {
1454 let cli = Cli::parse_from(["batty", "status", "--detail"]);
1455 match cli.command {
1456 Command::Status {
1457 json,
1458 detail,
1459 health,
1460 } => {
1461 assert!(!json);
1462 assert!(detail);
1463 assert!(!health);
1464 }
1465 other => panic!("expected status command, got {other:?}"),
1466 }
1467 }
1468
1469 #[test]
1470 fn status_subcommand_health_flag() {
1471 let cli = Cli::parse_from(["batty", "status", "--health"]);
1472 match cli.command {
1473 Command::Status {
1474 json,
1475 detail,
1476 health,
1477 } => {
1478 assert!(!json);
1479 assert!(!detail);
1480 assert!(health);
1481 }
1482 other => panic!("expected status command, got {other:?}"),
1483 }
1484 }
1485
1486 #[test]
1487 fn openclaw_register_subcommand_parses() {
1488 let cli = Cli::parse_from(["batty", "openclaw", "register", "--force"]);
1489 match cli.command {
1490 Command::OpenClaw { command } => match command {
1491 OpenClawCommand::Register { force } => assert!(force),
1492 other => panic!("expected openclaw register command, got {other:?}"),
1493 },
1494 other => panic!("expected openclaw command, got {other:?}"),
1495 }
1496 }
1497
1498 #[test]
1499 fn openclaw_status_subcommand_parses_json_flag() {
1500 let cli = Cli::parse_from(["batty", "openclaw", "status", "--json"]);
1501 match cli.command {
1502 Command::OpenClaw { command } => match command {
1503 OpenClawCommand::Status { json } => assert!(json),
1504 other => panic!("expected openclaw status command, got {other:?}"),
1505 },
1506 other => panic!("expected openclaw command, got {other:?}"),
1507 }
1508 }
1509
1510 #[test]
1511 fn openclaw_follow_up_run_subcommand_parses() {
1512 let cli = Cli::parse_from(["batty", "openclaw", "follow-up", "run"]);
1513 match cli.command {
1514 Command::OpenClaw { command } => match command {
1515 OpenClawCommand::FollowUp { command } => match command {
1516 OpenClawFollowUpCommand::Run { json } => assert!(!json),
1517 },
1518 other => panic!("expected openclaw follow-up command, got {other:?}"),
1519 },
1520 other => panic!("expected openclaw command, got {other:?}"),
1521 }
1522 }
1523
1524 #[test]
1525 fn openclaw_events_subcommand_parses_filters() {
1526 let cli = Cli::parse_from([
1527 "batty",
1528 "openclaw",
1529 "events",
1530 "--project-id",
1531 "fixture-degraded",
1532 "--json",
1533 "--topic",
1534 "escalation",
1535 "--topic",
1536 "completion",
1537 "--role",
1538 "eng-1-1",
1539 "--task-id",
1540 "449",
1541 "--event-type",
1542 "task.escalated",
1543 "--session-name",
1544 "batty-fixture-team-degraded",
1545 "--since-ts",
1546 "1712402000",
1547 "--limit",
1548 "5",
1549 "--include-archived",
1550 ]);
1551 match cli.command {
1552 Command::OpenClaw { command } => match command {
1553 OpenClawCommand::Events {
1554 project_id,
1555 all_projects,
1556 json,
1557 topics,
1558 roles,
1559 task_ids,
1560 event_types,
1561 session_names,
1562 since_ts,
1563 limit,
1564 include_archived,
1565 } => {
1566 assert_eq!(project_id.as_deref(), Some("fixture-degraded"));
1567 assert!(!all_projects);
1568 assert!(json);
1569 assert_eq!(
1570 topics,
1571 vec![
1572 OpenClawEventTopicArg::Escalation,
1573 OpenClawEventTopicArg::Completion,
1574 ]
1575 );
1576 assert_eq!(roles, vec!["eng-1-1"]);
1577 assert_eq!(task_ids, vec!["449"]);
1578 assert_eq!(event_types, vec!["task.escalated"]);
1579 assert_eq!(session_names, vec!["batty-fixture-team-degraded"]);
1580 assert_eq!(since_ts, Some(1_712_402_000));
1581 assert_eq!(limit, Some(5));
1582 assert!(include_archived);
1583 }
1584 other => panic!("expected openclaw events command, got {other:?}"),
1585 },
1586 other => panic!("expected openclaw command, got {other:?}"),
1587 }
1588 }
1589
1590 #[test]
1591 fn send_subcommand_parses_role_and_message() {
1592 let cli = Cli::parse_from(["batty", "send", "architect", "hello world"]);
1593 match cli.command {
1594 Command::Send {
1595 from,
1596 role,
1597 message,
1598 } => {
1599 assert!(from.is_none());
1600 assert_eq!(role, "architect");
1601 assert_eq!(message, "hello world");
1602 }
1603 other => panic!("expected send command, got {other:?}"),
1604 }
1605 }
1606
1607 #[test]
1608 fn assign_subcommand_parses_engineer_and_task() {
1609 let cli = Cli::parse_from(["batty", "assign", "eng-1-1", "fix auth bug"]);
1610 match cli.command {
1611 Command::Assign { engineer, task } => {
1612 assert_eq!(engineer, "eng-1-1");
1613 assert_eq!(task, "fix auth bug");
1614 }
1615 other => panic!("expected assign command, got {other:?}"),
1616 }
1617 }
1618
1619 #[test]
1620 fn bench_subcommand_parses_reason() {
1621 let cli = Cli::parse_from(["batty", "bench", "eng-1-1", "--reason", "session end"]);
1622 match cli.command {
1623 Command::Bench { engineer, reason } => {
1624 assert_eq!(engineer, "eng-1-1");
1625 assert_eq!(reason.as_deref(), Some("session end"));
1626 }
1627 other => panic!("expected bench command, got {other:?}"),
1628 }
1629 }
1630
1631 #[test]
1632 fn unbench_subcommand_parses_engineer() {
1633 let cli = Cli::parse_from(["batty", "unbench", "eng-1-1"]);
1634 match cli.command {
1635 Command::Unbench { engineer } => assert_eq!(engineer, "eng-1-1"),
1636 other => panic!("expected unbench command, got {other:?}"),
1637 }
1638 }
1639
1640 #[test]
1641 fn validate_subcommand_parses() {
1642 let cli = Cli::parse_from(["batty", "validate"]);
1643 match cli.command {
1644 Command::Validate { show_checks } => assert!(!show_checks),
1645 other => panic!("expected validate command, got {other:?}"),
1646 }
1647 }
1648
1649 #[test]
1650 fn validate_subcommand_show_checks_flag() {
1651 let cli = Cli::parse_from(["batty", "validate", "--show-checks"]);
1652 match cli.command {
1653 Command::Validate { show_checks } => assert!(show_checks),
1654 other => panic!("expected validate command with show_checks, got {other:?}"),
1655 }
1656 }
1657
1658 #[test]
1659 fn config_subcommand_json_flag() {
1660 let cli = Cli::parse_from(["batty", "config", "--json"]);
1661 match cli.command {
1662 Command::Config { json } => assert!(json),
1663 other => panic!("expected config command, got {other:?}"),
1664 }
1665 }
1666
1667 #[test]
1668 fn merge_subcommand_parses_engineer() {
1669 let cli = Cli::parse_from(["batty", "merge", "eng-1-1"]);
1670 match cli.command {
1671 Command::Merge { engineer } => assert_eq!(engineer, "eng-1-1"),
1672 other => panic!("expected merge command, got {other:?}"),
1673 }
1674 }
1675
1676 #[test]
1677 fn completions_subcommand_parses_shell() {
1678 let cli = Cli::parse_from(["batty", "completions", "zsh"]);
1679 match cli.command {
1680 Command::Completions { shell } => assert_eq!(shell, CompletionShell::Zsh),
1681 other => panic!("expected completions command, got {other:?}"),
1682 }
1683 }
1684
1685 #[test]
1686 fn inbox_subcommand_parses_defaults() {
1687 let cli = Cli::parse_from(["batty", "inbox", "architect"]);
1688 match cli.command {
1689 Command::Inbox {
1690 command,
1691 member,
1692 limit,
1693 all,
1694 raw,
1695 } => {
1696 assert!(command.is_none());
1697 assert_eq!(member.as_deref(), Some("architect"));
1698 assert_eq!(limit, 20);
1699 assert!(!all);
1700 assert!(!raw);
1701 }
1702 other => panic!("expected inbox command, got {other:?}"),
1703 }
1704 }
1705
1706 #[test]
1707 fn inbox_subcommand_parses_limit_flag() {
1708 let cli = Cli::parse_from(["batty", "inbox", "architect", "-n", "50"]);
1709 match cli.command {
1710 Command::Inbox {
1711 command,
1712 member,
1713 limit,
1714 all,
1715 raw,
1716 } => {
1717 assert!(command.is_none());
1718 assert_eq!(member.as_deref(), Some("architect"));
1719 assert_eq!(limit, 50);
1720 assert!(!all);
1721 assert!(!raw);
1722 }
1723 other => panic!("expected inbox command, got {other:?}"),
1724 }
1725 }
1726
1727 #[test]
1728 fn inbox_subcommand_parses_all_flag() {
1729 let cli = Cli::parse_from(["batty", "inbox", "architect", "--all"]);
1730 match cli.command {
1731 Command::Inbox {
1732 command,
1733 member,
1734 limit,
1735 all,
1736 raw,
1737 } => {
1738 assert!(command.is_none());
1739 assert_eq!(member.as_deref(), Some("architect"));
1740 assert_eq!(limit, 20);
1741 assert!(all);
1742 assert!(!raw);
1743 }
1744 other => panic!("expected inbox command, got {other:?}"),
1745 }
1746 }
1747
1748 #[test]
1749 fn inbox_subcommand_parses_raw_flag() {
1750 let cli = Cli::parse_from(["batty", "inbox", "architect", "--raw"]);
1751 match cli.command {
1752 Command::Inbox {
1753 command,
1754 member,
1755 raw,
1756 ..
1757 } => {
1758 assert!(command.is_none());
1759 assert_eq!(member.as_deref(), Some("architect"));
1760 assert!(raw);
1761 }
1762 other => panic!("expected inbox command, got {other:?}"),
1763 }
1764 }
1765
1766 #[test]
1767 fn inbox_purge_subcommand_parses_role_and_before() {
1768 let cli = Cli::parse_from(["batty", "inbox", "purge", "architect", "--before", "123"]);
1769 match cli.command {
1770 Command::Inbox {
1771 command:
1772 Some(InboxCommand::Purge {
1773 role,
1774 all_roles,
1775 before,
1776 older_than,
1777 all,
1778 }),
1779 member,
1780 ..
1781 } => {
1782 assert!(member.is_none());
1783 assert_eq!(role.as_deref(), Some("architect"));
1784 assert!(!all_roles);
1785 assert_eq!(before, Some(123));
1786 assert!(older_than.is_none());
1787 assert!(!all);
1788 }
1789 other => panic!("expected inbox purge command, got {other:?}"),
1790 }
1791 }
1792
1793 #[test]
1794 fn inbox_purge_subcommand_parses_all_roles_and_all() {
1795 let cli = Cli::parse_from(["batty", "inbox", "purge", "--all-roles", "--all"]);
1796 match cli.command {
1797 Command::Inbox {
1798 command:
1799 Some(InboxCommand::Purge {
1800 role,
1801 all_roles,
1802 before,
1803 older_than,
1804 all,
1805 }),
1806 member,
1807 ..
1808 } => {
1809 assert!(member.is_none());
1810 assert!(role.is_none());
1811 assert!(all_roles);
1812 assert_eq!(before, None);
1813 assert!(older_than.is_none());
1814 assert!(all);
1815 }
1816 other => panic!("expected inbox purge command, got {other:?}"),
1817 }
1818 }
1819
1820 #[test]
1821 fn inbox_purge_subcommand_parses_older_than() {
1822 let cli = Cli::parse_from(["batty", "inbox", "purge", "eng-1", "--older-than", "24h"]);
1823 match cli.command {
1824 Command::Inbox {
1825 command:
1826 Some(InboxCommand::Purge {
1827 role,
1828 all_roles,
1829 before,
1830 older_than,
1831 all,
1832 }),
1833 ..
1834 } => {
1835 assert_eq!(role.as_deref(), Some("eng-1"));
1836 assert!(!all_roles);
1837 assert_eq!(before, None);
1838 assert_eq!(older_than.as_deref(), Some("24h"));
1839 assert!(!all);
1840 }
1841 other => panic!("expected inbox purge command, got {other:?}"),
1842 }
1843 }
1844
1845 #[test]
1846 fn inbox_purge_rejects_older_than_with_before() {
1847 let result = Cli::try_parse_from([
1848 "batty",
1849 "inbox",
1850 "purge",
1851 "eng-1",
1852 "--older-than",
1853 "24h",
1854 "--before",
1855 "100",
1856 ]);
1857 assert!(result.is_err());
1858 }
1859
1860 #[test]
1861 fn inbox_purge_rejects_older_than_with_all() {
1862 let result = Cli::try_parse_from([
1863 "batty",
1864 "inbox",
1865 "purge",
1866 "eng-1",
1867 "--older-than",
1868 "24h",
1869 "--all",
1870 ]);
1871 assert!(result.is_err());
1872 }
1873
1874 #[test]
1875 fn read_subcommand_parses_member_and_id() {
1876 let cli = Cli::parse_from(["batty", "read", "architect", "abc123"]);
1877 match cli.command {
1878 Command::Read { member, id } => {
1879 assert_eq!(member, "architect");
1880 assert_eq!(id, "abc123");
1881 }
1882 other => panic!("expected read command, got {other:?}"),
1883 }
1884 }
1885
1886 #[test]
1887 fn ack_subcommand_parses_member_and_id() {
1888 let cli = Cli::parse_from(["batty", "ack", "eng-1-1", "abc123"]);
1889 match cli.command {
1890 Command::Ack { member, id } => {
1891 assert_eq!(member, "eng-1-1");
1892 assert_eq!(id, "abc123");
1893 }
1894 other => panic!("expected ack command, got {other:?}"),
1895 }
1896 }
1897
1898 #[test]
1899 fn pause_subcommand_parses() {
1900 let cli = Cli::parse_from(["batty", "pause"]);
1901 assert!(matches!(cli.command, Command::Pause));
1902 }
1903
1904 #[test]
1905 fn resume_subcommand_parses() {
1906 let cli = Cli::parse_from(["batty", "resume"]);
1907 assert!(matches!(cli.command, Command::Resume));
1908 }
1909
1910 #[test]
1911 fn telegram_subcommand_parses() {
1912 let cli = Cli::parse_from(["batty", "telegram"]);
1913 assert!(matches!(cli.command, Command::Telegram));
1914 }
1915
1916 #[test]
1917 fn discord_subcommand_defaults_to_setup_flow() {
1918 let cli = Cli::parse_from(["batty", "discord"]);
1919 assert!(matches!(cli.command, Command::Discord { command: None }));
1920 }
1921
1922 #[test]
1923 fn discord_status_subcommand_parses() {
1924 let cli = Cli::parse_from(["batty", "discord", "status"]);
1925 assert!(matches!(
1926 cli.command,
1927 Command::Discord {
1928 command: Some(DiscordCommand::Status)
1929 }
1930 ));
1931 }
1932
1933 #[test]
1934 fn project_register_subcommand_parses() {
1935 let cli = Cli::parse_from([
1936 "batty",
1937 "project",
1938 "register",
1939 "--project-id",
1940 "batty-core",
1941 "--name",
1942 "Batty Core",
1943 "--project-root",
1944 "/tmp/batty",
1945 "--board-dir",
1946 "/tmp/batty/.batty/team_config/board",
1947 "--team-name",
1948 "batty",
1949 "--session-name",
1950 "batty-batty",
1951 "--owner",
1952 "platform",
1953 "--tag",
1954 "openclaw",
1955 "--channel-binding",
1956 "telegram=chat:123",
1957 "--allow-openclaw-supervision",
1958 "--json",
1959 ]);
1960 match cli.command {
1961 Command::Project {
1962 command:
1963 ProjectCommand::Register {
1964 project_id,
1965 name,
1966 aliases,
1967 project_root,
1968 board_dir,
1969 team_name,
1970 session_name,
1971 owner,
1972 tags,
1973 channel_bindings,
1974 thread_bindings,
1975 allow_openclaw_supervision,
1976 allow_cross_project_routing,
1977 allow_shared_service_routing,
1978 archived,
1979 json,
1980 },
1981 } => {
1982 assert_eq!(project_id, "batty-core");
1983 assert_eq!(name, "Batty Core");
1984 assert_eq!(project_root, PathBuf::from("/tmp/batty"));
1985 assert_eq!(
1986 board_dir,
1987 PathBuf::from("/tmp/batty/.batty/team_config/board")
1988 );
1989 assert_eq!(team_name, "batty");
1990 assert_eq!(session_name, "batty-batty");
1991 assert_eq!(owner.as_deref(), Some("platform"));
1992 assert!(aliases.is_empty());
1993 assert_eq!(tags, vec!["openclaw"]);
1994 assert_eq!(channel_bindings, vec!["telegram=chat:123"]);
1995 assert!(thread_bindings.is_empty());
1996 assert!(allow_openclaw_supervision);
1997 assert!(!allow_cross_project_routing);
1998 assert!(!allow_shared_service_routing);
1999 assert!(!archived);
2000 assert!(json);
2001 }
2002 other => panic!("expected project register command, got {other:?}"),
2003 }
2004 }
2005
2006 #[test]
2007 fn project_get_subcommand_parses() {
2008 let cli = Cli::parse_from(["batty", "project", "get", "batty-core", "--json"]);
2009 match cli.command {
2010 Command::Project {
2011 command: ProjectCommand::Get { project_id, json },
2012 } => {
2013 assert_eq!(project_id, "batty-core");
2014 assert!(json);
2015 }
2016 other => panic!("expected project get command, got {other:?}"),
2017 }
2018 }
2019
2020 #[test]
2021 fn project_lifecycle_subcommands_parse() {
2022 let start = Cli::parse_from(["batty", "project", "start", "batty-core", "--json"]);
2023 assert!(matches!(
2024 start.command,
2025 Command::Project {
2026 command: ProjectCommand::Start {
2027 project_id,
2028 json: true
2029 }
2030 } if project_id == "batty-core"
2031 ));
2032
2033 let stop = Cli::parse_from(["batty", "project", "stop", "batty-core"]);
2034 assert!(matches!(
2035 stop.command,
2036 Command::Project {
2037 command: ProjectCommand::Stop {
2038 project_id,
2039 json: false
2040 }
2041 } if project_id == "batty-core"
2042 ));
2043
2044 let restart = Cli::parse_from(["batty", "project", "restart", "batty-core"]);
2045 assert!(matches!(
2046 restart.command,
2047 Command::Project {
2048 command: ProjectCommand::Restart {
2049 project_id,
2050 json: false
2051 }
2052 } if project_id == "batty-core"
2053 ));
2054
2055 let status = Cli::parse_from(["batty", "project", "status", "batty-core"]);
2056 assert!(matches!(
2057 status.command,
2058 Command::Project {
2059 command: ProjectCommand::Status {
2060 project_id,
2061 json: false
2062 }
2063 } if project_id == "batty-core"
2064 ));
2065 }
2066
2067 #[test]
2068 fn project_set_active_subcommand_parses_thread_scope() {
2069 let cli = Cli::parse_from([
2070 "batty",
2071 "project",
2072 "set-active",
2073 "batty-core",
2074 "--channel",
2075 "slack",
2076 "--binding",
2077 "channel:C123",
2078 "--thread-binding",
2079 "thread:abc",
2080 ]);
2081 match cli.command {
2082 Command::Project {
2083 command:
2084 ProjectCommand::SetActive {
2085 project_id,
2086 channel,
2087 binding,
2088 thread_binding,
2089 json,
2090 },
2091 } => {
2092 assert_eq!(project_id, "batty-core");
2093 assert_eq!(channel.as_deref(), Some("slack"));
2094 assert_eq!(binding.as_deref(), Some("channel:C123"));
2095 assert_eq!(thread_binding.as_deref(), Some("thread:abc"));
2096 assert!(!json);
2097 }
2098 other => panic!("expected project set-active command, got {other:?}"),
2099 }
2100 }
2101
2102 #[test]
2103 fn project_resolve_subcommand_parses() {
2104 let cli = Cli::parse_from([
2105 "batty",
2106 "project",
2107 "resolve",
2108 "check batty",
2109 "--channel",
2110 "telegram",
2111 "--binding",
2112 "chat:123",
2113 "--json",
2114 ]);
2115 match cli.command {
2116 Command::Project {
2117 command:
2118 ProjectCommand::Resolve {
2119 message,
2120 channel,
2121 binding,
2122 thread_binding,
2123 json,
2124 },
2125 } => {
2126 assert_eq!(message, "check batty");
2127 assert_eq!(channel.as_deref(), Some("telegram"));
2128 assert_eq!(binding.as_deref(), Some("chat:123"));
2129 assert!(thread_binding.is_none());
2130 assert!(json);
2131 }
2132 other => panic!("expected project resolve command, got {other:?}"),
2133 }
2134 }
2135
2136 #[test]
2137 fn doctor_subcommand_parses() {
2138 let cli = Cli::parse_from(["batty", "doctor"]);
2139 assert!(matches!(
2140 cli.command,
2141 Command::Doctor {
2142 fix: false,
2143 yes: false
2144 }
2145 ));
2146 }
2147
2148 #[test]
2149 fn doctor_subcommand_parses_fix_flag() {
2150 let cli = Cli::parse_from(["batty", "doctor", "--fix"]);
2151 assert!(matches!(
2152 cli.command,
2153 Command::Doctor {
2154 fix: true,
2155 yes: false
2156 }
2157 ));
2158 }
2159
2160 #[test]
2161 fn doctor_subcommand_parses_fix_yes_flags() {
2162 let cli = Cli::parse_from(["batty", "doctor", "--fix", "--yes"]);
2163 assert!(matches!(
2164 cli.command,
2165 Command::Doctor {
2166 fix: true,
2167 yes: true
2168 }
2169 ));
2170 }
2171
2172 #[test]
2173 fn worktree_health_command_parses() {
2174 let cli = Cli::parse_from(["batty", "worktree", "--health"]);
2175 assert!(matches!(cli.command, Command::Worktree { health: true }));
2176 }
2177
2178 #[test]
2179 fn load_subcommand_parses() {
2180 let cli = Cli::parse_from(["batty", "load"]);
2181 assert!(matches!(cli.command, Command::Load));
2182 }
2183
2184 #[test]
2185 fn parity_subcommand_parses_defaults() {
2186 let cli = Cli::parse_from(["batty", "parity"]);
2187 match cli.command {
2188 Command::Parity { detail, gaps } => {
2189 assert!(!detail);
2190 assert!(!gaps);
2191 }
2192 other => panic!("expected parity command, got {other:?}"),
2193 }
2194 }
2195
2196 #[test]
2197 fn parity_subcommand_parses_gaps_flag() {
2198 let cli = Cli::parse_from(["batty", "parity", "--gaps"]);
2199 match cli.command {
2200 Command::Parity { detail, gaps } => {
2201 assert!(!detail);
2202 assert!(gaps);
2203 }
2204 other => panic!("expected parity command, got {other:?}"),
2205 }
2206 }
2207
2208 #[test]
2209 fn verify_subcommand_parses() {
2210 let cli = Cli::parse_from(["batty", "verify"]);
2211 assert!(matches!(cli.command, Command::Verify));
2212 }
2213
2214 #[test]
2215 fn release_subcommand_parses_defaults() {
2216 let cli = Cli::parse_from(["batty", "release"]);
2217 match cli.command {
2218 Command::Release { tag } => assert!(tag.is_none()),
2219 other => panic!("expected release command, got {other:?}"),
2220 }
2221 }
2222
2223 #[test]
2224 fn release_subcommand_parses_tag_override() {
2225 let cli = Cli::parse_from(["batty", "release", "--tag", "batty-2026-04-10"]);
2226 match cli.command {
2227 Command::Release { tag } => {
2228 assert_eq!(tag.as_deref(), Some("batty-2026-04-10"))
2229 }
2230 other => panic!("expected release command, got {other:?}"),
2231 }
2232 }
2233
2234 #[test]
2235 fn queue_subcommand_parses() {
2236 let cli = Cli::parse_from(["batty", "queue"]);
2237 assert!(matches!(cli.command, Command::Queue));
2238 }
2239
2240 #[test]
2241 fn dispatch_explain_subcommand_parses() {
2242 let cli = Cli::parse_from(["batty", "dispatch", "--explain", "--task", "42"]);
2243 match cli.command {
2244 Command::Dispatch { explain, task } => {
2245 assert!(explain);
2246 assert_eq!(task, Some(42));
2247 }
2248 other => panic!("expected dispatch command, got {other:?}"),
2249 }
2250 }
2251
2252 #[test]
2253 fn cost_subcommand_parses() {
2254 let cli = Cli::parse_from(["batty", "cost"]);
2255 assert!(matches!(cli.command, Command::Cost));
2256 }
2257
2258 #[test]
2259 fn verbose_flag_is_global() {
2260 let cli = Cli::parse_from(["batty", "-vv", "status"]);
2261 assert_eq!(cli.verbose, 2);
2262 }
2263
2264 #[test]
2265 fn task_transition_subcommand_parses() {
2266 let cli = Cli::parse_from(["batty", "task", "transition", "24", "in-progress"]);
2267 match cli.command {
2268 Command::Task {
2269 command:
2270 TaskCommand::Transition {
2271 task_id,
2272 target_state,
2273 },
2274 } => {
2275 assert_eq!(task_id, 24);
2276 assert_eq!(target_state, TaskStateArg::InProgress);
2277 }
2278 other => panic!("expected task transition command, got {other:?}"),
2279 }
2280 }
2281
2282 #[test]
2283 fn task_assign_subcommand_parses() {
2284 let cli = Cli::parse_from([
2285 "batty",
2286 "task",
2287 "assign",
2288 "24",
2289 "--execution-owner",
2290 "eng-1-2",
2291 "--review-owner",
2292 "manager-1",
2293 ]);
2294 match cli.command {
2295 Command::Task {
2296 command:
2297 TaskCommand::Assign {
2298 task_id,
2299 execution_owner,
2300 review_owner,
2301 },
2302 } => {
2303 assert_eq!(task_id, 24);
2304 assert_eq!(execution_owner.as_deref(), Some("eng-1-2"));
2305 assert_eq!(review_owner.as_deref(), Some("manager-1"));
2306 }
2307 other => panic!("expected task assign command, got {other:?}"),
2308 }
2309 }
2310
2311 #[test]
2312 fn task_review_subcommand_parses() {
2313 let cli = Cli::parse_from([
2314 "batty",
2315 "task",
2316 "review",
2317 "24",
2318 "--disposition",
2319 "changes_requested",
2320 ]);
2321 match cli.command {
2322 Command::Task {
2323 command:
2324 TaskCommand::Review {
2325 task_id,
2326 disposition,
2327 feedback,
2328 },
2329 } => {
2330 assert_eq!(task_id, 24);
2331 assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
2332 assert!(feedback.is_none());
2333 }
2334 other => panic!("expected task review command, got {other:?}"),
2335 }
2336 }
2337
2338 #[test]
2339 fn task_update_subcommand_parses() {
2340 let cli = Cli::parse_from([
2341 "batty",
2342 "task",
2343 "update",
2344 "24",
2345 "--branch",
2346 "eng-1-2/task-24",
2347 "--commit",
2348 "abc1234",
2349 "--blocked-on",
2350 "waiting for review",
2351 "--clear-blocked",
2352 ]);
2353 match cli.command {
2354 Command::Task {
2355 command:
2356 TaskCommand::Update {
2357 task_id,
2358 branch,
2359 commit,
2360 blocked_on,
2361 clear_blocked,
2362 },
2363 } => {
2364 assert_eq!(task_id, 24);
2365 assert_eq!(branch.as_deref(), Some("eng-1-2/task-24"));
2366 assert_eq!(commit.as_deref(), Some("abc1234"));
2367 assert_eq!(blocked_on.as_deref(), Some("waiting for review"));
2368 assert!(clear_blocked);
2369 }
2370 other => panic!("expected task update command, got {other:?}"),
2371 }
2372 }
2373
2374 #[test]
2375 fn nudge_disable_parses() {
2376 let cli = Cli::parse_from(["batty", "nudge", "disable", "triage"]);
2377 match cli.command {
2378 Command::Nudge {
2379 command: NudgeCommand::Disable { name },
2380 } => assert_eq!(name, NudgeIntervention::Triage),
2381 other => panic!("expected nudge disable, got {other:?}"),
2382 }
2383 }
2384
2385 #[test]
2386 fn nudge_enable_parses() {
2387 let cli = Cli::parse_from(["batty", "nudge", "enable", "replenish"]);
2388 match cli.command {
2389 Command::Nudge {
2390 command: NudgeCommand::Enable { name },
2391 } => assert_eq!(name, NudgeIntervention::Replenish),
2392 other => panic!("expected nudge enable, got {other:?}"),
2393 }
2394 }
2395
2396 #[test]
2397 fn nudge_status_parses() {
2398 let cli = Cli::parse_from(["batty", "nudge", "status"]);
2399 match cli.command {
2400 Command::Nudge {
2401 command: NudgeCommand::Status,
2402 } => {}
2403 other => panic!("expected nudge status, got {other:?}"),
2404 }
2405 }
2406
2407 #[test]
2408 fn nudge_disable_owned_task_parses() {
2409 let cli = Cli::parse_from(["batty", "nudge", "disable", "owned-task"]);
2410 match cli.command {
2411 Command::Nudge {
2412 command: NudgeCommand::Disable { name },
2413 } => assert_eq!(name, NudgeIntervention::OwnedTask),
2414 other => panic!("expected nudge disable owned-task, got {other:?}"),
2415 }
2416 }
2417
2418 #[test]
2419 fn nudge_rejects_unknown_intervention() {
2420 let result = Cli::try_parse_from(["batty", "nudge", "disable", "unknown"]);
2421 assert!(result.is_err());
2422 }
2423
2424 #[test]
2425 fn nudge_intervention_marker_names() {
2426 assert_eq!(NudgeIntervention::Replenish.marker_name(), "replenish");
2427 assert_eq!(NudgeIntervention::Triage.marker_name(), "triage");
2428 assert_eq!(NudgeIntervention::Review.marker_name(), "review");
2429 assert_eq!(NudgeIntervention::Dispatch.marker_name(), "dispatch");
2430 assert_eq!(NudgeIntervention::Utilization.marker_name(), "utilization");
2431 assert_eq!(NudgeIntervention::OwnedTask.marker_name(), "owned-task");
2432 }
2433
2434 #[test]
2435 fn parse_task_schedule_at() {
2436 let cli = Cli::parse_from([
2437 "batty",
2438 "task",
2439 "schedule",
2440 "50",
2441 "--at",
2442 "2026-03-25T09:00:00-04:00",
2443 ]);
2444 match cli.command {
2445 Command::Task {
2446 command:
2447 TaskCommand::Schedule {
2448 task_id,
2449 at,
2450 cron,
2451 clear,
2452 },
2453 } => {
2454 assert_eq!(task_id, 50);
2455 assert_eq!(at.as_deref(), Some("2026-03-25T09:00:00-04:00"));
2456 assert!(cron.is_none());
2457 assert!(!clear);
2458 }
2459 other => panic!("expected task schedule command, got {other:?}"),
2460 }
2461 }
2462
2463 #[test]
2464 fn parse_task_schedule_cron() {
2465 let cli = Cli::parse_from(["batty", "task", "schedule", "51", "--cron", "0 9 * * *"]);
2466 match cli.command {
2467 Command::Task {
2468 command:
2469 TaskCommand::Schedule {
2470 task_id,
2471 at,
2472 cron,
2473 clear,
2474 },
2475 } => {
2476 assert_eq!(task_id, 51);
2477 assert!(at.is_none());
2478 assert_eq!(cron.as_deref(), Some("0 9 * * *"));
2479 assert!(!clear);
2480 }
2481 other => panic!("expected task schedule command, got {other:?}"),
2482 }
2483 }
2484
2485 #[test]
2486 fn parse_task_schedule_clear() {
2487 let cli = Cli::parse_from(["batty", "task", "schedule", "52", "--clear"]);
2488 match cli.command {
2489 Command::Task {
2490 command:
2491 TaskCommand::Schedule {
2492 task_id,
2493 at,
2494 cron,
2495 clear,
2496 },
2497 } => {
2498 assert_eq!(task_id, 52);
2499 assert!(at.is_none());
2500 assert!(cron.is_none());
2501 assert!(clear);
2502 }
2503 other => panic!("expected task schedule command, got {other:?}"),
2504 }
2505 }
2506
2507 #[test]
2508 fn parse_task_schedule_both() {
2509 let cli = Cli::parse_from([
2510 "batty",
2511 "task",
2512 "schedule",
2513 "53",
2514 "--at",
2515 "2026-04-01T00:00:00Z",
2516 "--cron",
2517 "0 9 * * 1",
2518 ]);
2519 match cli.command {
2520 Command::Task {
2521 command:
2522 TaskCommand::Schedule {
2523 task_id,
2524 at,
2525 cron,
2526 clear,
2527 },
2528 } => {
2529 assert_eq!(task_id, 53);
2530 assert_eq!(at.as_deref(), Some("2026-04-01T00:00:00Z"));
2531 assert_eq!(cron.as_deref(), Some("0 9 * * 1"));
2532 assert!(!clear);
2533 }
2534 other => panic!("expected task schedule command, got {other:?}"),
2535 }
2536 }
2537
2538 #[test]
2539 fn review_approve_parses() {
2540 let cli = Cli::parse_from(["batty", "review", "42", "approve"]);
2541 match cli.command {
2542 Command::Review {
2543 task_id,
2544 disposition,
2545 feedback,
2546 reviewer,
2547 } => {
2548 assert_eq!(task_id, 42);
2549 assert_eq!(disposition, ReviewAction::Approve);
2550 assert!(feedback.is_none());
2551 assert_eq!(reviewer, "human");
2552 }
2553 other => panic!("expected review command, got {other:?}"),
2554 }
2555 }
2556
2557 #[test]
2558 fn review_request_changes_with_feedback_parses() {
2559 let cli = Cli::parse_from([
2560 "batty",
2561 "review",
2562 "99",
2563 "request-changes",
2564 "fix the error handling",
2565 ]);
2566 match cli.command {
2567 Command::Review {
2568 task_id,
2569 disposition,
2570 feedback,
2571 reviewer,
2572 } => {
2573 assert_eq!(task_id, 99);
2574 assert_eq!(disposition, ReviewAction::RequestChanges);
2575 assert_eq!(feedback.as_deref(), Some("fix the error handling"));
2576 assert_eq!(reviewer, "human");
2577 }
2578 other => panic!("expected review command, got {other:?}"),
2579 }
2580 }
2581
2582 #[test]
2583 fn review_reject_with_reviewer_flag_parses() {
2584 let cli = Cli::parse_from([
2585 "batty",
2586 "review",
2587 "7",
2588 "reject",
2589 "does not meet requirements",
2590 "--reviewer",
2591 "manager-1",
2592 ]);
2593 match cli.command {
2594 Command::Review {
2595 task_id,
2596 disposition,
2597 feedback,
2598 reviewer,
2599 } => {
2600 assert_eq!(task_id, 7);
2601 assert_eq!(disposition, ReviewAction::Reject);
2602 assert_eq!(feedback.as_deref(), Some("does not meet requirements"));
2603 assert_eq!(reviewer, "manager-1");
2604 }
2605 other => panic!("expected review command, got {other:?}"),
2606 }
2607 }
2608
2609 #[test]
2610 fn review_rejects_invalid_disposition() {
2611 let result = Cli::try_parse_from(["batty", "review", "42", "maybe"]);
2612 assert!(result.is_err());
2613 }
2614
2615 #[test]
2618 fn send_rejects_missing_role() {
2619 let result = Cli::try_parse_from(["batty", "send"]);
2620 assert!(result.is_err());
2621 }
2622
2623 #[test]
2624 fn send_rejects_missing_message() {
2625 let result = Cli::try_parse_from(["batty", "send", "architect"]);
2626 assert!(result.is_err());
2627 }
2628
2629 #[test]
2632 fn assign_rejects_missing_engineer() {
2633 let result = Cli::try_parse_from(["batty", "assign"]);
2634 assert!(result.is_err());
2635 }
2636
2637 #[test]
2638 fn assign_rejects_missing_task() {
2639 let result = Cli::try_parse_from(["batty", "assign", "eng-1-1"]);
2640 assert!(result.is_err());
2641 }
2642
2643 #[test]
2646 fn review_rejects_missing_task_id() {
2647 let result = Cli::try_parse_from(["batty", "review"]);
2648 assert!(result.is_err());
2649 }
2650
2651 #[test]
2652 fn review_rejects_missing_disposition() {
2653 let result = Cli::try_parse_from(["batty", "review", "42"]);
2654 assert!(result.is_err());
2655 }
2656
2657 #[test]
2660 fn merge_rejects_missing_engineer() {
2661 let result = Cli::try_parse_from(["batty", "merge"]);
2662 assert!(result.is_err());
2663 }
2664
2665 #[test]
2668 fn read_rejects_missing_member() {
2669 let result = Cli::try_parse_from(["batty", "read"]);
2670 assert!(result.is_err());
2671 }
2672
2673 #[test]
2674 fn read_rejects_missing_id() {
2675 let result = Cli::try_parse_from(["batty", "read", "architect"]);
2676 assert!(result.is_err());
2677 }
2678
2679 #[test]
2680 fn ack_rejects_missing_args() {
2681 let result = Cli::try_parse_from(["batty", "ack"]);
2682 assert!(result.is_err());
2683 }
2684
2685 #[test]
2688 fn telemetry_summary_parses() {
2689 let cli = Cli::parse_from(["batty", "telemetry", "summary"]);
2690 match cli.command {
2691 Command::Telemetry {
2692 command: TelemetryCommand::Summary,
2693 } => {}
2694 other => panic!("expected telemetry summary, got {other:?}"),
2695 }
2696 }
2697
2698 #[test]
2699 fn telemetry_agents_parses() {
2700 let cli = Cli::parse_from(["batty", "telemetry", "agents"]);
2701 match cli.command {
2702 Command::Telemetry {
2703 command: TelemetryCommand::Agents,
2704 } => {}
2705 other => panic!("expected telemetry agents, got {other:?}"),
2706 }
2707 }
2708
2709 #[test]
2710 fn telemetry_tasks_parses() {
2711 let cli = Cli::parse_from(["batty", "telemetry", "tasks"]);
2712 match cli.command {
2713 Command::Telemetry {
2714 command: TelemetryCommand::Tasks,
2715 } => {}
2716 other => panic!("expected telemetry tasks, got {other:?}"),
2717 }
2718 }
2719
2720 #[test]
2721 fn telemetry_reviews_parses() {
2722 let cli = Cli::parse_from(["batty", "telemetry", "reviews"]);
2723 match cli.command {
2724 Command::Telemetry {
2725 command: TelemetryCommand::Reviews,
2726 } => {}
2727 other => panic!("expected telemetry reviews, got {other:?}"),
2728 }
2729 }
2730
2731 #[test]
2732 fn telemetry_events_default_limit() {
2733 let cli = Cli::parse_from(["batty", "telemetry", "events"]);
2734 match cli.command {
2735 Command::Telemetry {
2736 command: TelemetryCommand::Events { limit },
2737 } => assert_eq!(limit, 50),
2738 other => panic!("expected telemetry events, got {other:?}"),
2739 }
2740 }
2741
2742 #[test]
2743 fn telemetry_events_custom_limit() {
2744 let cli = Cli::parse_from(["batty", "telemetry", "events", "-n", "10"]);
2745 match cli.command {
2746 Command::Telemetry {
2747 command: TelemetryCommand::Events { limit },
2748 } => assert_eq!(limit, 10),
2749 other => panic!("expected telemetry events with limit, got {other:?}"),
2750 }
2751 }
2752
2753 #[test]
2754 fn telemetry_rejects_missing_subcommand() {
2755 let result = Cli::try_parse_from(["batty", "telemetry"]);
2756 assert!(result.is_err());
2757 }
2758
2759 #[test]
2762 fn grafana_setup_parses() {
2763 let cli = Cli::parse_from(["batty", "grafana", "setup"]);
2764 assert!(matches!(
2765 cli.command,
2766 Command::Grafana {
2767 command: GrafanaCommand::Setup
2768 }
2769 ));
2770 }
2771
2772 #[test]
2773 fn grafana_status_parses() {
2774 let cli = Cli::parse_from(["batty", "grafana", "status"]);
2775 assert!(matches!(
2776 cli.command,
2777 Command::Grafana {
2778 command: GrafanaCommand::Status
2779 }
2780 ));
2781 }
2782
2783 #[test]
2784 fn grafana_open_parses() {
2785 let cli = Cli::parse_from(["batty", "grafana", "open"]);
2786 assert!(matches!(
2787 cli.command,
2788 Command::Grafana {
2789 command: GrafanaCommand::Open
2790 }
2791 ));
2792 }
2793
2794 #[test]
2795 fn grafana_rejects_missing_subcommand() {
2796 let result = Cli::try_parse_from(["batty", "grafana"]);
2797 assert!(result.is_err());
2798 }
2799
2800 #[test]
2803 fn task_auto_merge_enable_parses() {
2804 let cli = Cli::parse_from(["batty", "task", "auto-merge", "30", "enable"]);
2805 match cli.command {
2806 Command::Task {
2807 command: TaskCommand::AutoMerge { task_id, action },
2808 } => {
2809 assert_eq!(task_id, 30);
2810 assert_eq!(action, AutoMergeAction::Enable);
2811 }
2812 other => panic!("expected task auto-merge enable, got {other:?}"),
2813 }
2814 }
2815
2816 #[test]
2817 fn task_auto_merge_disable_parses() {
2818 let cli = Cli::parse_from(["batty", "task", "auto-merge", "31", "disable"]);
2819 match cli.command {
2820 Command::Task {
2821 command: TaskCommand::AutoMerge { task_id, action },
2822 } => {
2823 assert_eq!(task_id, 31);
2824 assert_eq!(action, AutoMergeAction::Disable);
2825 }
2826 other => panic!("expected task auto-merge disable, got {other:?}"),
2827 }
2828 }
2829
2830 #[test]
2831 fn task_auto_merge_rejects_invalid_action() {
2832 let result = Cli::try_parse_from(["batty", "task", "auto-merge", "30", "toggle"]);
2833 assert!(result.is_err());
2834 }
2835
2836 #[test]
2839 fn task_assign_execution_owner_only() {
2840 let cli = Cli::parse_from([
2841 "batty",
2842 "task",
2843 "assign",
2844 "10",
2845 "--execution-owner",
2846 "eng-1-3",
2847 ]);
2848 match cli.command {
2849 Command::Task {
2850 command:
2851 TaskCommand::Assign {
2852 task_id,
2853 execution_owner,
2854 review_owner,
2855 },
2856 } => {
2857 assert_eq!(task_id, 10);
2858 assert_eq!(execution_owner.as_deref(), Some("eng-1-3"));
2859 assert!(review_owner.is_none());
2860 }
2861 other => panic!("expected task assign command, got {other:?}"),
2862 }
2863 }
2864
2865 #[test]
2868 fn task_rejects_missing_subcommand() {
2869 let result = Cli::try_parse_from(["batty", "task"]);
2870 assert!(result.is_err());
2871 }
2872
2873 #[test]
2876 fn doctor_rejects_yes_without_fix() {
2877 let result = Cli::try_parse_from(["batty", "doctor", "--yes"]);
2878 assert!(result.is_err());
2879 }
2880
2881 #[test]
2884 fn daemon_subcommand_parses() {
2885 let cli = Cli::parse_from(["batty", "daemon", "--project-root", "/tmp/project"]);
2886 match cli.command {
2887 Command::Daemon {
2888 project_root,
2889 resume,
2890 } => {
2891 assert_eq!(project_root, "/tmp/project");
2892 assert!(!resume);
2893 }
2894 other => panic!("expected daemon command, got {other:?}"),
2895 }
2896 }
2897
2898 #[test]
2899 fn daemon_subcommand_parses_resume_flag() {
2900 let cli = Cli::parse_from([
2901 "batty",
2902 "daemon",
2903 "--project-root",
2904 "/tmp/project",
2905 "--resume",
2906 ]);
2907 match cli.command {
2908 Command::Daemon {
2909 project_root,
2910 resume,
2911 } => {
2912 assert_eq!(project_root, "/tmp/project");
2913 assert!(resume);
2914 }
2915 other => panic!("expected daemon command with resume, got {other:?}"),
2916 }
2917 }
2918
2919 #[test]
2920 fn watchdog_subcommand_parses() {
2921 let cli = Cli::parse_from(["batty", "watchdog", "--project-root", "/tmp/project"]);
2922 match cli.command {
2923 Command::Watchdog {
2924 project_root,
2925 resume,
2926 } => {
2927 assert_eq!(project_root, "/tmp/project");
2928 assert!(!resume);
2929 }
2930 other => panic!("expected watchdog command, got {other:?}"),
2931 }
2932 }
2933
2934 #[test]
2935 fn watchdog_subcommand_parses_resume_flag() {
2936 let cli = Cli::parse_from([
2937 "batty",
2938 "watchdog",
2939 "--project-root",
2940 "/tmp/project",
2941 "--resume",
2942 ]);
2943 match cli.command {
2944 Command::Watchdog {
2945 project_root,
2946 resume,
2947 } => {
2948 assert_eq!(project_root, "/tmp/project");
2949 assert!(resume);
2950 }
2951 other => panic!("expected watchdog command with resume, got {other:?}"),
2952 }
2953 }
2954
2955 #[test]
2958 fn completions_all_shells_parse() {
2959 for (arg, expected) in [
2960 ("bash", CompletionShell::Bash),
2961 ("zsh", CompletionShell::Zsh),
2962 ("fish", CompletionShell::Fish),
2963 ] {
2964 let cli = Cli::parse_from(["batty", "completions", arg]);
2965 match cli.command {
2966 Command::Completions { shell } => assert_eq!(shell, expected, "shell arg={arg}"),
2967 other => panic!("expected completions command for {arg}, got {other:?}"),
2968 }
2969 }
2970 }
2971
2972 #[test]
2973 fn completions_rejects_unknown_shell() {
2974 let result = Cli::try_parse_from(["batty", "completions", "powershell"]);
2975 assert!(result.is_err());
2976 }
2977
2978 #[test]
2981 fn init_all_template_variants() {
2982 for (arg, expected) in [
2983 ("solo", InitTemplate::Solo),
2984 ("pair", InitTemplate::Pair),
2985 ("simple", InitTemplate::Simple),
2986 ("squad", InitTemplate::Squad),
2987 ("large", InitTemplate::Large),
2988 ("research", InitTemplate::Research),
2989 ("software", InitTemplate::Software),
2990 ("cleanroom", InitTemplate::Cleanroom),
2991 ("batty", InitTemplate::Batty),
2992 ] {
2993 let cli = Cli::parse_from(["batty", "init", "--template", arg]);
2994 match cli.command {
2995 Command::Init { template, from, .. } => {
2996 assert_eq!(template, Some(expected), "template arg={arg}");
2997 assert!(from.is_none());
2998 }
2999 other => panic!("expected init command for template {arg}, got {other:?}"),
3000 }
3001 }
3002 }
3003
3004 #[test]
3007 fn task_review_with_feedback_parses() {
3008 let cli = Cli::parse_from([
3009 "batty",
3010 "task",
3011 "review",
3012 "15",
3013 "--disposition",
3014 "changes_requested",
3015 "--feedback",
3016 "please fix tests",
3017 ]);
3018 match cli.command {
3019 Command::Task {
3020 command:
3021 TaskCommand::Review {
3022 task_id,
3023 disposition,
3024 feedback,
3025 },
3026 } => {
3027 assert_eq!(task_id, 15);
3028 assert_eq!(disposition, ReviewDispositionArg::ChangesRequested);
3029 assert_eq!(feedback.as_deref(), Some("please fix tests"));
3030 }
3031 other => panic!("expected task review command, got {other:?}"),
3032 }
3033 }
3034
3035 #[test]
3038 fn task_transition_all_states() {
3039 for (arg, expected) in [
3040 ("backlog", TaskStateArg::Backlog),
3041 ("todo", TaskStateArg::Todo),
3042 ("in-progress", TaskStateArg::InProgress),
3043 ("review", TaskStateArg::Review),
3044 ("blocked", TaskStateArg::Blocked),
3045 ("done", TaskStateArg::Done),
3046 ("archived", TaskStateArg::Archived),
3047 ] {
3048 let cli = Cli::parse_from(["batty", "task", "transition", "1", arg]);
3049 match cli.command {
3050 Command::Task {
3051 command:
3052 TaskCommand::Transition {
3053 task_id,
3054 target_state,
3055 },
3056 } => {
3057 assert_eq!(task_id, 1);
3058 assert_eq!(target_state, expected, "state arg={arg}");
3059 }
3060 other => panic!("expected task transition for {arg}, got {other:?}"),
3061 }
3062 }
3063 }
3064
3065 #[test]
3066 fn task_transition_rejects_invalid_state() {
3067 let result = Cli::try_parse_from(["batty", "task", "transition", "1", "cancelled"]);
3068 assert!(result.is_err());
3069 }
3070
3071 #[test]
3074 fn rejects_unknown_subcommand() {
3075 let result = Cli::try_parse_from(["batty", "foobar"]);
3076 assert!(result.is_err());
3077 }
3078
3079 #[test]
3082 fn rejects_no_subcommand() {
3083 let result = Cli::try_parse_from(["batty"]);
3084 assert!(result.is_err());
3085 }
3086
3087 #[test]
3090 fn inbox_purge_rejects_missing_role_and_all_roles() {
3091 let result = Cli::try_parse_from(["batty", "inbox", "purge", "--all"]);
3092 assert!(result.is_err());
3093 }
3094
3095 #[test]
3098 fn nudge_enable_all_interventions() {
3099 for (arg, expected) in [
3100 ("replenish", NudgeIntervention::Replenish),
3101 ("triage", NudgeIntervention::Triage),
3102 ("review", NudgeIntervention::Review),
3103 ("dispatch", NudgeIntervention::Dispatch),
3104 ("utilization", NudgeIntervention::Utilization),
3105 ("owned-task", NudgeIntervention::OwnedTask),
3106 ] {
3107 let cli = Cli::parse_from(["batty", "nudge", "enable", arg]);
3108 match cli.command {
3109 Command::Nudge {
3110 command: NudgeCommand::Enable { name },
3111 } => assert_eq!(name, expected, "nudge enable arg={arg}"),
3112 other => panic!("expected nudge enable for {arg}, got {other:?}"),
3113 }
3114 }
3115 }
3116
3117 #[test]
3120 fn config_subcommand_defaults_no_json() {
3121 let cli = Cli::parse_from(["batty", "config"]);
3122 match cli.command {
3123 Command::Config { json } => assert!(!json),
3124 other => panic!("expected config command, got {other:?}"),
3125 }
3126 }
3127
3128 fn generate_completions(shell: clap_complete::Shell) -> String {
3132 use clap::CommandFactory;
3133 let mut buf = Vec::new();
3134 clap_complete::generate(shell, &mut Cli::command(), "batty", &mut buf);
3135 String::from_utf8(buf).expect("completions should be valid UTF-8")
3136 }
3137
3138 #[test]
3139 fn completions_bash_generates() {
3140 let output = generate_completions(clap_complete::Shell::Bash);
3141 assert!(!output.is_empty(), "bash completions should not be empty");
3142 assert!(
3143 output.contains("_batty"),
3144 "bash completions should define _batty function"
3145 );
3146 }
3147
3148 #[test]
3149 fn completions_zsh_generates() {
3150 let output = generate_completions(clap_complete::Shell::Zsh);
3151 assert!(!output.is_empty(), "zsh completions should not be empty");
3152 assert!(
3153 output.contains("#compdef batty"),
3154 "zsh completions should start with #compdef"
3155 );
3156 }
3157
3158 #[test]
3159 fn completions_fish_generates() {
3160 let output = generate_completions(clap_complete::Shell::Fish);
3161 assert!(!output.is_empty(), "fish completions should not be empty");
3162 assert!(
3163 output.contains("complete -c batty"),
3164 "fish completions should contain complete -c batty"
3165 );
3166 }
3167
3168 #[test]
3169 fn completions_include_grafana_subcommands() {
3170 let output = generate_completions(clap_complete::Shell::Fish);
3171 assert!(
3173 output.contains("grafana"),
3174 "completions should include grafana command"
3175 );
3176 assert!(
3178 output.contains("setup"),
3179 "completions should include grafana setup"
3180 );
3181 assert!(
3182 output.contains("status"),
3183 "completions should include grafana status"
3184 );
3185 assert!(
3186 output.contains("open"),
3187 "completions should include grafana open"
3188 );
3189 }
3190
3191 #[test]
3192 fn completions_include_all_recent_commands() {
3193 let output = generate_completions(clap_complete::Shell::Fish);
3194 let expected_commands = [
3195 "task",
3196 "metrics",
3197 "grafana",
3198 "telemetry",
3199 "nudge",
3200 "load",
3201 "queue",
3202 "cost",
3203 "doctor",
3204 "pause",
3205 "resume",
3206 ];
3207 for cmd in &expected_commands {
3208 assert!(
3209 output.contains(cmd),
3210 "completions should include '{cmd}' command"
3211 );
3212 }
3213 }
3214}