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 Plant(PlantArgs),
33
34 Delay(DelayArgs),
36
37 Disarm(DisarmArgs),
39
40 Intel(IntelArgs),
42
43 Tripwire(TripwireArgs),
45
46 Fallout(FalloutArgs),
48
49 Defuse(DefuseArgs),
51
52 Bunker(BunkerArgs),
54
55 Completions(CompletionsArgs),
57}
58
59#[derive(Debug, clap::Args)]
61pub struct SweepArgs {
62 #[arg(default_value = ".")]
64 pub path: String,
65
66 #[arg(long, value_name = "DURATION")]
68 pub fuse: Option<String>,
69
70 #[arg(long, default_value_t = false)]
72 pub fail_on_ticking: bool,
73
74 #[arg(long, value_name = "FORMAT")]
76 pub format: Option<FormatArg>,
77
78 #[arg(long, value_name = "FILE")]
80 pub config: Option<String>,
81
82 #[arg(long, value_name = "REF")]
84 pub since: Option<String>,
85
86 #[arg(long)]
88 pub blame: bool,
89
90 #[arg(long, default_value_t = false)]
92 pub changed: bool,
93
94 #[arg(long, value_name = "REF", requires = "changed")]
96 pub base: Option<String>,
97
98 #[arg(long, value_name = "OWNER")]
100 pub owner: Option<String>,
101
102 #[arg(long, value_name = "TAG")]
104 pub tag: Option<String>,
105
106 #[arg(long, default_value_t = false)]
108 pub quiet: bool,
109
110 #[arg(long, default_value_t = false, conflicts_with = "quiet")]
112 pub summary: bool,
113
114 #[arg(long, value_name = "N")]
116 pub max_detonated: Option<u32>,
117
118 #[arg(long, value_name = "N")]
120 pub max_ticking: Option<u32>,
121
122 #[arg(long, value_name = "FILE")]
124 pub output: Option<String>,
125
126 #[arg(long, default_value_t = false)]
128 pub no_inert: bool,
129
130 #[arg(long, default_value_t = false)]
132 pub stats: bool,
133}
134
135#[derive(Debug, clap::Args)]
137pub struct ManifestArgs {
138 #[arg(default_value = ".")]
140 pub path: String,
141
142 #[arg(long, default_value_t = false)]
144 pub detonated: bool,
145
146 #[arg(long, value_name = "DURATION", conflicts_with = "detonated")]
148 pub ticking: Option<String>,
149
150 #[arg(long, value_name = "FORMAT")]
152 pub format: Option<FormatArg>,
153
154 #[arg(long, value_name = "DURATION")]
156 pub fuse: Option<String>,
157
158 #[arg(long, value_name = "FILE")]
160 pub config: Option<String>,
161
162 #[arg(long)]
164 pub blame: bool,
165
166 #[arg(long, value_name = "OWNER")]
168 pub owner: Option<String>,
169
170 #[arg(long, value_name = "TAG")]
172 pub tag: Option<String>,
173
174 #[arg(long, value_name = "N")]
176 pub next: Option<usize>,
177
178 #[arg(long, value_name = "BY")]
180 pub sort: Option<SortBy>,
181
182 #[arg(long, value_name = "PATH")]
184 pub file: Vec<String>,
185
186 #[arg(long, num_args = 2, value_names = ["START", "END"])]
188 pub between: Option<Vec<String>>,
189
190 #[arg(long, default_value_t = false)]
192 pub count: bool,
193
194 #[arg(long, default_value_t = false)]
196 pub no_inert: bool,
197
198 #[arg(long, default_value_t = false)]
200 pub owner_missing: bool,
201
202 #[arg(long, value_name = "FILE")]
204 pub output: Option<String>,
205}
206
207#[derive(Debug, clap::Args)]
209pub struct ArmoryArgs {
210 #[arg(default_value = ".")]
212 pub path: String,
213
214 #[arg(long, default_value_t = 10, value_name = "N")]
216 pub limit: usize,
217
218 #[arg(long, value_name = "DURATION")]
220 pub fuse: Option<String>,
221
222 #[arg(long, value_name = "FILE")]
224 pub config: Option<String>,
225
226 #[arg(long)]
228 pub blame: bool,
229
230 #[arg(long, value_name = "OWNER")]
232 pub owner: Option<String>,
233
234 #[arg(long, value_name = "TAG")]
236 pub tag: Option<String>,
237}
238
239#[derive(Debug, clap::Args)]
241pub struct PlantArgs {
242 #[arg(value_name = "FILE[:LINE]")]
244 pub target: String,
245
246 #[arg(value_name = "MESSAGE")]
248 pub message: String,
249
250 #[arg(long, value_name = "PATTERN")]
252 pub search: Option<String>,
253
254 #[arg(long, default_value = "TODO", value_name = "TAG")]
256 pub tag: String,
257
258 #[arg(long, value_name = "OWNER")]
260 pub owner: Option<String>,
261
262 #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "in_days")]
264 pub date: Option<String>,
265
266 #[arg(long, value_name = "DAYS", conflicts_with = "date")]
268 pub in_days: Option<u32>,
269
270 #[arg(long, default_value_t = false)]
272 pub yes: bool,
273}
274
275#[derive(Debug, clap::Args)]
277pub struct DelayArgs {
278 #[arg(value_name = "FILE[:LINE]")]
280 pub target: String,
281
282 #[arg(long, value_name = "DATE", conflicts_with = "in_days")]
284 pub date: Option<String>,
285
286 #[arg(long, value_name = "DAYS", conflicts_with = "date")]
288 pub in_days: Option<u32>,
289
290 #[arg(long, value_name = "TEXT")]
292 pub reason: Option<String>,
293
294 #[arg(long, value_name = "PATTERN")]
296 pub search: Option<String>,
297
298 #[arg(long, default_value_t = false)]
300 pub yes: bool,
301}
302
303#[derive(Debug, clap::Args)]
305pub struct DisarmArgs {
306 #[arg(value_name = "FILE[:LINE]")]
309 pub target: Option<String>,
310
311 #[arg(long, value_name = "PATTERN", conflicts_with = "all_detonated")]
313 pub search: Option<String>,
314
315 #[arg(long, conflicts_with = "target")]
317 pub all_detonated: bool,
318
319 #[arg(long, default_value = ".", value_name = "PATH")]
321 pub path: String,
322
323 #[arg(long, value_name = "FILE")]
325 pub config: Option<String>,
326
327 #[arg(long, short, default_value_t = false)]
329 pub yes: bool,
330}
331
332#[derive(Debug, clap::Args)]
334pub struct IntelArgs {
335 #[arg(default_value = ".")]
337 pub path: String,
338
339 #[arg(long, value_name = "DIMENSION")]
341 pub by: Option<GroupBy>,
342
343 #[arg(long, value_name = "FORMAT")]
345 pub format: Option<FormatArg>,
346
347 #[arg(long, value_name = "DURATION")]
349 pub fuse: Option<String>,
350
351 #[arg(long, value_name = "FILE")]
353 pub config: Option<String>,
354
355 #[arg(long, value_name = "OWNER")]
357 pub owner: Option<String>,
358
359 #[arg(long, value_name = "TAG")]
361 pub tag: Option<String>,
362}
363
364#[derive(Debug, clap::Args)]
366pub struct TripwireArgs {
367 #[command(subcommand)]
368 pub command: TripwireCommand,
369}
370
371#[derive(Debug, Subcommand)]
373pub enum TripwireCommand {
374 Set(TripwireSetArgs),
376 Cut(TripwireSetArgs),
378}
379
380#[derive(Debug, clap::Args)]
382pub struct TripwireSetArgs {
383 #[arg(default_value = ".")]
385 pub path: String,
386
387 #[arg(short, long)]
389 pub yes: bool,
390}
391
392#[derive(Debug, clap::Args)]
394pub struct FalloutArgs {
395 pub report_a: String,
397 pub report_b: String,
399 #[arg(long, value_name = "FORMAT")]
401 pub format: Option<FormatArg>,
402}
403
404#[derive(Debug, clap::Args)]
406pub struct DefuseArgs {
407 #[arg(default_value = ".")]
409 pub path: String,
410
411 #[arg(long, value_name = "FILE")]
413 pub config: Option<String>,
414
415 #[arg(long, value_name = "DURATION")]
417 pub fuse: Option<String>,
418}
419
420#[derive(Debug, clap::Args)]
422pub struct BunkerArgs {
423 #[command(subcommand)]
424 pub command: BaselineCommand,
425}
426
427#[derive(Debug, Subcommand)]
429pub enum BaselineCommand {
430 Save(BunkerSaveArgs),
432 Show(BunkerShowArgs),
434}
435
436#[derive(Debug, clap::Args)]
438pub struct BunkerSaveArgs {
439 #[arg(default_value = ".")]
441 pub path: String,
442
443 #[arg(long, value_name = "FILE")]
445 pub config: Option<String>,
446
447 #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
449 pub baseline_file: String,
450
451 #[arg(long, value_name = "DURATION")]
453 pub fuse: Option<String>,
454}
455
456#[derive(Debug, clap::Args)]
458pub struct BunkerShowArgs {
459 #[arg(default_value = ".")]
461 pub path: String,
462
463 #[arg(long, value_name = "FILE")]
465 pub config: Option<String>,
466
467 #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
469 pub baseline_file: String,
470
471 #[arg(long, value_name = "DURATION")]
473 pub fuse: Option<String>,
474}
475
476#[derive(Debug, clap::Args)]
478pub struct CompletionsArgs {
479 pub shell: Shell,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
485pub enum SortBy {
486 Date,
488 File,
490 Owner,
492 Status,
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
498pub enum GroupBy {
499 Owner,
501 Tag,
503 Month,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
509pub enum FormatArg {
510 Terminal,
512 Json,
514 Github,
516 Csv,
518 Table,
520}
521
522impl FormatArg {
523 pub fn to_output_format(&self) -> crate::output::OutputFormat {
525 match self {
526 FormatArg::Terminal => crate::output::OutputFormat::Terminal,
527 FormatArg::Json => crate::output::OutputFormat::Json,
528 FormatArg::Github => crate::output::OutputFormat::GitHub,
529 FormatArg::Csv => crate::output::OutputFormat::Csv,
530 FormatArg::Table => crate::output::OutputFormat::Table,
531 }
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use clap::Parser;
539
540 fn parse(args: &[&str]) -> Cli {
541 Cli::parse_from(args)
542 }
543
544 fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
545 Cli::try_parse_from(args)
546 }
547
548 #[test]
551 fn test_sweep_defaults() {
552 let cli = parse(&["timebomb", "sweep"]);
553 match cli.command {
554 Command::Sweep(args) => {
555 assert_eq!(args.path, ".");
556 assert!(args.fuse.is_none());
557 assert!(!args.fail_on_ticking);
558 assert!(args.format.is_none());
559 assert!(args.config.is_none());
560 assert!(args.since.is_none());
561 }
562 _ => panic!("expected Sweep"),
563 }
564 }
565
566 #[test]
567 fn test_sweep_custom_path() {
568 let cli = parse(&["timebomb", "sweep", "./src"]);
569 match cli.command {
570 Command::Sweep(args) => assert_eq!(args.path, "./src"),
571 _ => panic!("expected Sweep"),
572 }
573 }
574
575 #[test]
576 fn test_sweep_fuse_flag() {
577 let cli = parse(&["timebomb", "sweep", "--fuse", "30d"]);
578 match cli.command {
579 Command::Sweep(args) => {
580 assert_eq!(args.fuse, Some("30d".to_string()));
581 }
582 _ => panic!("expected Sweep"),
583 }
584 }
585
586 #[test]
587 fn test_sweep_fail_on_ticking() {
588 let cli = parse(&["timebomb", "sweep", "--fail-on-ticking"]);
589 match cli.command {
590 Command::Sweep(args) => assert!(args.fail_on_ticking),
591 _ => panic!("expected Sweep"),
592 }
593 }
594
595 #[test]
596 fn test_sweep_format_json() {
597 let cli = parse(&["timebomb", "sweep", "--format", "json"]);
598 match cli.command {
599 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Json)),
600 _ => panic!("expected Sweep"),
601 }
602 }
603
604 #[test]
605 fn test_sweep_format_github() {
606 let cli = parse(&["timebomb", "sweep", "--format", "github"]);
607 match cli.command {
608 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Github)),
609 _ => panic!("expected Sweep"),
610 }
611 }
612
613 #[test]
614 fn test_sweep_format_terminal() {
615 let cli = parse(&["timebomb", "sweep", "--format", "terminal"]);
616 match cli.command {
617 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Terminal)),
618 _ => panic!("expected Sweep"),
619 }
620 }
621
622 #[test]
623 fn test_sweep_config_flag() {
624 let cli = parse(&["timebomb", "sweep", "--config", "my.toml"]);
625 match cli.command {
626 Command::Sweep(args) => assert_eq!(args.config, Some("my.toml".to_string())),
627 _ => panic!("expected Sweep"),
628 }
629 }
630
631 #[test]
632 fn test_sweep_all_flags_combined() {
633 let cli = parse(&[
634 "timebomb",
635 "sweep",
636 "./src",
637 "--fuse",
638 "14d",
639 "--fail-on-ticking",
640 "--format",
641 "json",
642 "--config",
643 ".timebomb.toml",
644 ]);
645 match cli.command {
646 Command::Sweep(args) => {
647 assert_eq!(args.path, "./src");
648 assert_eq!(args.fuse, Some("14d".to_string()));
649 assert!(args.fail_on_ticking);
650 assert_eq!(args.format, Some(FormatArg::Json));
651 assert_eq!(args.config, Some(".timebomb.toml".to_string()));
652 }
653 _ => panic!("expected Sweep"),
654 }
655 }
656
657 #[test]
658 fn test_sweep_since_flag() {
659 let cli = parse(&["timebomb", "sweep", "--since", "main"]);
660 match cli.command {
661 Command::Sweep(args) => assert_eq!(args.since, Some("main".to_string())),
662 _ => panic!("expected Sweep"),
663 }
664 }
665
666 #[test]
667 fn test_sweep_since_head() {
668 let cli = parse(&["timebomb", "sweep", "--since", "HEAD"]);
669 match cli.command {
670 Command::Sweep(args) => assert_eq!(args.since, Some("HEAD".to_string())),
671 _ => panic!("expected Sweep"),
672 }
673 }
674
675 #[test]
676 fn test_sweep_owner_flag() {
677 let cli = parse(&["timebomb", "sweep", "--owner", "alice"]);
678 match cli.command {
679 Command::Sweep(args) => assert_eq!(args.owner, Some("alice".to_string())),
680 _ => panic!("expected Sweep"),
681 }
682 }
683
684 #[test]
685 fn test_manifest_owner_flag() {
686 let cli = parse(&["timebomb", "manifest", "--owner", "bob"]);
687 match cli.command {
688 Command::Manifest(args) => assert_eq!(args.owner, Some("bob".to_string())),
689 _ => panic!("expected Manifest"),
690 }
691 }
692
693 #[test]
694 fn test_sweep_tag_flag() {
695 let cli = parse(&["timebomb", "sweep", "--tag", "FIXME"]);
696 match cli.command {
697 Command::Sweep(args) => assert_eq!(args.tag, Some("FIXME".to_string())),
698 _ => panic!("expected Sweep"),
699 }
700 }
701
702 #[test]
703 fn test_sweep_quiet_flag() {
704 let cli = parse(&["timebomb", "sweep", "--quiet"]);
705 match cli.command {
706 Command::Sweep(args) => assert!(args.quiet),
707 _ => panic!("expected Sweep"),
708 }
709 }
710
711 #[test]
712 fn test_sweep_quiet_default_false() {
713 let cli = parse(&["timebomb", "sweep"]);
714 match cli.command {
715 Command::Sweep(args) => assert!(!args.quiet),
716 _ => panic!("expected Sweep"),
717 }
718 }
719
720 #[test]
721 fn test_manifest_tag_flag() {
722 let cli = parse(&["timebomb", "manifest", "--tag", "TODO"]);
723 match cli.command {
724 Command::Manifest(args) => assert_eq!(args.tag, Some("TODO".to_string())),
725 _ => panic!("expected Manifest"),
726 }
727 }
728
729 #[test]
730 fn test_manifest_next_flag() {
731 let cli = parse(&["timebomb", "manifest", "--next", "5"]);
732 match cli.command {
733 Command::Manifest(args) => assert_eq!(args.next, Some(5)),
734 _ => panic!("expected Manifest"),
735 }
736 }
737
738 #[test]
739 fn test_manifest_next_default_none() {
740 let cli = parse(&["timebomb", "manifest"]);
741 match cli.command {
742 Command::Manifest(args) => assert!(args.next.is_none()),
743 _ => panic!("expected Manifest"),
744 }
745 }
746
747 #[test]
748 fn test_armory_defaults() {
749 let cli = parse(&["timebomb", "armory"]);
750 match cli.command {
751 Command::Armory(args) => {
752 assert_eq!(args.path, ".");
753 assert_eq!(args.limit, 10);
754 assert!(args.fuse.is_none());
755 assert!(args.config.is_none());
756 assert!(!args.blame);
757 assert!(args.owner.is_none());
758 assert!(args.tag.is_none());
759 }
760 _ => panic!("expected Armory"),
761 }
762 }
763
764 #[test]
765 fn test_armory_all_flags() {
766 let cli = parse(&[
767 "timebomb",
768 "armory",
769 "./src",
770 "--limit",
771 "5",
772 "--fuse",
773 "14d",
774 "--config",
775 ".timebomb.toml",
776 "--blame",
777 "--owner",
778 "alice",
779 "--tag",
780 "FIXME",
781 ]);
782 match cli.command {
783 Command::Armory(args) => {
784 assert_eq!(args.path, "./src");
785 assert_eq!(args.limit, 5);
786 assert_eq!(args.fuse, Some("14d".to_string()));
787 assert_eq!(args.config, Some(".timebomb.toml".to_string()));
788 assert!(args.blame);
789 assert_eq!(args.owner, Some("alice".to_string()));
790 assert_eq!(args.tag, Some("FIXME".to_string()));
791 }
792 _ => panic!("expected Armory"),
793 }
794 }
795
796 #[test]
797 fn test_sweep_summary_flag() {
798 let cli = parse(&["timebomb", "sweep", "--summary"]);
799 match cli.command {
800 Command::Sweep(args) => assert!(args.summary),
801 _ => panic!("expected Sweep"),
802 }
803 }
804
805 #[test]
806 fn test_sweep_summary_and_quiet_conflict() {
807 let result = try_parse(&["timebomb", "sweep", "--summary", "--quiet"]);
808 assert!(result.is_err(), "--summary and --quiet should conflict");
809 }
810
811 #[test]
812 fn test_sweep_max_detonated_flag() {
813 let cli = parse(&["timebomb", "sweep", "--max-detonated", "0"]);
814 match cli.command {
815 Command::Sweep(args) => assert_eq!(args.max_detonated, Some(0)),
816 _ => panic!("expected Sweep"),
817 }
818 }
819
820 #[test]
821 fn test_sweep_max_ticking_flag() {
822 let cli = parse(&["timebomb", "sweep", "--max-ticking", "5"]);
823 match cli.command {
824 Command::Sweep(args) => assert_eq!(args.max_ticking, Some(5)),
825 _ => panic!("expected Sweep"),
826 }
827 }
828
829 #[test]
830 fn test_sweep_max_flags_default_none() {
831 let cli = parse(&["timebomb", "sweep"]);
832 match cli.command {
833 Command::Sweep(args) => {
834 assert!(args.max_detonated.is_none());
835 assert!(args.max_ticking.is_none());
836 }
837 _ => panic!("expected Sweep"),
838 }
839 }
840
841 #[test]
842 fn test_manifest_sort_date() {
843 let cli = parse(&["timebomb", "manifest", "--sort", "date"]);
844 match cli.command {
845 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Date)),
846 _ => panic!("expected Manifest"),
847 }
848 }
849
850 #[test]
851 fn test_manifest_sort_file() {
852 let cli = parse(&["timebomb", "manifest", "--sort", "file"]);
853 match cli.command {
854 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::File)),
855 _ => panic!("expected Manifest"),
856 }
857 }
858
859 #[test]
860 fn test_manifest_sort_owner() {
861 let cli = parse(&["timebomb", "manifest", "--sort", "owner"]);
862 match cli.command {
863 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Owner)),
864 _ => panic!("expected Manifest"),
865 }
866 }
867
868 #[test]
869 fn test_manifest_sort_status() {
870 let cli = parse(&["timebomb", "manifest", "--sort", "status"]);
871 match cli.command {
872 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Status)),
873 _ => panic!("expected Manifest"),
874 }
875 }
876
877 #[test]
878 fn test_manifest_sort_default_none() {
879 let cli = parse(&["timebomb", "manifest"]);
880 match cli.command {
881 Command::Manifest(args) => assert!(args.sort.is_none()),
882 _ => panic!("expected Manifest"),
883 }
884 }
885
886 #[test]
887 fn test_sweep_output_flag() {
888 let cli = parse(&["timebomb", "sweep", "--output", "report.json"]);
889 match cli.command {
890 Command::Sweep(args) => assert_eq!(args.output, Some("report.json".to_string())),
891 _ => panic!("expected Sweep"),
892 }
893 }
894
895 #[test]
896 fn test_sweep_output_default_none() {
897 let cli = parse(&["timebomb", "sweep"]);
898 match cli.command {
899 Command::Sweep(args) => assert!(args.output.is_none()),
900 _ => panic!("expected Sweep"),
901 }
902 }
903
904 #[test]
905 fn test_manifest_file_single() {
906 let cli = parse(&["timebomb", "manifest", "--file", "src/auth/login.rs"]);
907 match cli.command {
908 Command::Manifest(args) => {
909 assert_eq!(args.file, vec!["src/auth/login.rs".to_string()])
910 }
911 _ => panic!("expected Manifest"),
912 }
913 }
914
915 #[test]
916 fn test_manifest_file_multiple() {
917 let cli = parse(&[
918 "timebomb",
919 "manifest",
920 "--file",
921 "src/auth/login.rs",
922 "--file",
923 "src/db/schema.sql",
924 ]);
925 match cli.command {
926 Command::Manifest(args) => {
927 assert_eq!(
928 args.file,
929 vec![
930 "src/auth/login.rs".to_string(),
931 "src/db/schema.sql".to_string(),
932 ]
933 )
934 }
935 _ => panic!("expected Manifest"),
936 }
937 }
938
939 #[test]
940 fn test_manifest_file_default_empty() {
941 let cli = parse(&["timebomb", "manifest"]);
942 match cli.command {
943 Command::Manifest(args) => assert!(args.file.is_empty()),
944 _ => panic!("expected Manifest"),
945 }
946 }
947
948 #[test]
949 fn test_manifest_between_flag() {
950 let cli = parse(&[
951 "timebomb",
952 "manifest",
953 "--between",
954 "2026-01-01",
955 "2026-06-30",
956 ]);
957 match cli.command {
958 Command::Manifest(args) => {
959 let dates = args.between.unwrap();
960 assert_eq!(dates[0], "2026-01-01");
961 assert_eq!(dates[1], "2026-06-30");
962 }
963 _ => panic!("expected Manifest"),
964 }
965 }
966
967 #[test]
968 fn test_manifest_between_default_none() {
969 let cli = parse(&["timebomb", "manifest"]);
970 match cli.command {
971 Command::Manifest(args) => assert!(args.between.is_none()),
972 _ => panic!("expected Manifest"),
973 }
974 }
975
976 #[test]
977 fn test_manifest_count_flag() {
978 let cli = parse(&["timebomb", "manifest", "--count"]);
979 match cli.command {
980 Command::Manifest(args) => assert!(args.count),
981 _ => panic!("expected Manifest"),
982 }
983 }
984
985 #[test]
986 fn test_manifest_count_default_false() {
987 let cli = parse(&["timebomb", "manifest"]);
988 match cli.command {
989 Command::Manifest(args) => assert!(!args.count),
990 _ => panic!("expected Manifest"),
991 }
992 }
993
994 #[test]
997 fn test_plant_message_positional() {
998 let cli = parse(&[
1000 "timebomb",
1001 "plant",
1002 "src/main.rs:42",
1003 "--in-days",
1004 "90",
1005 "the message",
1006 ]);
1007 match cli.command {
1008 Command::Plant(args) => {
1009 assert_eq!(args.target, "src/main.rs:42");
1010 assert_eq!(args.message, "the message");
1011 assert_eq!(args.in_days, Some(90));
1012 }
1013 _ => panic!("expected Plant"),
1014 }
1015 }
1016
1017 #[test]
1018 fn test_plant_with_search() {
1019 let cli = parse(&[
1020 "timebomb",
1021 "plant",
1022 "src/foo.rs",
1023 "--search",
1024 "legacy_auth",
1025 "--in-days",
1026 "30",
1027 "msg",
1028 ]);
1029 match cli.command {
1030 Command::Plant(args) => {
1031 assert_eq!(args.target, "src/foo.rs");
1032 assert_eq!(args.search, Some("legacy_auth".to_string()));
1033 assert_eq!(args.in_days, Some(30));
1034 assert_eq!(args.message, "msg");
1035 }
1036 _ => panic!("expected Plant"),
1037 }
1038 }
1039
1040 #[test]
1041 fn test_plant_defaults() {
1042 let cli = parse(&["timebomb", "plant", "src/main.rs:42", "remove this"]);
1043 match cli.command {
1044 Command::Plant(args) => {
1045 assert_eq!(args.target, "src/main.rs:42");
1046 assert_eq!(args.message, "remove this");
1047 assert_eq!(args.tag, "TODO");
1048 assert!(args.owner.is_none());
1049 assert!(args.date.is_none());
1050 assert!(args.in_days.is_none());
1051 assert!(!args.yes);
1052 assert!(args.search.is_none());
1053 }
1054 _ => panic!("expected Plant"),
1055 }
1056 }
1057
1058 #[test]
1059 fn test_plant_all_flags() {
1060 let cli = parse(&[
1061 "timebomb",
1062 "plant",
1063 "src/auth.rs:10",
1064 "remove oauth flow",
1065 "--tag",
1066 "FIXME",
1067 "--owner",
1068 "alice",
1069 "--date",
1070 "2026-09-01",
1071 "--yes",
1072 ]);
1073 match cli.command {
1074 Command::Plant(args) => {
1075 assert_eq!(args.target, "src/auth.rs:10");
1076 assert_eq!(args.message, "remove oauth flow");
1077 assert_eq!(args.tag, "FIXME");
1078 assert_eq!(args.owner, Some("alice".to_string()));
1079 assert_eq!(args.date, Some("2026-09-01".to_string()));
1080 assert!(args.yes);
1081 }
1082 _ => panic!("expected Plant"),
1083 }
1084 }
1085
1086 #[test]
1087 fn test_plant_in_days() {
1088 let cli = parse(&[
1089 "timebomb",
1090 "plant",
1091 "src/lib.rs:1",
1092 "cleanup",
1093 "--in-days",
1094 "90",
1095 ]);
1096 match cli.command {
1097 Command::Plant(args) => assert_eq!(args.in_days, Some(90)),
1098 _ => panic!("expected Plant"),
1099 }
1100 }
1101
1102 #[test]
1103 fn test_plant_date_and_in_days_conflict() {
1104 let result = try_parse(&[
1105 "timebomb",
1106 "plant",
1107 "src/lib.rs:1",
1108 "cleanup",
1109 "--date",
1110 "2026-01-01",
1111 "--in-days",
1112 "30",
1113 ]);
1114 assert!(result.is_err(), "--date and --in-days should conflict");
1115 }
1116
1117 #[test]
1120 fn test_delay_defaults() {
1121 let cli = parse(&["timebomb", "delay", "src/main.rs:42", "--in-days", "30"]);
1122 match cli.command {
1123 Command::Delay(args) => {
1124 assert_eq!(args.target, "src/main.rs:42");
1125 assert_eq!(args.in_days, Some(30));
1126 assert!(args.date.is_none());
1127 assert!(args.reason.is_none());
1128 assert!(args.search.is_none());
1129 assert!(!args.yes);
1130 }
1131 _ => panic!("expected Delay"),
1132 }
1133 }
1134
1135 #[test]
1136 fn test_delay_with_search() {
1137 let cli = parse(&[
1138 "timebomb",
1139 "delay",
1140 "src/main.rs",
1141 "--search",
1142 "pattern",
1143 "--in-days",
1144 "30",
1145 ]);
1146 match cli.command {
1147 Command::Delay(args) => {
1148 assert_eq!(args.target, "src/main.rs");
1149 assert_eq!(args.search, Some("pattern".to_string()));
1150 assert_eq!(args.in_days, Some(30));
1151 }
1152 _ => panic!("expected Delay"),
1153 }
1154 }
1155
1156 #[test]
1157 fn test_delay_with_date() {
1158 let cli = parse(&[
1159 "timebomb",
1160 "delay",
1161 "src/main.rs:42",
1162 "--date",
1163 "2027-01-01",
1164 ]);
1165 match cli.command {
1166 Command::Delay(args) => {
1167 assert_eq!(args.date, Some("2027-01-01".to_string()));
1168 assert!(args.in_days.is_none());
1169 }
1170 _ => panic!("expected Delay"),
1171 }
1172 }
1173
1174 #[test]
1175 fn test_delay_with_reason() {
1176 let cli = parse(&[
1177 "timebomb",
1178 "delay",
1179 "src/main.rs:42",
1180 "--in-days",
1181 "30",
1182 "--reason",
1183 "blocked upstream",
1184 ]);
1185 match cli.command {
1186 Command::Delay(args) => {
1187 assert_eq!(args.reason, Some("blocked upstream".to_string()));
1188 }
1189 _ => panic!("expected Delay"),
1190 }
1191 }
1192
1193 #[test]
1196 fn test_disarm_by_target() {
1197 let cli = parse(&["timebomb", "disarm", "src/main.rs:42"]);
1198 match cli.command {
1199 Command::Disarm(args) => {
1200 assert_eq!(args.target, Some("src/main.rs:42".to_string()));
1201 assert!(args.search.is_none());
1202 assert!(!args.all_detonated);
1203 }
1204 _ => panic!("expected Disarm"),
1205 }
1206 }
1207
1208 #[test]
1209 fn test_disarm_with_search() {
1210 let cli = parse(&["timebomb", "disarm", "src/main.rs", "--search", "pattern"]);
1211 match cli.command {
1212 Command::Disarm(args) => {
1213 assert_eq!(args.target, Some("src/main.rs".to_string()));
1214 assert_eq!(args.search, Some("pattern".to_string()));
1215 }
1216 _ => panic!("expected Disarm"),
1217 }
1218 }
1219
1220 #[test]
1221 fn test_disarm_all_detonated() {
1222 let cli = parse(&["timebomb", "disarm", "--all-detonated", "--path", "./src"]);
1223 match cli.command {
1224 Command::Disarm(args) => {
1225 assert!(args.all_detonated);
1226 assert_eq!(args.path, "./src");
1227 assert!(args.target.is_none());
1228 }
1229 _ => panic!("expected Disarm"),
1230 }
1231 }
1232
1233 #[test]
1234 fn test_disarm_all_detonated_default_path() {
1235 let cli = parse(&["timebomb", "disarm", "--all-detonated"]);
1236 match cli.command {
1237 Command::Disarm(args) => {
1238 assert!(args.all_detonated);
1239 assert_eq!(args.path, ".");
1240 }
1241 _ => panic!("expected Disarm"),
1242 }
1243 }
1244
1245 #[test]
1246 fn test_disarm_yes_flag() {
1247 let cli = parse(&["timebomb", "disarm", "src/main.rs:42", "--yes"]);
1248 match cli.command {
1249 Command::Disarm(args) => assert!(args.yes),
1250 _ => panic!("expected Disarm"),
1251 }
1252 }
1253
1254 #[test]
1257 fn test_intel_defaults() {
1258 let cli = parse(&["timebomb", "intel"]);
1259 match cli.command {
1260 Command::Intel(args) => {
1261 assert_eq!(args.path, ".");
1262 assert!(args.by.is_none());
1263 assert!(args.format.is_none());
1264 assert!(args.fuse.is_none());
1265 assert!(args.config.is_none());
1266 }
1267 _ => panic!("expected Intel"),
1268 }
1269 }
1270
1271 #[test]
1272 fn test_intel_by_owner() {
1273 let cli = parse(&["timebomb", "intel", "--by", "owner"]);
1274 match cli.command {
1275 Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Owner)),
1276 _ => panic!("expected Intel"),
1277 }
1278 }
1279
1280 #[test]
1281 fn test_intel_by_tag() {
1282 let cli = parse(&["timebomb", "intel", "--by", "tag"]);
1283 match cli.command {
1284 Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Tag)),
1285 _ => panic!("expected Intel"),
1286 }
1287 }
1288
1289 #[test]
1290 fn test_intel_all_flags() {
1291 let cli = parse(&[
1292 "timebomb",
1293 "intel",
1294 "./src",
1295 "--by",
1296 "owner",
1297 "--format",
1298 "json",
1299 "--fuse",
1300 "14d",
1301 "--config",
1302 "custom.toml",
1303 ]);
1304 match cli.command {
1305 Command::Intel(args) => {
1306 assert_eq!(args.path, "./src");
1307 assert_eq!(args.by, Some(GroupBy::Owner));
1308 assert_eq!(args.format, Some(FormatArg::Json));
1309 assert_eq!(args.fuse, Some("14d".to_string()));
1310 assert_eq!(args.config, Some("custom.toml".to_string()));
1311 }
1312 _ => panic!("expected Intel"),
1313 }
1314 }
1315
1316 #[test]
1319 fn test_manifest_defaults() {
1320 let cli = parse(&["timebomb", "manifest"]);
1321 match cli.command {
1322 Command::Manifest(args) => {
1323 assert_eq!(args.path, ".");
1324 assert!(!args.detonated);
1325 assert!(args.ticking.is_none());
1326 assert!(args.format.is_none());
1327 assert!(args.fuse.is_none());
1328 assert!(args.config.is_none());
1329 }
1330 _ => panic!("expected Manifest"),
1331 }
1332 }
1333
1334 #[test]
1335 fn test_manifest_detonated_flag() {
1336 let cli = parse(&["timebomb", "manifest", "--detonated"]);
1337 match cli.command {
1338 Command::Manifest(args) => assert!(args.detonated),
1339 _ => panic!("expected Manifest"),
1340 }
1341 }
1342
1343 #[test]
1344 fn test_manifest_ticking() {
1345 let cli = parse(&["timebomb", "manifest", "--ticking", "14d"]);
1346 match cli.command {
1347 Command::Manifest(args) => {
1348 assert_eq!(args.ticking, Some("14d".to_string()));
1349 assert!(!args.detonated);
1350 }
1351 _ => panic!("expected Manifest"),
1352 }
1353 }
1354
1355 #[test]
1356 fn test_manifest_detonated_and_ticking_conflict() {
1357 let result = try_parse(&["timebomb", "manifest", "--detonated", "--ticking", "14d"]);
1359 assert!(result.is_err(), "conflicting flags should produce an error");
1360 }
1361
1362 #[test]
1363 fn test_manifest_format_json() {
1364 let cli = parse(&["timebomb", "manifest", "--format", "json"]);
1365 match cli.command {
1366 Command::Manifest(args) => assert_eq!(args.format, Some(FormatArg::Json)),
1367 _ => panic!("expected Manifest"),
1368 }
1369 }
1370
1371 #[test]
1372 fn test_manifest_fuse_flag() {
1373 let cli = parse(&["timebomb", "manifest", "--fuse", "7d"]);
1374 match cli.command {
1375 Command::Manifest(args) => assert_eq!(args.fuse, Some("7d".to_string())),
1376 _ => panic!("expected Manifest"),
1377 }
1378 }
1379
1380 #[test]
1381 fn test_manifest_custom_path() {
1382 let cli = parse(&["timebomb", "manifest", "./my/project"]);
1383 match cli.command {
1384 Command::Manifest(args) => assert_eq!(args.path, "./my/project"),
1385 _ => panic!("expected Manifest"),
1386 }
1387 }
1388
1389 #[test]
1390 fn test_manifest_all_flags_combined() {
1391 let cli = parse(&[
1392 "timebomb",
1393 "manifest",
1394 "./src",
1395 "--detonated",
1396 "--format",
1397 "github",
1398 "--fuse",
1399 "30d",
1400 "--config",
1401 "custom.toml",
1402 ]);
1403 match cli.command {
1404 Command::Manifest(args) => {
1405 assert_eq!(args.path, "./src");
1406 assert!(args.detonated);
1407 assert_eq!(args.format, Some(FormatArg::Github));
1408 assert_eq!(args.fuse, Some("30d".to_string()));
1409 assert_eq!(args.config, Some("custom.toml".to_string()));
1410 }
1411 _ => panic!("expected Manifest"),
1412 }
1413 }
1414
1415 #[test]
1418 fn test_format_arg_to_output_format_terminal() {
1419 assert_eq!(
1420 FormatArg::Terminal.to_output_format(),
1421 crate::output::OutputFormat::Terminal
1422 );
1423 }
1424
1425 #[test]
1426 fn test_format_arg_to_output_format_json() {
1427 assert_eq!(
1428 FormatArg::Json.to_output_format(),
1429 crate::output::OutputFormat::Json
1430 );
1431 }
1432
1433 #[test]
1434 fn test_format_arg_to_output_format_github() {
1435 assert_eq!(
1436 FormatArg::Github.to_output_format(),
1437 crate::output::OutputFormat::GitHub
1438 );
1439 }
1440
1441 #[test]
1444 fn test_unknown_subcommand_is_error() {
1445 let result = try_parse(&["timebomb", "run"]);
1446 assert!(result.is_err());
1447 }
1448
1449 #[test]
1450 fn test_no_subcommand_is_error() {
1451 let result = try_parse(&["timebomb"]);
1452 assert!(result.is_err());
1453 }
1454}