1use clap::{Parser, Subcommand, ValueEnum};
2pub use clap_complete::Shell;
3
4#[derive(Debug, Parser)]
6#[command(
7 name = "timebomb",
8 version,
9 about = "Sweep source code for ticking fuses and detonate in CI when deadlines pass",
10 long_about = "timebomb sweeps your source code for structured TODO/FIXME fuses \
11 with expiry dates and fails in CI when deadlines have passed.\n\n\
12 Fuse format: // TODO[2026-06-01]: message\n\
13 With owner: // TODO[2026-06-01][alice]: message"
14)]
15pub struct Cli {
16 #[command(subcommand)]
17 pub command: Command,
18}
19
20#[derive(Debug, Subcommand)]
21pub enum Command {
22 Sweep(SweepArgs),
24
25 Manifest(ManifestArgs),
27
28 Armory(ArmoryArgs),
30
31 Explain(ExplainArgs),
33
34 Plant(PlantArgs),
36
37 Delay(DelayArgs),
39
40 Disarm(DisarmArgs),
42
43 Intel(IntelArgs),
45
46 Tripwire(TripwireArgs),
48
49 Fallout(FalloutArgs),
51
52 Defuse(DefuseArgs),
54
55 Bunker(BunkerArgs),
57
58 Completions(CompletionsArgs),
60}
61
62#[derive(Debug, clap::Args)]
64pub struct SweepArgs {
65 #[arg(default_value = ".")]
67 pub path: String,
68
69 #[arg(long, value_name = "DURATION")]
71 pub fuse: Option<String>,
72
73 #[arg(long, default_value_t = false)]
75 pub fail_on_ticking: bool,
76
77 #[arg(long, value_name = "FORMAT")]
79 pub format: Option<FormatArg>,
80
81 #[arg(long, value_name = "FILE")]
83 pub config: Option<String>,
84
85 #[arg(long, value_name = "REF")]
87 pub since: Option<String>,
88
89 #[arg(long)]
91 pub blame: bool,
92
93 #[arg(long, default_value_t = false)]
95 pub changed: bool,
96
97 #[arg(long, value_name = "REF", requires = "changed")]
99 pub base: Option<String>,
100
101 #[arg(long, value_name = "OWNER")]
103 pub owner: Option<String>,
104
105 #[arg(long, value_name = "TAG")]
107 pub tag: Option<String>,
108
109 #[arg(long, value_name = "TEXT")]
111 pub message: Option<String>,
112
113 #[arg(long, default_value_t = false)]
115 pub quiet: bool,
116
117 #[arg(long, default_value_t = false, conflicts_with = "quiet")]
119 pub summary: bool,
120
121 #[arg(
123 long,
124 default_value_t = false,
125 conflicts_with_all = ["quiet", "summary", "format", "fix_plan"]
126 )]
127 pub agent_summary: bool,
128
129 #[arg(
131 long,
132 value_name = "FORMAT",
133 conflicts_with_all = ["quiet", "summary", "format", "agent_summary", "stats"]
134 )]
135 pub fix_plan: Option<FixPlanArg>,
136
137 #[arg(long, value_name = "N")]
139 pub max_detonated: Option<u32>,
140
141 #[arg(long, value_name = "N")]
143 pub max_ticking: Option<u32>,
144
145 #[arg(long, value_name = "FILE")]
147 pub output: Option<String>,
148
149 #[arg(long, default_value_t = false)]
151 pub no_inert: bool,
152
153 #[arg(long, default_value_t = false)]
155 pub stats: bool,
156}
157
158#[derive(Debug, clap::Args)]
160pub struct ManifestArgs {
161 #[arg(default_value = ".")]
163 pub path: String,
164
165 #[arg(long, default_value_t = false)]
167 pub detonated: bool,
168
169 #[arg(long, value_name = "DURATION", conflicts_with = "detonated")]
171 pub ticking: Option<String>,
172
173 #[arg(long, value_name = "FORMAT")]
175 pub format: Option<FormatArg>,
176
177 #[arg(long, value_name = "DURATION")]
179 pub fuse: Option<String>,
180
181 #[arg(long, value_name = "FILE")]
183 pub config: Option<String>,
184
185 #[arg(long)]
187 pub blame: bool,
188
189 #[arg(long, value_name = "OWNER")]
191 pub owner: Option<String>,
192
193 #[arg(long, value_name = "TAG")]
195 pub tag: Option<String>,
196
197 #[arg(long, value_name = "TEXT")]
199 pub message: Option<String>,
200
201 #[arg(long, value_name = "N")]
203 pub next: Option<usize>,
204
205 #[arg(long, value_name = "BY")]
207 pub sort: Option<SortBy>,
208
209 #[arg(long, value_name = "PATH")]
211 pub file: Vec<String>,
212
213 #[arg(long, num_args = 2, value_names = ["START", "END"])]
215 pub between: Option<Vec<String>>,
216
217 #[arg(long, default_value_t = false, conflicts_with = "path_only")]
219 pub count: bool,
220
221 #[arg(long, default_value_t = false, conflicts_with_all = ["count", "output"])]
223 pub path_only: bool,
224
225 #[arg(long, default_value_t = false)]
227 pub no_inert: bool,
228
229 #[arg(long, default_value_t = false)]
231 pub owner_missing: bool,
232
233 #[arg(long, value_name = "FILE")]
235 pub output: Option<String>,
236}
237
238#[derive(Debug, clap::Args)]
240pub struct ArmoryArgs {
241 #[arg(default_value = ".")]
243 pub path: String,
244
245 #[arg(
247 long,
248 default_value_t = 10,
249 value_name = "N",
250 conflicts_with = "oldest"
251 )]
252 pub limit: usize,
253
254 #[arg(long, default_value_t = false)]
256 pub oldest: bool,
257
258 #[arg(long, default_value_t = false, conflicts_with = "json")]
260 pub count: bool,
261
262 #[arg(long, default_value_t = false)]
264 pub json: bool,
265
266 #[arg(long, value_name = "DURATION")]
268 pub fuse: Option<String>,
269
270 #[arg(long, value_name = "FILE")]
272 pub config: Option<String>,
273
274 #[arg(long)]
276 pub blame: bool,
277
278 #[arg(long, value_name = "OWNER")]
280 pub owner: Option<String>,
281
282 #[arg(long, value_name = "TAG")]
284 pub tag: Option<String>,
285
286 #[arg(long, value_name = "TEXT")]
288 pub message: Option<String>,
289}
290
291#[derive(Debug, clap::Args)]
293pub struct ExplainArgs {
294 #[arg(value_name = "FILE:LINE")]
296 pub target: String,
297
298 #[arg(long, default_value = ".", value_name = "PATH")]
300 pub path: String,
301
302 #[arg(long, value_name = "DURATION")]
304 pub fuse: Option<String>,
305
306 #[arg(long, value_name = "FILE")]
308 pub config: Option<String>,
309
310 #[arg(long)]
312 pub blame: bool,
313}
314
315#[derive(Debug, clap::Args)]
317pub struct PlantArgs {
318 #[arg(value_name = "FILE[:LINE]")]
320 pub target: String,
321
322 #[arg(value_name = "MESSAGE")]
324 pub message: String,
325
326 #[arg(long, value_name = "PATTERN")]
328 pub search: Option<String>,
329
330 #[arg(long, default_value = "TODO", value_name = "TAG")]
332 pub tag: String,
333
334 #[arg(long, value_name = "OWNER")]
336 pub owner: Option<String>,
337
338 #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "in_days")]
340 pub date: Option<String>,
341
342 #[arg(long, value_name = "DAYS", conflicts_with = "date")]
344 pub in_days: Option<u32>,
345
346 #[arg(long, default_value_t = false)]
348 pub yes: bool,
349}
350
351#[derive(Debug, clap::Args)]
353pub struct DelayArgs {
354 #[arg(value_name = "FILE[:LINE]")]
356 pub target: String,
357
358 #[arg(long, value_name = "DATE", conflicts_with = "in_days")]
360 pub date: Option<String>,
361
362 #[arg(long, value_name = "DAYS", conflicts_with = "date")]
364 pub in_days: Option<u32>,
365
366 #[arg(long, value_name = "TEXT")]
368 pub reason: Option<String>,
369
370 #[arg(long, value_name = "PATTERN")]
372 pub search: Option<String>,
373
374 #[arg(long, default_value_t = false)]
376 pub yes: bool,
377}
378
379#[derive(Debug, clap::Args)]
381pub struct DisarmArgs {
382 #[arg(value_name = "FILE[:LINE]")]
385 pub target: Option<String>,
386
387 #[arg(long, value_name = "PATTERN", conflicts_with = "all_detonated")]
389 pub search: Option<String>,
390
391 #[arg(long, conflicts_with = "target")]
393 pub all_detonated: bool,
394
395 #[arg(long, default_value = ".", value_name = "PATH")]
397 pub path: String,
398
399 #[arg(long, value_name = "FILE")]
401 pub config: Option<String>,
402
403 #[arg(long, short, default_value_t = false)]
405 pub yes: bool,
406}
407
408#[derive(Debug, clap::Args)]
410pub struct IntelArgs {
411 #[arg(default_value = ".")]
413 pub path: String,
414
415 #[arg(long, value_name = "DIMENSION")]
417 pub by: Option<GroupBy>,
418
419 #[arg(long, value_name = "FORMAT")]
421 pub format: Option<FormatArg>,
422
423 #[arg(long, value_name = "DURATION")]
425 pub fuse: Option<String>,
426
427 #[arg(long, value_name = "FILE")]
429 pub config: Option<String>,
430
431 #[arg(long, value_name = "OWNER")]
433 pub owner: Option<String>,
434
435 #[arg(long, value_name = "TAG")]
437 pub tag: Option<String>,
438
439 #[arg(long, value_name = "TEXT")]
441 pub message: Option<String>,
442}
443
444#[derive(Debug, clap::Args)]
446pub struct TripwireArgs {
447 #[command(subcommand)]
448 pub command: TripwireCommand,
449}
450
451#[derive(Debug, Subcommand)]
453pub enum TripwireCommand {
454 Set(TripwireSetArgs),
456 Cut(TripwireSetArgs),
458}
459
460#[derive(Debug, clap::Args)]
462pub struct TripwireSetArgs {
463 #[arg(default_value = ".")]
465 pub path: String,
466
467 #[arg(short, long)]
469 pub yes: bool,
470}
471
472#[derive(Debug, clap::Args)]
474pub struct FalloutArgs {
475 pub report_a: String,
477 pub report_b: String,
479 #[arg(long, value_name = "FORMAT")]
481 pub format: Option<FormatArg>,
482}
483
484#[derive(Debug, clap::Args)]
486pub struct DefuseArgs {
487 #[arg(default_value = ".")]
489 pub path: String,
490
491 #[arg(long, value_name = "FILE")]
493 pub config: Option<String>,
494
495 #[arg(long, value_name = "DURATION")]
497 pub fuse: Option<String>,
498}
499
500#[derive(Debug, clap::Args)]
502pub struct BunkerArgs {
503 #[command(subcommand)]
504 pub command: BaselineCommand,
505}
506
507#[derive(Debug, Subcommand)]
509pub enum BaselineCommand {
510 Save(BunkerSaveArgs),
512 Show(BunkerShowArgs),
514}
515
516#[derive(Debug, clap::Args)]
518pub struct BunkerSaveArgs {
519 #[arg(default_value = ".")]
521 pub path: String,
522
523 #[arg(long, value_name = "FILE")]
525 pub config: Option<String>,
526
527 #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
529 pub baseline_file: String,
530
531 #[arg(long, value_name = "DURATION")]
533 pub fuse: Option<String>,
534}
535
536#[derive(Debug, clap::Args)]
538pub struct BunkerShowArgs {
539 #[arg(default_value = ".")]
541 pub path: String,
542
543 #[arg(long, value_name = "FILE")]
545 pub config: Option<String>,
546
547 #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
549 pub baseline_file: String,
550
551 #[arg(long, value_name = "DURATION")]
553 pub fuse: Option<String>,
554}
555
556#[derive(Debug, clap::Args)]
558pub struct CompletionsArgs {
559 pub shell: Shell,
561}
562
563#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
565pub enum SortBy {
566 Date,
568 File,
570 Owner,
572 Status,
574}
575
576#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
578pub enum GroupBy {
579 Owner,
581 Tag,
583 Month,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
589pub enum FixPlanArg {
590 Json,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
596pub enum FormatArg {
597 Terminal,
599 Json,
601 Github,
603 Csv,
605 Table,
607}
608
609impl FormatArg {
610 pub fn to_output_format(&self) -> crate::output::OutputFormat {
612 match self {
613 FormatArg::Terminal => crate::output::OutputFormat::Terminal,
614 FormatArg::Json => crate::output::OutputFormat::Json,
615 FormatArg::Github => crate::output::OutputFormat::GitHub,
616 FormatArg::Csv => crate::output::OutputFormat::Csv,
617 FormatArg::Table => crate::output::OutputFormat::Table,
618 }
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use clap::Parser;
626
627 fn parse(args: &[&str]) -> Cli {
628 Cli::parse_from(args)
629 }
630
631 fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
632 Cli::try_parse_from(args)
633 }
634
635 #[test]
638 fn test_sweep_defaults() {
639 let cli = parse(&["timebomb", "sweep"]);
640 match cli.command {
641 Command::Sweep(args) => {
642 assert_eq!(args.path, ".");
643 assert!(args.fuse.is_none());
644 assert!(!args.fail_on_ticking);
645 assert!(args.format.is_none());
646 assert!(args.config.is_none());
647 assert!(args.since.is_none());
648 assert!(!args.agent_summary);
649 assert!(args.fix_plan.is_none());
650 }
651 _ => panic!("expected Sweep"),
652 }
653 }
654
655 #[test]
656 fn test_sweep_custom_path() {
657 let cli = parse(&["timebomb", "sweep", "./src"]);
658 match cli.command {
659 Command::Sweep(args) => assert_eq!(args.path, "./src"),
660 _ => panic!("expected Sweep"),
661 }
662 }
663
664 #[test]
665 fn test_sweep_fuse_flag() {
666 let cli = parse(&["timebomb", "sweep", "--fuse", "30d"]);
667 match cli.command {
668 Command::Sweep(args) => {
669 assert_eq!(args.fuse, Some("30d".to_string()));
670 }
671 _ => panic!("expected Sweep"),
672 }
673 }
674
675 #[test]
676 fn test_sweep_fail_on_ticking() {
677 let cli = parse(&["timebomb", "sweep", "--fail-on-ticking"]);
678 match cli.command {
679 Command::Sweep(args) => assert!(args.fail_on_ticking),
680 _ => panic!("expected Sweep"),
681 }
682 }
683
684 #[test]
685 fn test_sweep_format_json() {
686 let cli = parse(&["timebomb", "sweep", "--format", "json"]);
687 match cli.command {
688 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Json)),
689 _ => panic!("expected Sweep"),
690 }
691 }
692
693 #[test]
694 fn test_sweep_format_github() {
695 let cli = parse(&["timebomb", "sweep", "--format", "github"]);
696 match cli.command {
697 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Github)),
698 _ => panic!("expected Sweep"),
699 }
700 }
701
702 #[test]
703 fn test_sweep_format_terminal() {
704 let cli = parse(&["timebomb", "sweep", "--format", "terminal"]);
705 match cli.command {
706 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Terminal)),
707 _ => panic!("expected Sweep"),
708 }
709 }
710
711 #[test]
712 fn test_sweep_config_flag() {
713 let cli = parse(&["timebomb", "sweep", "--config", "my.toml"]);
714 match cli.command {
715 Command::Sweep(args) => assert_eq!(args.config, Some("my.toml".to_string())),
716 _ => panic!("expected Sweep"),
717 }
718 }
719
720 #[test]
721 fn test_sweep_all_flags_combined() {
722 let cli = parse(&[
723 "timebomb",
724 "sweep",
725 "./src",
726 "--fuse",
727 "14d",
728 "--fail-on-ticking",
729 "--format",
730 "json",
731 "--config",
732 ".timebomb.toml",
733 ]);
734 match cli.command {
735 Command::Sweep(args) => {
736 assert_eq!(args.path, "./src");
737 assert_eq!(args.fuse, Some("14d".to_string()));
738 assert!(args.fail_on_ticking);
739 assert_eq!(args.format, Some(FormatArg::Json));
740 assert_eq!(args.config, Some(".timebomb.toml".to_string()));
741 }
742 _ => panic!("expected Sweep"),
743 }
744 }
745
746 #[test]
747 fn test_sweep_since_flag() {
748 let cli = parse(&["timebomb", "sweep", "--since", "main"]);
749 match cli.command {
750 Command::Sweep(args) => assert_eq!(args.since, Some("main".to_string())),
751 _ => panic!("expected Sweep"),
752 }
753 }
754
755 #[test]
756 fn test_sweep_since_head() {
757 let cli = parse(&["timebomb", "sweep", "--since", "HEAD"]);
758 match cli.command {
759 Command::Sweep(args) => assert_eq!(args.since, Some("HEAD".to_string())),
760 _ => panic!("expected Sweep"),
761 }
762 }
763
764 #[test]
765 fn test_sweep_owner_flag() {
766 let cli = parse(&["timebomb", "sweep", "--owner", "alice"]);
767 match cli.command {
768 Command::Sweep(args) => assert_eq!(args.owner, Some("alice".to_string())),
769 _ => panic!("expected Sweep"),
770 }
771 }
772
773 #[test]
774 fn test_manifest_owner_flag() {
775 let cli = parse(&["timebomb", "manifest", "--owner", "bob"]);
776 match cli.command {
777 Command::Manifest(args) => assert_eq!(args.owner, Some("bob".to_string())),
778 _ => panic!("expected Manifest"),
779 }
780 }
781
782 #[test]
783 fn test_sweep_tag_flag() {
784 let cli = parse(&["timebomb", "sweep", "--tag", "FIXME"]);
785 match cli.command {
786 Command::Sweep(args) => assert_eq!(args.tag, Some("FIXME".to_string())),
787 _ => panic!("expected Sweep"),
788 }
789 }
790
791 #[test]
792 fn test_sweep_message_flag() {
793 let cli = parse(&["timebomb", "sweep", "--message", "oauth"]);
794 match cli.command {
795 Command::Sweep(args) => assert_eq!(args.message, Some("oauth".to_string())),
796 _ => panic!("expected Sweep"),
797 }
798 }
799
800 #[test]
801 fn test_sweep_quiet_flag() {
802 let cli = parse(&["timebomb", "sweep", "--quiet"]);
803 match cli.command {
804 Command::Sweep(args) => assert!(args.quiet),
805 _ => panic!("expected Sweep"),
806 }
807 }
808
809 #[test]
810 fn test_sweep_quiet_default_false() {
811 let cli = parse(&["timebomb", "sweep"]);
812 match cli.command {
813 Command::Sweep(args) => assert!(!args.quiet),
814 _ => panic!("expected Sweep"),
815 }
816 }
817
818 #[test]
819 fn test_manifest_tag_flag() {
820 let cli = parse(&["timebomb", "manifest", "--tag", "TODO"]);
821 match cli.command {
822 Command::Manifest(args) => assert_eq!(args.tag, Some("TODO".to_string())),
823 _ => panic!("expected Manifest"),
824 }
825 }
826
827 #[test]
828 fn test_manifest_message_flag() {
829 let cli = parse(&["timebomb", "manifest", "--message", "migration"]);
830 match cli.command {
831 Command::Manifest(args) => assert_eq!(args.message, Some("migration".to_string())),
832 _ => panic!("expected Manifest"),
833 }
834 }
835
836 #[test]
837 fn test_manifest_next_flag() {
838 let cli = parse(&["timebomb", "manifest", "--next", "5"]);
839 match cli.command {
840 Command::Manifest(args) => assert_eq!(args.next, Some(5)),
841 _ => panic!("expected Manifest"),
842 }
843 }
844
845 #[test]
846 fn test_manifest_next_default_none() {
847 let cli = parse(&["timebomb", "manifest"]);
848 match cli.command {
849 Command::Manifest(args) => assert!(args.next.is_none()),
850 _ => panic!("expected Manifest"),
851 }
852 }
853
854 #[test]
855 fn test_armory_defaults() {
856 let cli = parse(&["timebomb", "armory"]);
857 match cli.command {
858 Command::Armory(args) => {
859 assert_eq!(args.path, ".");
860 assert_eq!(args.limit, 10);
861 assert!(!args.oldest);
862 assert!(!args.count);
863 assert!(!args.json);
864 assert!(args.fuse.is_none());
865 assert!(args.config.is_none());
866 assert!(!args.blame);
867 assert!(args.owner.is_none());
868 assert!(args.tag.is_none());
869 assert!(args.message.is_none());
870 }
871 _ => panic!("expected Armory"),
872 }
873 }
874
875 #[test]
876 fn test_armory_all_flags() {
877 let cli = parse(&[
878 "timebomb",
879 "armory",
880 "./src",
881 "--limit",
882 "5",
883 "--fuse",
884 "14d",
885 "--config",
886 ".timebomb.toml",
887 "--blame",
888 "--owner",
889 "alice",
890 "--tag",
891 "FIXME",
892 "--message",
893 "migration",
894 ]);
895 match cli.command {
896 Command::Armory(args) => {
897 assert_eq!(args.path, "./src");
898 assert_eq!(args.limit, 5);
899 assert!(!args.oldest);
900 assert!(!args.count);
901 assert!(!args.json);
902 assert_eq!(args.fuse, Some("14d".to_string()));
903 assert_eq!(args.config, Some(".timebomb.toml".to_string()));
904 assert!(args.blame);
905 assert_eq!(args.owner, Some("alice".to_string()));
906 assert_eq!(args.tag, Some("FIXME".to_string()));
907 assert_eq!(args.message, Some("migration".to_string()));
908 }
909 _ => panic!("expected Armory"),
910 }
911 }
912
913 #[test]
914 fn test_armory_oldest_flag() {
915 let cli = parse(&["timebomb", "armory", "--oldest"]);
916 match cli.command {
917 Command::Armory(args) => assert!(args.oldest),
918 _ => panic!("expected Armory"),
919 }
920 }
921
922 #[test]
923 fn test_armory_count_flag() {
924 let cli = parse(&["timebomb", "armory", "--count"]);
925 match cli.command {
926 Command::Armory(args) => assert!(args.count),
927 _ => panic!("expected Armory"),
928 }
929 }
930
931 #[test]
932 fn test_armory_json_flag() {
933 let cli = parse(&["timebomb", "armory", "--json"]);
934 match cli.command {
935 Command::Armory(args) => assert!(args.json),
936 _ => panic!("expected Armory"),
937 }
938 }
939
940 #[test]
941 fn test_armory_count_conflicts_with_json() {
942 let result = try_parse(&["timebomb", "armory", "--count", "--json"]);
943 assert!(result.is_err(), "--count and --json should conflict");
944 }
945
946 #[test]
947 fn test_armory_oldest_conflicts_with_limit() {
948 let result = try_parse(&["timebomb", "armory", "--oldest", "--limit", "5"]);
949 assert!(result.is_err(), "--oldest and --limit should conflict");
950 }
951
952 #[test]
953 fn test_explain_defaults() {
954 let cli = parse(&["timebomb", "explain", "src/main.rs:42"]);
955 match cli.command {
956 Command::Explain(args) => {
957 assert_eq!(args.target, "src/main.rs:42");
958 assert_eq!(args.path, ".");
959 assert!(args.fuse.is_none());
960 assert!(args.config.is_none());
961 assert!(!args.blame);
962 }
963 _ => panic!("expected Explain"),
964 }
965 }
966
967 #[test]
968 fn test_explain_all_flags() {
969 let cli = parse(&[
970 "timebomb",
971 "explain",
972 "src/main.rs:42",
973 "--path",
974 "./src",
975 "--fuse",
976 "14d",
977 "--config",
978 ".timebomb.toml",
979 "--blame",
980 ]);
981 match cli.command {
982 Command::Explain(args) => {
983 assert_eq!(args.target, "src/main.rs:42");
984 assert_eq!(args.path, "./src");
985 assert_eq!(args.fuse, Some("14d".to_string()));
986 assert_eq!(args.config, Some(".timebomb.toml".to_string()));
987 assert!(args.blame);
988 }
989 _ => panic!("expected Explain"),
990 }
991 }
992
993 #[test]
994 fn test_sweep_summary_flag() {
995 let cli = parse(&["timebomb", "sweep", "--summary"]);
996 match cli.command {
997 Command::Sweep(args) => assert!(args.summary),
998 _ => panic!("expected Sweep"),
999 }
1000 }
1001
1002 #[test]
1003 fn test_sweep_summary_and_quiet_conflict() {
1004 let result = try_parse(&["timebomb", "sweep", "--summary", "--quiet"]);
1005 assert!(result.is_err(), "--summary and --quiet should conflict");
1006 }
1007
1008 #[test]
1009 fn test_sweep_agent_summary_flag() {
1010 let cli = parse(&["timebomb", "sweep", "--agent-summary"]);
1011 match cli.command {
1012 Command::Sweep(args) => assert!(args.agent_summary),
1013 _ => panic!("expected Sweep"),
1014 }
1015 }
1016
1017 #[test]
1018 fn test_sweep_agent_summary_conflicts_with_format() {
1019 let result = try_parse(&["timebomb", "sweep", "--agent-summary", "--format", "json"]);
1020 assert!(
1021 result.is_err(),
1022 "--agent-summary and --format should conflict"
1023 );
1024 }
1025
1026 #[test]
1027 fn test_sweep_fix_plan_json_flag() {
1028 let cli = parse(&["timebomb", "sweep", "--fix-plan", "json"]);
1029 match cli.command {
1030 Command::Sweep(args) => assert_eq!(args.fix_plan, Some(FixPlanArg::Json)),
1031 _ => panic!("expected Sweep"),
1032 }
1033 }
1034
1035 #[test]
1036 fn test_sweep_fix_plan_conflicts_with_agent_summary() {
1037 let result = try_parse(&["timebomb", "sweep", "--fix-plan", "json", "--agent-summary"]);
1038 assert!(
1039 result.is_err(),
1040 "--fix-plan and --agent-summary should conflict"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_sweep_max_detonated_flag() {
1046 let cli = parse(&["timebomb", "sweep", "--max-detonated", "0"]);
1047 match cli.command {
1048 Command::Sweep(args) => assert_eq!(args.max_detonated, Some(0)),
1049 _ => panic!("expected Sweep"),
1050 }
1051 }
1052
1053 #[test]
1054 fn test_sweep_max_ticking_flag() {
1055 let cli = parse(&["timebomb", "sweep", "--max-ticking", "5"]);
1056 match cli.command {
1057 Command::Sweep(args) => assert_eq!(args.max_ticking, Some(5)),
1058 _ => panic!("expected Sweep"),
1059 }
1060 }
1061
1062 #[test]
1063 fn test_sweep_max_flags_default_none() {
1064 let cli = parse(&["timebomb", "sweep"]);
1065 match cli.command {
1066 Command::Sweep(args) => {
1067 assert!(args.max_detonated.is_none());
1068 assert!(args.max_ticking.is_none());
1069 }
1070 _ => panic!("expected Sweep"),
1071 }
1072 }
1073
1074 #[test]
1075 fn test_manifest_sort_date() {
1076 let cli = parse(&["timebomb", "manifest", "--sort", "date"]);
1077 match cli.command {
1078 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Date)),
1079 _ => panic!("expected Manifest"),
1080 }
1081 }
1082
1083 #[test]
1084 fn test_manifest_sort_file() {
1085 let cli = parse(&["timebomb", "manifest", "--sort", "file"]);
1086 match cli.command {
1087 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::File)),
1088 _ => panic!("expected Manifest"),
1089 }
1090 }
1091
1092 #[test]
1093 fn test_manifest_sort_owner() {
1094 let cli = parse(&["timebomb", "manifest", "--sort", "owner"]);
1095 match cli.command {
1096 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Owner)),
1097 _ => panic!("expected Manifest"),
1098 }
1099 }
1100
1101 #[test]
1102 fn test_manifest_sort_status() {
1103 let cli = parse(&["timebomb", "manifest", "--sort", "status"]);
1104 match cli.command {
1105 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Status)),
1106 _ => panic!("expected Manifest"),
1107 }
1108 }
1109
1110 #[test]
1111 fn test_manifest_sort_default_none() {
1112 let cli = parse(&["timebomb", "manifest"]);
1113 match cli.command {
1114 Command::Manifest(args) => assert!(args.sort.is_none()),
1115 _ => panic!("expected Manifest"),
1116 }
1117 }
1118
1119 #[test]
1120 fn test_sweep_output_flag() {
1121 let cli = parse(&["timebomb", "sweep", "--output", "report.json"]);
1122 match cli.command {
1123 Command::Sweep(args) => assert_eq!(args.output, Some("report.json".to_string())),
1124 _ => panic!("expected Sweep"),
1125 }
1126 }
1127
1128 #[test]
1129 fn test_sweep_output_default_none() {
1130 let cli = parse(&["timebomb", "sweep"]);
1131 match cli.command {
1132 Command::Sweep(args) => assert!(args.output.is_none()),
1133 _ => panic!("expected Sweep"),
1134 }
1135 }
1136
1137 #[test]
1138 fn test_manifest_file_single() {
1139 let cli = parse(&["timebomb", "manifest", "--file", "src/auth/login.rs"]);
1140 match cli.command {
1141 Command::Manifest(args) => {
1142 assert_eq!(args.file, vec!["src/auth/login.rs".to_string()])
1143 }
1144 _ => panic!("expected Manifest"),
1145 }
1146 }
1147
1148 #[test]
1149 fn test_manifest_file_multiple() {
1150 let cli = parse(&[
1151 "timebomb",
1152 "manifest",
1153 "--file",
1154 "src/auth/login.rs",
1155 "--file",
1156 "src/db/schema.sql",
1157 ]);
1158 match cli.command {
1159 Command::Manifest(args) => {
1160 assert_eq!(
1161 args.file,
1162 vec![
1163 "src/auth/login.rs".to_string(),
1164 "src/db/schema.sql".to_string(),
1165 ]
1166 )
1167 }
1168 _ => panic!("expected Manifest"),
1169 }
1170 }
1171
1172 #[test]
1173 fn test_manifest_file_default_empty() {
1174 let cli = parse(&["timebomb", "manifest"]);
1175 match cli.command {
1176 Command::Manifest(args) => assert!(args.file.is_empty()),
1177 _ => panic!("expected Manifest"),
1178 }
1179 }
1180
1181 #[test]
1182 fn test_manifest_between_flag() {
1183 let cli = parse(&[
1184 "timebomb",
1185 "manifest",
1186 "--between",
1187 "2026-01-01",
1188 "2026-06-30",
1189 ]);
1190 match cli.command {
1191 Command::Manifest(args) => {
1192 let dates = args.between.unwrap();
1193 assert_eq!(dates[0], "2026-01-01");
1194 assert_eq!(dates[1], "2026-06-30");
1195 }
1196 _ => panic!("expected Manifest"),
1197 }
1198 }
1199
1200 #[test]
1201 fn test_manifest_between_default_none() {
1202 let cli = parse(&["timebomb", "manifest"]);
1203 match cli.command {
1204 Command::Manifest(args) => assert!(args.between.is_none()),
1205 _ => panic!("expected Manifest"),
1206 }
1207 }
1208
1209 #[test]
1210 fn test_manifest_count_flag() {
1211 let cli = parse(&["timebomb", "manifest", "--count"]);
1212 match cli.command {
1213 Command::Manifest(args) => assert!(args.count),
1214 _ => panic!("expected Manifest"),
1215 }
1216 }
1217
1218 #[test]
1219 fn test_manifest_count_default_false() {
1220 let cli = parse(&["timebomb", "manifest"]);
1221 match cli.command {
1222 Command::Manifest(args) => assert!(!args.count),
1223 _ => panic!("expected Manifest"),
1224 }
1225 }
1226
1227 #[test]
1228 fn test_manifest_path_only_flag() {
1229 let cli = parse(&["timebomb", "manifest", "--path-only"]);
1230 match cli.command {
1231 Command::Manifest(args) => assert!(args.path_only),
1232 _ => panic!("expected Manifest"),
1233 }
1234 }
1235
1236 #[test]
1237 fn test_manifest_path_only_conflicts_with_count() {
1238 let result = try_parse(&["timebomb", "manifest", "--path-only", "--count"]);
1239 assert!(result.is_err(), "--path-only and --count should conflict");
1240 }
1241
1242 #[test]
1243 fn test_manifest_path_only_conflicts_with_output() {
1244 let result = try_parse(&[
1245 "timebomb",
1246 "manifest",
1247 "--path-only",
1248 "--output",
1249 "fuses.json",
1250 ]);
1251 assert!(result.is_err(), "--path-only and --output should conflict");
1252 }
1253
1254 #[test]
1257 fn test_plant_message_positional() {
1258 let cli = parse(&[
1260 "timebomb",
1261 "plant",
1262 "src/main.rs:42",
1263 "--in-days",
1264 "90",
1265 "the message",
1266 ]);
1267 match cli.command {
1268 Command::Plant(args) => {
1269 assert_eq!(args.target, "src/main.rs:42");
1270 assert_eq!(args.message, "the message");
1271 assert_eq!(args.in_days, Some(90));
1272 }
1273 _ => panic!("expected Plant"),
1274 }
1275 }
1276
1277 #[test]
1278 fn test_plant_with_search() {
1279 let cli = parse(&[
1280 "timebomb",
1281 "plant",
1282 "src/foo.rs",
1283 "--search",
1284 "legacy_auth",
1285 "--in-days",
1286 "30",
1287 "msg",
1288 ]);
1289 match cli.command {
1290 Command::Plant(args) => {
1291 assert_eq!(args.target, "src/foo.rs");
1292 assert_eq!(args.search, Some("legacy_auth".to_string()));
1293 assert_eq!(args.in_days, Some(30));
1294 assert_eq!(args.message, "msg");
1295 }
1296 _ => panic!("expected Plant"),
1297 }
1298 }
1299
1300 #[test]
1301 fn test_plant_defaults() {
1302 let cli = parse(&["timebomb", "plant", "src/main.rs:42", "remove this"]);
1303 match cli.command {
1304 Command::Plant(args) => {
1305 assert_eq!(args.target, "src/main.rs:42");
1306 assert_eq!(args.message, "remove this");
1307 assert_eq!(args.tag, "TODO");
1308 assert!(args.owner.is_none());
1309 assert!(args.date.is_none());
1310 assert!(args.in_days.is_none());
1311 assert!(!args.yes);
1312 assert!(args.search.is_none());
1313 }
1314 _ => panic!("expected Plant"),
1315 }
1316 }
1317
1318 #[test]
1319 fn test_plant_all_flags() {
1320 let cli = parse(&[
1321 "timebomb",
1322 "plant",
1323 "src/auth.rs:10",
1324 "remove oauth flow",
1325 "--tag",
1326 "FIXME",
1327 "--owner",
1328 "alice",
1329 "--date",
1330 "2026-09-01",
1331 "--yes",
1332 ]);
1333 match cli.command {
1334 Command::Plant(args) => {
1335 assert_eq!(args.target, "src/auth.rs:10");
1336 assert_eq!(args.message, "remove oauth flow");
1337 assert_eq!(args.tag, "FIXME");
1338 assert_eq!(args.owner, Some("alice".to_string()));
1339 assert_eq!(args.date, Some("2026-09-01".to_string()));
1340 assert!(args.yes);
1341 }
1342 _ => panic!("expected Plant"),
1343 }
1344 }
1345
1346 #[test]
1347 fn test_plant_in_days() {
1348 let cli = parse(&[
1349 "timebomb",
1350 "plant",
1351 "src/lib.rs:1",
1352 "cleanup",
1353 "--in-days",
1354 "90",
1355 ]);
1356 match cli.command {
1357 Command::Plant(args) => assert_eq!(args.in_days, Some(90)),
1358 _ => panic!("expected Plant"),
1359 }
1360 }
1361
1362 #[test]
1363 fn test_plant_date_and_in_days_conflict() {
1364 let result = try_parse(&[
1365 "timebomb",
1366 "plant",
1367 "src/lib.rs:1",
1368 "cleanup",
1369 "--date",
1370 "2026-01-01",
1371 "--in-days",
1372 "30",
1373 ]);
1374 assert!(result.is_err(), "--date and --in-days should conflict");
1375 }
1376
1377 #[test]
1380 fn test_delay_defaults() {
1381 let cli = parse(&["timebomb", "delay", "src/main.rs:42", "--in-days", "30"]);
1382 match cli.command {
1383 Command::Delay(args) => {
1384 assert_eq!(args.target, "src/main.rs:42");
1385 assert_eq!(args.in_days, Some(30));
1386 assert!(args.date.is_none());
1387 assert!(args.reason.is_none());
1388 assert!(args.search.is_none());
1389 assert!(!args.yes);
1390 }
1391 _ => panic!("expected Delay"),
1392 }
1393 }
1394
1395 #[test]
1396 fn test_delay_with_search() {
1397 let cli = parse(&[
1398 "timebomb",
1399 "delay",
1400 "src/main.rs",
1401 "--search",
1402 "pattern",
1403 "--in-days",
1404 "30",
1405 ]);
1406 match cli.command {
1407 Command::Delay(args) => {
1408 assert_eq!(args.target, "src/main.rs");
1409 assert_eq!(args.search, Some("pattern".to_string()));
1410 assert_eq!(args.in_days, Some(30));
1411 }
1412 _ => panic!("expected Delay"),
1413 }
1414 }
1415
1416 #[test]
1417 fn test_delay_with_date() {
1418 let cli = parse(&[
1419 "timebomb",
1420 "delay",
1421 "src/main.rs:42",
1422 "--date",
1423 "2027-01-01",
1424 ]);
1425 match cli.command {
1426 Command::Delay(args) => {
1427 assert_eq!(args.date, Some("2027-01-01".to_string()));
1428 assert!(args.in_days.is_none());
1429 }
1430 _ => panic!("expected Delay"),
1431 }
1432 }
1433
1434 #[test]
1435 fn test_delay_with_reason() {
1436 let cli = parse(&[
1437 "timebomb",
1438 "delay",
1439 "src/main.rs:42",
1440 "--in-days",
1441 "30",
1442 "--reason",
1443 "blocked upstream",
1444 ]);
1445 match cli.command {
1446 Command::Delay(args) => {
1447 assert_eq!(args.reason, Some("blocked upstream".to_string()));
1448 }
1449 _ => panic!("expected Delay"),
1450 }
1451 }
1452
1453 #[test]
1456 fn test_disarm_by_target() {
1457 let cli = parse(&["timebomb", "disarm", "src/main.rs:42"]);
1458 match cli.command {
1459 Command::Disarm(args) => {
1460 assert_eq!(args.target, Some("src/main.rs:42".to_string()));
1461 assert!(args.search.is_none());
1462 assert!(!args.all_detonated);
1463 }
1464 _ => panic!("expected Disarm"),
1465 }
1466 }
1467
1468 #[test]
1469 fn test_disarm_with_search() {
1470 let cli = parse(&["timebomb", "disarm", "src/main.rs", "--search", "pattern"]);
1471 match cli.command {
1472 Command::Disarm(args) => {
1473 assert_eq!(args.target, Some("src/main.rs".to_string()));
1474 assert_eq!(args.search, Some("pattern".to_string()));
1475 }
1476 _ => panic!("expected Disarm"),
1477 }
1478 }
1479
1480 #[test]
1481 fn test_disarm_all_detonated() {
1482 let cli = parse(&["timebomb", "disarm", "--all-detonated", "--path", "./src"]);
1483 match cli.command {
1484 Command::Disarm(args) => {
1485 assert!(args.all_detonated);
1486 assert_eq!(args.path, "./src");
1487 assert!(args.target.is_none());
1488 }
1489 _ => panic!("expected Disarm"),
1490 }
1491 }
1492
1493 #[test]
1494 fn test_disarm_all_detonated_default_path() {
1495 let cli = parse(&["timebomb", "disarm", "--all-detonated"]);
1496 match cli.command {
1497 Command::Disarm(args) => {
1498 assert!(args.all_detonated);
1499 assert_eq!(args.path, ".");
1500 }
1501 _ => panic!("expected Disarm"),
1502 }
1503 }
1504
1505 #[test]
1506 fn test_disarm_yes_flag() {
1507 let cli = parse(&["timebomb", "disarm", "src/main.rs:42", "--yes"]);
1508 match cli.command {
1509 Command::Disarm(args) => assert!(args.yes),
1510 _ => panic!("expected Disarm"),
1511 }
1512 }
1513
1514 #[test]
1517 fn test_intel_defaults() {
1518 let cli = parse(&["timebomb", "intel"]);
1519 match cli.command {
1520 Command::Intel(args) => {
1521 assert_eq!(args.path, ".");
1522 assert!(args.by.is_none());
1523 assert!(args.format.is_none());
1524 assert!(args.fuse.is_none());
1525 assert!(args.config.is_none());
1526 assert!(args.message.is_none());
1527 }
1528 _ => panic!("expected Intel"),
1529 }
1530 }
1531
1532 #[test]
1533 fn test_intel_by_owner() {
1534 let cli = parse(&["timebomb", "intel", "--by", "owner"]);
1535 match cli.command {
1536 Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Owner)),
1537 _ => panic!("expected Intel"),
1538 }
1539 }
1540
1541 #[test]
1542 fn test_intel_by_tag() {
1543 let cli = parse(&["timebomb", "intel", "--by", "tag"]);
1544 match cli.command {
1545 Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Tag)),
1546 _ => panic!("expected Intel"),
1547 }
1548 }
1549
1550 #[test]
1551 fn test_intel_all_flags() {
1552 let cli = parse(&[
1553 "timebomb",
1554 "intel",
1555 "./src",
1556 "--by",
1557 "owner",
1558 "--format",
1559 "json",
1560 "--fuse",
1561 "14d",
1562 "--config",
1563 "custom.toml",
1564 "--message",
1565 "cleanup",
1566 ]);
1567 match cli.command {
1568 Command::Intel(args) => {
1569 assert_eq!(args.path, "./src");
1570 assert_eq!(args.by, Some(GroupBy::Owner));
1571 assert_eq!(args.format, Some(FormatArg::Json));
1572 assert_eq!(args.fuse, Some("14d".to_string()));
1573 assert_eq!(args.config, Some("custom.toml".to_string()));
1574 assert_eq!(args.message, Some("cleanup".to_string()));
1575 }
1576 _ => panic!("expected Intel"),
1577 }
1578 }
1579
1580 #[test]
1583 fn test_manifest_defaults() {
1584 let cli = parse(&["timebomb", "manifest"]);
1585 match cli.command {
1586 Command::Manifest(args) => {
1587 assert_eq!(args.path, ".");
1588 assert!(!args.detonated);
1589 assert!(args.ticking.is_none());
1590 assert!(args.format.is_none());
1591 assert!(args.fuse.is_none());
1592 assert!(args.config.is_none());
1593 assert!(args.message.is_none());
1594 }
1595 _ => panic!("expected Manifest"),
1596 }
1597 }
1598
1599 #[test]
1600 fn test_manifest_detonated_flag() {
1601 let cli = parse(&["timebomb", "manifest", "--detonated"]);
1602 match cli.command {
1603 Command::Manifest(args) => assert!(args.detonated),
1604 _ => panic!("expected Manifest"),
1605 }
1606 }
1607
1608 #[test]
1609 fn test_manifest_ticking() {
1610 let cli = parse(&["timebomb", "manifest", "--ticking", "14d"]);
1611 match cli.command {
1612 Command::Manifest(args) => {
1613 assert_eq!(args.ticking, Some("14d".to_string()));
1614 assert!(!args.detonated);
1615 }
1616 _ => panic!("expected Manifest"),
1617 }
1618 }
1619
1620 #[test]
1621 fn test_manifest_detonated_and_ticking_conflict() {
1622 let result = try_parse(&["timebomb", "manifest", "--detonated", "--ticking", "14d"]);
1624 assert!(result.is_err(), "conflicting flags should produce an error");
1625 }
1626
1627 #[test]
1628 fn test_manifest_format_json() {
1629 let cli = parse(&["timebomb", "manifest", "--format", "json"]);
1630 match cli.command {
1631 Command::Manifest(args) => assert_eq!(args.format, Some(FormatArg::Json)),
1632 _ => panic!("expected Manifest"),
1633 }
1634 }
1635
1636 #[test]
1637 fn test_manifest_fuse_flag() {
1638 let cli = parse(&["timebomb", "manifest", "--fuse", "7d"]);
1639 match cli.command {
1640 Command::Manifest(args) => assert_eq!(args.fuse, Some("7d".to_string())),
1641 _ => panic!("expected Manifest"),
1642 }
1643 }
1644
1645 #[test]
1646 fn test_manifest_custom_path() {
1647 let cli = parse(&["timebomb", "manifest", "./my/project"]);
1648 match cli.command {
1649 Command::Manifest(args) => assert_eq!(args.path, "./my/project"),
1650 _ => panic!("expected Manifest"),
1651 }
1652 }
1653
1654 #[test]
1655 fn test_manifest_all_flags_combined() {
1656 let cli = parse(&[
1657 "timebomb",
1658 "manifest",
1659 "./src",
1660 "--detonated",
1661 "--format",
1662 "github",
1663 "--fuse",
1664 "30d",
1665 "--config",
1666 "custom.toml",
1667 "--message",
1668 "migration",
1669 ]);
1670 match cli.command {
1671 Command::Manifest(args) => {
1672 assert_eq!(args.path, "./src");
1673 assert!(args.detonated);
1674 assert_eq!(args.format, Some(FormatArg::Github));
1675 assert_eq!(args.fuse, Some("30d".to_string()));
1676 assert_eq!(args.config, Some("custom.toml".to_string()));
1677 assert_eq!(args.message, Some("migration".to_string()));
1678 }
1679 _ => panic!("expected Manifest"),
1680 }
1681 }
1682
1683 #[test]
1686 fn test_format_arg_to_output_format_terminal() {
1687 assert_eq!(
1688 FormatArg::Terminal.to_output_format(),
1689 crate::output::OutputFormat::Terminal
1690 );
1691 }
1692
1693 #[test]
1694 fn test_format_arg_to_output_format_json() {
1695 assert_eq!(
1696 FormatArg::Json.to_output_format(),
1697 crate::output::OutputFormat::Json
1698 );
1699 }
1700
1701 #[test]
1702 fn test_format_arg_to_output_format_github() {
1703 assert_eq!(
1704 FormatArg::Github.to_output_format(),
1705 crate::output::OutputFormat::GitHub
1706 );
1707 }
1708
1709 #[test]
1712 fn test_unknown_subcommand_is_error() {
1713 let result = try_parse(&["timebomb", "run"]);
1714 assert!(result.is_err());
1715 }
1716
1717 #[test]
1718 fn test_no_subcommand_is_error() {
1719 let result = try_parse(&["timebomb"]);
1720 assert!(result.is_err());
1721 }
1722}