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, value_name = "TEXT")]
108 pub message: Option<String>,
109
110 #[arg(long, default_value_t = false)]
112 pub quiet: bool,
113
114 #[arg(long, default_value_t = false, conflicts_with = "quiet")]
116 pub summary: bool,
117
118 #[arg(long, value_name = "N")]
120 pub max_detonated: Option<u32>,
121
122 #[arg(long, value_name = "N")]
124 pub max_ticking: Option<u32>,
125
126 #[arg(long, value_name = "FILE")]
128 pub output: Option<String>,
129
130 #[arg(long, default_value_t = false)]
132 pub no_inert: bool,
133
134 #[arg(long, default_value_t = false)]
136 pub stats: bool,
137}
138
139#[derive(Debug, clap::Args)]
141pub struct ManifestArgs {
142 #[arg(default_value = ".")]
144 pub path: String,
145
146 #[arg(long, default_value_t = false)]
148 pub detonated: bool,
149
150 #[arg(long, value_name = "DURATION", conflicts_with = "detonated")]
152 pub ticking: Option<String>,
153
154 #[arg(long, value_name = "FORMAT")]
156 pub format: Option<FormatArg>,
157
158 #[arg(long, value_name = "DURATION")]
160 pub fuse: Option<String>,
161
162 #[arg(long, value_name = "FILE")]
164 pub config: Option<String>,
165
166 #[arg(long)]
168 pub blame: bool,
169
170 #[arg(long, value_name = "OWNER")]
172 pub owner: Option<String>,
173
174 #[arg(long, value_name = "TAG")]
176 pub tag: Option<String>,
177
178 #[arg(long, value_name = "TEXT")]
180 pub message: Option<String>,
181
182 #[arg(long, value_name = "N")]
184 pub next: Option<usize>,
185
186 #[arg(long, value_name = "BY")]
188 pub sort: Option<SortBy>,
189
190 #[arg(long, value_name = "PATH")]
192 pub file: Vec<String>,
193
194 #[arg(long, num_args = 2, value_names = ["START", "END"])]
196 pub between: Option<Vec<String>>,
197
198 #[arg(long, default_value_t = false, conflicts_with = "path_only")]
200 pub count: bool,
201
202 #[arg(long, default_value_t = false, conflicts_with_all = ["count", "output"])]
204 pub path_only: bool,
205
206 #[arg(long, default_value_t = false)]
208 pub no_inert: bool,
209
210 #[arg(long, default_value_t = false)]
212 pub owner_missing: bool,
213
214 #[arg(long, value_name = "FILE")]
216 pub output: Option<String>,
217}
218
219#[derive(Debug, clap::Args)]
221pub struct ArmoryArgs {
222 #[arg(default_value = ".")]
224 pub path: String,
225
226 #[arg(
228 long,
229 default_value_t = 10,
230 value_name = "N",
231 conflicts_with = "oldest"
232 )]
233 pub limit: usize,
234
235 #[arg(long, default_value_t = false)]
237 pub oldest: bool,
238
239 #[arg(long, default_value_t = false, conflicts_with = "json")]
241 pub count: bool,
242
243 #[arg(long, default_value_t = false)]
245 pub json: bool,
246
247 #[arg(long, value_name = "DURATION")]
249 pub fuse: Option<String>,
250
251 #[arg(long, value_name = "FILE")]
253 pub config: Option<String>,
254
255 #[arg(long)]
257 pub blame: bool,
258
259 #[arg(long, value_name = "OWNER")]
261 pub owner: Option<String>,
262
263 #[arg(long, value_name = "TAG")]
265 pub tag: Option<String>,
266
267 #[arg(long, value_name = "TEXT")]
269 pub message: Option<String>,
270}
271
272#[derive(Debug, clap::Args)]
274pub struct PlantArgs {
275 #[arg(value_name = "FILE[:LINE]")]
277 pub target: String,
278
279 #[arg(value_name = "MESSAGE")]
281 pub message: String,
282
283 #[arg(long, value_name = "PATTERN")]
285 pub search: Option<String>,
286
287 #[arg(long, default_value = "TODO", value_name = "TAG")]
289 pub tag: String,
290
291 #[arg(long, value_name = "OWNER")]
293 pub owner: Option<String>,
294
295 #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "in_days")]
297 pub date: Option<String>,
298
299 #[arg(long, value_name = "DAYS", conflicts_with = "date")]
301 pub in_days: Option<u32>,
302
303 #[arg(long, default_value_t = false)]
305 pub yes: bool,
306}
307
308#[derive(Debug, clap::Args)]
310pub struct DelayArgs {
311 #[arg(value_name = "FILE[:LINE]")]
313 pub target: String,
314
315 #[arg(long, value_name = "DATE", conflicts_with = "in_days")]
317 pub date: Option<String>,
318
319 #[arg(long, value_name = "DAYS", conflicts_with = "date")]
321 pub in_days: Option<u32>,
322
323 #[arg(long, value_name = "TEXT")]
325 pub reason: Option<String>,
326
327 #[arg(long, value_name = "PATTERN")]
329 pub search: Option<String>,
330
331 #[arg(long, default_value_t = false)]
333 pub yes: bool,
334}
335
336#[derive(Debug, clap::Args)]
338pub struct DisarmArgs {
339 #[arg(value_name = "FILE[:LINE]")]
342 pub target: Option<String>,
343
344 #[arg(long, value_name = "PATTERN", conflicts_with = "all_detonated")]
346 pub search: Option<String>,
347
348 #[arg(long, conflicts_with = "target")]
350 pub all_detonated: bool,
351
352 #[arg(long, default_value = ".", value_name = "PATH")]
354 pub path: String,
355
356 #[arg(long, value_name = "FILE")]
358 pub config: Option<String>,
359
360 #[arg(long, short, default_value_t = false)]
362 pub yes: bool,
363}
364
365#[derive(Debug, clap::Args)]
367pub struct IntelArgs {
368 #[arg(default_value = ".")]
370 pub path: String,
371
372 #[arg(long, value_name = "DIMENSION")]
374 pub by: Option<GroupBy>,
375
376 #[arg(long, value_name = "FORMAT")]
378 pub format: Option<FormatArg>,
379
380 #[arg(long, value_name = "DURATION")]
382 pub fuse: Option<String>,
383
384 #[arg(long, value_name = "FILE")]
386 pub config: Option<String>,
387
388 #[arg(long, value_name = "OWNER")]
390 pub owner: Option<String>,
391
392 #[arg(long, value_name = "TAG")]
394 pub tag: Option<String>,
395
396 #[arg(long, value_name = "TEXT")]
398 pub message: Option<String>,
399}
400
401#[derive(Debug, clap::Args)]
403pub struct TripwireArgs {
404 #[command(subcommand)]
405 pub command: TripwireCommand,
406}
407
408#[derive(Debug, Subcommand)]
410pub enum TripwireCommand {
411 Set(TripwireSetArgs),
413 Cut(TripwireSetArgs),
415}
416
417#[derive(Debug, clap::Args)]
419pub struct TripwireSetArgs {
420 #[arg(default_value = ".")]
422 pub path: String,
423
424 #[arg(short, long)]
426 pub yes: bool,
427}
428
429#[derive(Debug, clap::Args)]
431pub struct FalloutArgs {
432 pub report_a: String,
434 pub report_b: String,
436 #[arg(long, value_name = "FORMAT")]
438 pub format: Option<FormatArg>,
439}
440
441#[derive(Debug, clap::Args)]
443pub struct DefuseArgs {
444 #[arg(default_value = ".")]
446 pub path: String,
447
448 #[arg(long, value_name = "FILE")]
450 pub config: Option<String>,
451
452 #[arg(long, value_name = "DURATION")]
454 pub fuse: Option<String>,
455}
456
457#[derive(Debug, clap::Args)]
459pub struct BunkerArgs {
460 #[command(subcommand)]
461 pub command: BaselineCommand,
462}
463
464#[derive(Debug, Subcommand)]
466pub enum BaselineCommand {
467 Save(BunkerSaveArgs),
469 Show(BunkerShowArgs),
471}
472
473#[derive(Debug, clap::Args)]
475pub struct BunkerSaveArgs {
476 #[arg(default_value = ".")]
478 pub path: String,
479
480 #[arg(long, value_name = "FILE")]
482 pub config: Option<String>,
483
484 #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
486 pub baseline_file: String,
487
488 #[arg(long, value_name = "DURATION")]
490 pub fuse: Option<String>,
491}
492
493#[derive(Debug, clap::Args)]
495pub struct BunkerShowArgs {
496 #[arg(default_value = ".")]
498 pub path: String,
499
500 #[arg(long, value_name = "FILE")]
502 pub config: Option<String>,
503
504 #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
506 pub baseline_file: String,
507
508 #[arg(long, value_name = "DURATION")]
510 pub fuse: Option<String>,
511}
512
513#[derive(Debug, clap::Args)]
515pub struct CompletionsArgs {
516 pub shell: Shell,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
522pub enum SortBy {
523 Date,
525 File,
527 Owner,
529 Status,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
535pub enum GroupBy {
536 Owner,
538 Tag,
540 Month,
542}
543
544#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
546pub enum FormatArg {
547 Terminal,
549 Json,
551 Github,
553 Csv,
555 Table,
557}
558
559impl FormatArg {
560 pub fn to_output_format(&self) -> crate::output::OutputFormat {
562 match self {
563 FormatArg::Terminal => crate::output::OutputFormat::Terminal,
564 FormatArg::Json => crate::output::OutputFormat::Json,
565 FormatArg::Github => crate::output::OutputFormat::GitHub,
566 FormatArg::Csv => crate::output::OutputFormat::Csv,
567 FormatArg::Table => crate::output::OutputFormat::Table,
568 }
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use clap::Parser;
576
577 fn parse(args: &[&str]) -> Cli {
578 Cli::parse_from(args)
579 }
580
581 fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
582 Cli::try_parse_from(args)
583 }
584
585 #[test]
588 fn test_sweep_defaults() {
589 let cli = parse(&["timebomb", "sweep"]);
590 match cli.command {
591 Command::Sweep(args) => {
592 assert_eq!(args.path, ".");
593 assert!(args.fuse.is_none());
594 assert!(!args.fail_on_ticking);
595 assert!(args.format.is_none());
596 assert!(args.config.is_none());
597 assert!(args.since.is_none());
598 }
599 _ => panic!("expected Sweep"),
600 }
601 }
602
603 #[test]
604 fn test_sweep_custom_path() {
605 let cli = parse(&["timebomb", "sweep", "./src"]);
606 match cli.command {
607 Command::Sweep(args) => assert_eq!(args.path, "./src"),
608 _ => panic!("expected Sweep"),
609 }
610 }
611
612 #[test]
613 fn test_sweep_fuse_flag() {
614 let cli = parse(&["timebomb", "sweep", "--fuse", "30d"]);
615 match cli.command {
616 Command::Sweep(args) => {
617 assert_eq!(args.fuse, Some("30d".to_string()));
618 }
619 _ => panic!("expected Sweep"),
620 }
621 }
622
623 #[test]
624 fn test_sweep_fail_on_ticking() {
625 let cli = parse(&["timebomb", "sweep", "--fail-on-ticking"]);
626 match cli.command {
627 Command::Sweep(args) => assert!(args.fail_on_ticking),
628 _ => panic!("expected Sweep"),
629 }
630 }
631
632 #[test]
633 fn test_sweep_format_json() {
634 let cli = parse(&["timebomb", "sweep", "--format", "json"]);
635 match cli.command {
636 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Json)),
637 _ => panic!("expected Sweep"),
638 }
639 }
640
641 #[test]
642 fn test_sweep_format_github() {
643 let cli = parse(&["timebomb", "sweep", "--format", "github"]);
644 match cli.command {
645 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Github)),
646 _ => panic!("expected Sweep"),
647 }
648 }
649
650 #[test]
651 fn test_sweep_format_terminal() {
652 let cli = parse(&["timebomb", "sweep", "--format", "terminal"]);
653 match cli.command {
654 Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Terminal)),
655 _ => panic!("expected Sweep"),
656 }
657 }
658
659 #[test]
660 fn test_sweep_config_flag() {
661 let cli = parse(&["timebomb", "sweep", "--config", "my.toml"]);
662 match cli.command {
663 Command::Sweep(args) => assert_eq!(args.config, Some("my.toml".to_string())),
664 _ => panic!("expected Sweep"),
665 }
666 }
667
668 #[test]
669 fn test_sweep_all_flags_combined() {
670 let cli = parse(&[
671 "timebomb",
672 "sweep",
673 "./src",
674 "--fuse",
675 "14d",
676 "--fail-on-ticking",
677 "--format",
678 "json",
679 "--config",
680 ".timebomb.toml",
681 ]);
682 match cli.command {
683 Command::Sweep(args) => {
684 assert_eq!(args.path, "./src");
685 assert_eq!(args.fuse, Some("14d".to_string()));
686 assert!(args.fail_on_ticking);
687 assert_eq!(args.format, Some(FormatArg::Json));
688 assert_eq!(args.config, Some(".timebomb.toml".to_string()));
689 }
690 _ => panic!("expected Sweep"),
691 }
692 }
693
694 #[test]
695 fn test_sweep_since_flag() {
696 let cli = parse(&["timebomb", "sweep", "--since", "main"]);
697 match cli.command {
698 Command::Sweep(args) => assert_eq!(args.since, Some("main".to_string())),
699 _ => panic!("expected Sweep"),
700 }
701 }
702
703 #[test]
704 fn test_sweep_since_head() {
705 let cli = parse(&["timebomb", "sweep", "--since", "HEAD"]);
706 match cli.command {
707 Command::Sweep(args) => assert_eq!(args.since, Some("HEAD".to_string())),
708 _ => panic!("expected Sweep"),
709 }
710 }
711
712 #[test]
713 fn test_sweep_owner_flag() {
714 let cli = parse(&["timebomb", "sweep", "--owner", "alice"]);
715 match cli.command {
716 Command::Sweep(args) => assert_eq!(args.owner, Some("alice".to_string())),
717 _ => panic!("expected Sweep"),
718 }
719 }
720
721 #[test]
722 fn test_manifest_owner_flag() {
723 let cli = parse(&["timebomb", "manifest", "--owner", "bob"]);
724 match cli.command {
725 Command::Manifest(args) => assert_eq!(args.owner, Some("bob".to_string())),
726 _ => panic!("expected Manifest"),
727 }
728 }
729
730 #[test]
731 fn test_sweep_tag_flag() {
732 let cli = parse(&["timebomb", "sweep", "--tag", "FIXME"]);
733 match cli.command {
734 Command::Sweep(args) => assert_eq!(args.tag, Some("FIXME".to_string())),
735 _ => panic!("expected Sweep"),
736 }
737 }
738
739 #[test]
740 fn test_sweep_message_flag() {
741 let cli = parse(&["timebomb", "sweep", "--message", "oauth"]);
742 match cli.command {
743 Command::Sweep(args) => assert_eq!(args.message, Some("oauth".to_string())),
744 _ => panic!("expected Sweep"),
745 }
746 }
747
748 #[test]
749 fn test_sweep_quiet_flag() {
750 let cli = parse(&["timebomb", "sweep", "--quiet"]);
751 match cli.command {
752 Command::Sweep(args) => assert!(args.quiet),
753 _ => panic!("expected Sweep"),
754 }
755 }
756
757 #[test]
758 fn test_sweep_quiet_default_false() {
759 let cli = parse(&["timebomb", "sweep"]);
760 match cli.command {
761 Command::Sweep(args) => assert!(!args.quiet),
762 _ => panic!("expected Sweep"),
763 }
764 }
765
766 #[test]
767 fn test_manifest_tag_flag() {
768 let cli = parse(&["timebomb", "manifest", "--tag", "TODO"]);
769 match cli.command {
770 Command::Manifest(args) => assert_eq!(args.tag, Some("TODO".to_string())),
771 _ => panic!("expected Manifest"),
772 }
773 }
774
775 #[test]
776 fn test_manifest_message_flag() {
777 let cli = parse(&["timebomb", "manifest", "--message", "migration"]);
778 match cli.command {
779 Command::Manifest(args) => assert_eq!(args.message, Some("migration".to_string())),
780 _ => panic!("expected Manifest"),
781 }
782 }
783
784 #[test]
785 fn test_manifest_next_flag() {
786 let cli = parse(&["timebomb", "manifest", "--next", "5"]);
787 match cli.command {
788 Command::Manifest(args) => assert_eq!(args.next, Some(5)),
789 _ => panic!("expected Manifest"),
790 }
791 }
792
793 #[test]
794 fn test_manifest_next_default_none() {
795 let cli = parse(&["timebomb", "manifest"]);
796 match cli.command {
797 Command::Manifest(args) => assert!(args.next.is_none()),
798 _ => panic!("expected Manifest"),
799 }
800 }
801
802 #[test]
803 fn test_armory_defaults() {
804 let cli = parse(&["timebomb", "armory"]);
805 match cli.command {
806 Command::Armory(args) => {
807 assert_eq!(args.path, ".");
808 assert_eq!(args.limit, 10);
809 assert!(!args.oldest);
810 assert!(!args.count);
811 assert!(!args.json);
812 assert!(args.fuse.is_none());
813 assert!(args.config.is_none());
814 assert!(!args.blame);
815 assert!(args.owner.is_none());
816 assert!(args.tag.is_none());
817 assert!(args.message.is_none());
818 }
819 _ => panic!("expected Armory"),
820 }
821 }
822
823 #[test]
824 fn test_armory_all_flags() {
825 let cli = parse(&[
826 "timebomb",
827 "armory",
828 "./src",
829 "--limit",
830 "5",
831 "--fuse",
832 "14d",
833 "--config",
834 ".timebomb.toml",
835 "--blame",
836 "--owner",
837 "alice",
838 "--tag",
839 "FIXME",
840 "--message",
841 "migration",
842 ]);
843 match cli.command {
844 Command::Armory(args) => {
845 assert_eq!(args.path, "./src");
846 assert_eq!(args.limit, 5);
847 assert!(!args.oldest);
848 assert!(!args.count);
849 assert!(!args.json);
850 assert_eq!(args.fuse, Some("14d".to_string()));
851 assert_eq!(args.config, Some(".timebomb.toml".to_string()));
852 assert!(args.blame);
853 assert_eq!(args.owner, Some("alice".to_string()));
854 assert_eq!(args.tag, Some("FIXME".to_string()));
855 assert_eq!(args.message, Some("migration".to_string()));
856 }
857 _ => panic!("expected Armory"),
858 }
859 }
860
861 #[test]
862 fn test_armory_oldest_flag() {
863 let cli = parse(&["timebomb", "armory", "--oldest"]);
864 match cli.command {
865 Command::Armory(args) => assert!(args.oldest),
866 _ => panic!("expected Armory"),
867 }
868 }
869
870 #[test]
871 fn test_armory_count_flag() {
872 let cli = parse(&["timebomb", "armory", "--count"]);
873 match cli.command {
874 Command::Armory(args) => assert!(args.count),
875 _ => panic!("expected Armory"),
876 }
877 }
878
879 #[test]
880 fn test_armory_json_flag() {
881 let cli = parse(&["timebomb", "armory", "--json"]);
882 match cli.command {
883 Command::Armory(args) => assert!(args.json),
884 _ => panic!("expected Armory"),
885 }
886 }
887
888 #[test]
889 fn test_armory_count_conflicts_with_json() {
890 let result = try_parse(&["timebomb", "armory", "--count", "--json"]);
891 assert!(result.is_err(), "--count and --json should conflict");
892 }
893
894 #[test]
895 fn test_armory_oldest_conflicts_with_limit() {
896 let result = try_parse(&["timebomb", "armory", "--oldest", "--limit", "5"]);
897 assert!(result.is_err(), "--oldest and --limit should conflict");
898 }
899
900 #[test]
901 fn test_sweep_summary_flag() {
902 let cli = parse(&["timebomb", "sweep", "--summary"]);
903 match cli.command {
904 Command::Sweep(args) => assert!(args.summary),
905 _ => panic!("expected Sweep"),
906 }
907 }
908
909 #[test]
910 fn test_sweep_summary_and_quiet_conflict() {
911 let result = try_parse(&["timebomb", "sweep", "--summary", "--quiet"]);
912 assert!(result.is_err(), "--summary and --quiet should conflict");
913 }
914
915 #[test]
916 fn test_sweep_max_detonated_flag() {
917 let cli = parse(&["timebomb", "sweep", "--max-detonated", "0"]);
918 match cli.command {
919 Command::Sweep(args) => assert_eq!(args.max_detonated, Some(0)),
920 _ => panic!("expected Sweep"),
921 }
922 }
923
924 #[test]
925 fn test_sweep_max_ticking_flag() {
926 let cli = parse(&["timebomb", "sweep", "--max-ticking", "5"]);
927 match cli.command {
928 Command::Sweep(args) => assert_eq!(args.max_ticking, Some(5)),
929 _ => panic!("expected Sweep"),
930 }
931 }
932
933 #[test]
934 fn test_sweep_max_flags_default_none() {
935 let cli = parse(&["timebomb", "sweep"]);
936 match cli.command {
937 Command::Sweep(args) => {
938 assert!(args.max_detonated.is_none());
939 assert!(args.max_ticking.is_none());
940 }
941 _ => panic!("expected Sweep"),
942 }
943 }
944
945 #[test]
946 fn test_manifest_sort_date() {
947 let cli = parse(&["timebomb", "manifest", "--sort", "date"]);
948 match cli.command {
949 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Date)),
950 _ => panic!("expected Manifest"),
951 }
952 }
953
954 #[test]
955 fn test_manifest_sort_file() {
956 let cli = parse(&["timebomb", "manifest", "--sort", "file"]);
957 match cli.command {
958 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::File)),
959 _ => panic!("expected Manifest"),
960 }
961 }
962
963 #[test]
964 fn test_manifest_sort_owner() {
965 let cli = parse(&["timebomb", "manifest", "--sort", "owner"]);
966 match cli.command {
967 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Owner)),
968 _ => panic!("expected Manifest"),
969 }
970 }
971
972 #[test]
973 fn test_manifest_sort_status() {
974 let cli = parse(&["timebomb", "manifest", "--sort", "status"]);
975 match cli.command {
976 Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Status)),
977 _ => panic!("expected Manifest"),
978 }
979 }
980
981 #[test]
982 fn test_manifest_sort_default_none() {
983 let cli = parse(&["timebomb", "manifest"]);
984 match cli.command {
985 Command::Manifest(args) => assert!(args.sort.is_none()),
986 _ => panic!("expected Manifest"),
987 }
988 }
989
990 #[test]
991 fn test_sweep_output_flag() {
992 let cli = parse(&["timebomb", "sweep", "--output", "report.json"]);
993 match cli.command {
994 Command::Sweep(args) => assert_eq!(args.output, Some("report.json".to_string())),
995 _ => panic!("expected Sweep"),
996 }
997 }
998
999 #[test]
1000 fn test_sweep_output_default_none() {
1001 let cli = parse(&["timebomb", "sweep"]);
1002 match cli.command {
1003 Command::Sweep(args) => assert!(args.output.is_none()),
1004 _ => panic!("expected Sweep"),
1005 }
1006 }
1007
1008 #[test]
1009 fn test_manifest_file_single() {
1010 let cli = parse(&["timebomb", "manifest", "--file", "src/auth/login.rs"]);
1011 match cli.command {
1012 Command::Manifest(args) => {
1013 assert_eq!(args.file, vec!["src/auth/login.rs".to_string()])
1014 }
1015 _ => panic!("expected Manifest"),
1016 }
1017 }
1018
1019 #[test]
1020 fn test_manifest_file_multiple() {
1021 let cli = parse(&[
1022 "timebomb",
1023 "manifest",
1024 "--file",
1025 "src/auth/login.rs",
1026 "--file",
1027 "src/db/schema.sql",
1028 ]);
1029 match cli.command {
1030 Command::Manifest(args) => {
1031 assert_eq!(
1032 args.file,
1033 vec![
1034 "src/auth/login.rs".to_string(),
1035 "src/db/schema.sql".to_string(),
1036 ]
1037 )
1038 }
1039 _ => panic!("expected Manifest"),
1040 }
1041 }
1042
1043 #[test]
1044 fn test_manifest_file_default_empty() {
1045 let cli = parse(&["timebomb", "manifest"]);
1046 match cli.command {
1047 Command::Manifest(args) => assert!(args.file.is_empty()),
1048 _ => panic!("expected Manifest"),
1049 }
1050 }
1051
1052 #[test]
1053 fn test_manifest_between_flag() {
1054 let cli = parse(&[
1055 "timebomb",
1056 "manifest",
1057 "--between",
1058 "2026-01-01",
1059 "2026-06-30",
1060 ]);
1061 match cli.command {
1062 Command::Manifest(args) => {
1063 let dates = args.between.unwrap();
1064 assert_eq!(dates[0], "2026-01-01");
1065 assert_eq!(dates[1], "2026-06-30");
1066 }
1067 _ => panic!("expected Manifest"),
1068 }
1069 }
1070
1071 #[test]
1072 fn test_manifest_between_default_none() {
1073 let cli = parse(&["timebomb", "manifest"]);
1074 match cli.command {
1075 Command::Manifest(args) => assert!(args.between.is_none()),
1076 _ => panic!("expected Manifest"),
1077 }
1078 }
1079
1080 #[test]
1081 fn test_manifest_count_flag() {
1082 let cli = parse(&["timebomb", "manifest", "--count"]);
1083 match cli.command {
1084 Command::Manifest(args) => assert!(args.count),
1085 _ => panic!("expected Manifest"),
1086 }
1087 }
1088
1089 #[test]
1090 fn test_manifest_count_default_false() {
1091 let cli = parse(&["timebomb", "manifest"]);
1092 match cli.command {
1093 Command::Manifest(args) => assert!(!args.count),
1094 _ => panic!("expected Manifest"),
1095 }
1096 }
1097
1098 #[test]
1099 fn test_manifest_path_only_flag() {
1100 let cli = parse(&["timebomb", "manifest", "--path-only"]);
1101 match cli.command {
1102 Command::Manifest(args) => assert!(args.path_only),
1103 _ => panic!("expected Manifest"),
1104 }
1105 }
1106
1107 #[test]
1108 fn test_manifest_path_only_conflicts_with_count() {
1109 let result = try_parse(&["timebomb", "manifest", "--path-only", "--count"]);
1110 assert!(result.is_err(), "--path-only and --count should conflict");
1111 }
1112
1113 #[test]
1114 fn test_manifest_path_only_conflicts_with_output() {
1115 let result = try_parse(&[
1116 "timebomb",
1117 "manifest",
1118 "--path-only",
1119 "--output",
1120 "fuses.json",
1121 ]);
1122 assert!(result.is_err(), "--path-only and --output should conflict");
1123 }
1124
1125 #[test]
1128 fn test_plant_message_positional() {
1129 let cli = parse(&[
1131 "timebomb",
1132 "plant",
1133 "src/main.rs:42",
1134 "--in-days",
1135 "90",
1136 "the message",
1137 ]);
1138 match cli.command {
1139 Command::Plant(args) => {
1140 assert_eq!(args.target, "src/main.rs:42");
1141 assert_eq!(args.message, "the message");
1142 assert_eq!(args.in_days, Some(90));
1143 }
1144 _ => panic!("expected Plant"),
1145 }
1146 }
1147
1148 #[test]
1149 fn test_plant_with_search() {
1150 let cli = parse(&[
1151 "timebomb",
1152 "plant",
1153 "src/foo.rs",
1154 "--search",
1155 "legacy_auth",
1156 "--in-days",
1157 "30",
1158 "msg",
1159 ]);
1160 match cli.command {
1161 Command::Plant(args) => {
1162 assert_eq!(args.target, "src/foo.rs");
1163 assert_eq!(args.search, Some("legacy_auth".to_string()));
1164 assert_eq!(args.in_days, Some(30));
1165 assert_eq!(args.message, "msg");
1166 }
1167 _ => panic!("expected Plant"),
1168 }
1169 }
1170
1171 #[test]
1172 fn test_plant_defaults() {
1173 let cli = parse(&["timebomb", "plant", "src/main.rs:42", "remove this"]);
1174 match cli.command {
1175 Command::Plant(args) => {
1176 assert_eq!(args.target, "src/main.rs:42");
1177 assert_eq!(args.message, "remove this");
1178 assert_eq!(args.tag, "TODO");
1179 assert!(args.owner.is_none());
1180 assert!(args.date.is_none());
1181 assert!(args.in_days.is_none());
1182 assert!(!args.yes);
1183 assert!(args.search.is_none());
1184 }
1185 _ => panic!("expected Plant"),
1186 }
1187 }
1188
1189 #[test]
1190 fn test_plant_all_flags() {
1191 let cli = parse(&[
1192 "timebomb",
1193 "plant",
1194 "src/auth.rs:10",
1195 "remove oauth flow",
1196 "--tag",
1197 "FIXME",
1198 "--owner",
1199 "alice",
1200 "--date",
1201 "2026-09-01",
1202 "--yes",
1203 ]);
1204 match cli.command {
1205 Command::Plant(args) => {
1206 assert_eq!(args.target, "src/auth.rs:10");
1207 assert_eq!(args.message, "remove oauth flow");
1208 assert_eq!(args.tag, "FIXME");
1209 assert_eq!(args.owner, Some("alice".to_string()));
1210 assert_eq!(args.date, Some("2026-09-01".to_string()));
1211 assert!(args.yes);
1212 }
1213 _ => panic!("expected Plant"),
1214 }
1215 }
1216
1217 #[test]
1218 fn test_plant_in_days() {
1219 let cli = parse(&[
1220 "timebomb",
1221 "plant",
1222 "src/lib.rs:1",
1223 "cleanup",
1224 "--in-days",
1225 "90",
1226 ]);
1227 match cli.command {
1228 Command::Plant(args) => assert_eq!(args.in_days, Some(90)),
1229 _ => panic!("expected Plant"),
1230 }
1231 }
1232
1233 #[test]
1234 fn test_plant_date_and_in_days_conflict() {
1235 let result = try_parse(&[
1236 "timebomb",
1237 "plant",
1238 "src/lib.rs:1",
1239 "cleanup",
1240 "--date",
1241 "2026-01-01",
1242 "--in-days",
1243 "30",
1244 ]);
1245 assert!(result.is_err(), "--date and --in-days should conflict");
1246 }
1247
1248 #[test]
1251 fn test_delay_defaults() {
1252 let cli = parse(&["timebomb", "delay", "src/main.rs:42", "--in-days", "30"]);
1253 match cli.command {
1254 Command::Delay(args) => {
1255 assert_eq!(args.target, "src/main.rs:42");
1256 assert_eq!(args.in_days, Some(30));
1257 assert!(args.date.is_none());
1258 assert!(args.reason.is_none());
1259 assert!(args.search.is_none());
1260 assert!(!args.yes);
1261 }
1262 _ => panic!("expected Delay"),
1263 }
1264 }
1265
1266 #[test]
1267 fn test_delay_with_search() {
1268 let cli = parse(&[
1269 "timebomb",
1270 "delay",
1271 "src/main.rs",
1272 "--search",
1273 "pattern",
1274 "--in-days",
1275 "30",
1276 ]);
1277 match cli.command {
1278 Command::Delay(args) => {
1279 assert_eq!(args.target, "src/main.rs");
1280 assert_eq!(args.search, Some("pattern".to_string()));
1281 assert_eq!(args.in_days, Some(30));
1282 }
1283 _ => panic!("expected Delay"),
1284 }
1285 }
1286
1287 #[test]
1288 fn test_delay_with_date() {
1289 let cli = parse(&[
1290 "timebomb",
1291 "delay",
1292 "src/main.rs:42",
1293 "--date",
1294 "2027-01-01",
1295 ]);
1296 match cli.command {
1297 Command::Delay(args) => {
1298 assert_eq!(args.date, Some("2027-01-01".to_string()));
1299 assert!(args.in_days.is_none());
1300 }
1301 _ => panic!("expected Delay"),
1302 }
1303 }
1304
1305 #[test]
1306 fn test_delay_with_reason() {
1307 let cli = parse(&[
1308 "timebomb",
1309 "delay",
1310 "src/main.rs:42",
1311 "--in-days",
1312 "30",
1313 "--reason",
1314 "blocked upstream",
1315 ]);
1316 match cli.command {
1317 Command::Delay(args) => {
1318 assert_eq!(args.reason, Some("blocked upstream".to_string()));
1319 }
1320 _ => panic!("expected Delay"),
1321 }
1322 }
1323
1324 #[test]
1327 fn test_disarm_by_target() {
1328 let cli = parse(&["timebomb", "disarm", "src/main.rs:42"]);
1329 match cli.command {
1330 Command::Disarm(args) => {
1331 assert_eq!(args.target, Some("src/main.rs:42".to_string()));
1332 assert!(args.search.is_none());
1333 assert!(!args.all_detonated);
1334 }
1335 _ => panic!("expected Disarm"),
1336 }
1337 }
1338
1339 #[test]
1340 fn test_disarm_with_search() {
1341 let cli = parse(&["timebomb", "disarm", "src/main.rs", "--search", "pattern"]);
1342 match cli.command {
1343 Command::Disarm(args) => {
1344 assert_eq!(args.target, Some("src/main.rs".to_string()));
1345 assert_eq!(args.search, Some("pattern".to_string()));
1346 }
1347 _ => panic!("expected Disarm"),
1348 }
1349 }
1350
1351 #[test]
1352 fn test_disarm_all_detonated() {
1353 let cli = parse(&["timebomb", "disarm", "--all-detonated", "--path", "./src"]);
1354 match cli.command {
1355 Command::Disarm(args) => {
1356 assert!(args.all_detonated);
1357 assert_eq!(args.path, "./src");
1358 assert!(args.target.is_none());
1359 }
1360 _ => panic!("expected Disarm"),
1361 }
1362 }
1363
1364 #[test]
1365 fn test_disarm_all_detonated_default_path() {
1366 let cli = parse(&["timebomb", "disarm", "--all-detonated"]);
1367 match cli.command {
1368 Command::Disarm(args) => {
1369 assert!(args.all_detonated);
1370 assert_eq!(args.path, ".");
1371 }
1372 _ => panic!("expected Disarm"),
1373 }
1374 }
1375
1376 #[test]
1377 fn test_disarm_yes_flag() {
1378 let cli = parse(&["timebomb", "disarm", "src/main.rs:42", "--yes"]);
1379 match cli.command {
1380 Command::Disarm(args) => assert!(args.yes),
1381 _ => panic!("expected Disarm"),
1382 }
1383 }
1384
1385 #[test]
1388 fn test_intel_defaults() {
1389 let cli = parse(&["timebomb", "intel"]);
1390 match cli.command {
1391 Command::Intel(args) => {
1392 assert_eq!(args.path, ".");
1393 assert!(args.by.is_none());
1394 assert!(args.format.is_none());
1395 assert!(args.fuse.is_none());
1396 assert!(args.config.is_none());
1397 assert!(args.message.is_none());
1398 }
1399 _ => panic!("expected Intel"),
1400 }
1401 }
1402
1403 #[test]
1404 fn test_intel_by_owner() {
1405 let cli = parse(&["timebomb", "intel", "--by", "owner"]);
1406 match cli.command {
1407 Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Owner)),
1408 _ => panic!("expected Intel"),
1409 }
1410 }
1411
1412 #[test]
1413 fn test_intel_by_tag() {
1414 let cli = parse(&["timebomb", "intel", "--by", "tag"]);
1415 match cli.command {
1416 Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Tag)),
1417 _ => panic!("expected Intel"),
1418 }
1419 }
1420
1421 #[test]
1422 fn test_intel_all_flags() {
1423 let cli = parse(&[
1424 "timebomb",
1425 "intel",
1426 "./src",
1427 "--by",
1428 "owner",
1429 "--format",
1430 "json",
1431 "--fuse",
1432 "14d",
1433 "--config",
1434 "custom.toml",
1435 "--message",
1436 "cleanup",
1437 ]);
1438 match cli.command {
1439 Command::Intel(args) => {
1440 assert_eq!(args.path, "./src");
1441 assert_eq!(args.by, Some(GroupBy::Owner));
1442 assert_eq!(args.format, Some(FormatArg::Json));
1443 assert_eq!(args.fuse, Some("14d".to_string()));
1444 assert_eq!(args.config, Some("custom.toml".to_string()));
1445 assert_eq!(args.message, Some("cleanup".to_string()));
1446 }
1447 _ => panic!("expected Intel"),
1448 }
1449 }
1450
1451 #[test]
1454 fn test_manifest_defaults() {
1455 let cli = parse(&["timebomb", "manifest"]);
1456 match cli.command {
1457 Command::Manifest(args) => {
1458 assert_eq!(args.path, ".");
1459 assert!(!args.detonated);
1460 assert!(args.ticking.is_none());
1461 assert!(args.format.is_none());
1462 assert!(args.fuse.is_none());
1463 assert!(args.config.is_none());
1464 assert!(args.message.is_none());
1465 }
1466 _ => panic!("expected Manifest"),
1467 }
1468 }
1469
1470 #[test]
1471 fn test_manifest_detonated_flag() {
1472 let cli = parse(&["timebomb", "manifest", "--detonated"]);
1473 match cli.command {
1474 Command::Manifest(args) => assert!(args.detonated),
1475 _ => panic!("expected Manifest"),
1476 }
1477 }
1478
1479 #[test]
1480 fn test_manifest_ticking() {
1481 let cli = parse(&["timebomb", "manifest", "--ticking", "14d"]);
1482 match cli.command {
1483 Command::Manifest(args) => {
1484 assert_eq!(args.ticking, Some("14d".to_string()));
1485 assert!(!args.detonated);
1486 }
1487 _ => panic!("expected Manifest"),
1488 }
1489 }
1490
1491 #[test]
1492 fn test_manifest_detonated_and_ticking_conflict() {
1493 let result = try_parse(&["timebomb", "manifest", "--detonated", "--ticking", "14d"]);
1495 assert!(result.is_err(), "conflicting flags should produce an error");
1496 }
1497
1498 #[test]
1499 fn test_manifest_format_json() {
1500 let cli = parse(&["timebomb", "manifest", "--format", "json"]);
1501 match cli.command {
1502 Command::Manifest(args) => assert_eq!(args.format, Some(FormatArg::Json)),
1503 _ => panic!("expected Manifest"),
1504 }
1505 }
1506
1507 #[test]
1508 fn test_manifest_fuse_flag() {
1509 let cli = parse(&["timebomb", "manifest", "--fuse", "7d"]);
1510 match cli.command {
1511 Command::Manifest(args) => assert_eq!(args.fuse, Some("7d".to_string())),
1512 _ => panic!("expected Manifest"),
1513 }
1514 }
1515
1516 #[test]
1517 fn test_manifest_custom_path() {
1518 let cli = parse(&["timebomb", "manifest", "./my/project"]);
1519 match cli.command {
1520 Command::Manifest(args) => assert_eq!(args.path, "./my/project"),
1521 _ => panic!("expected Manifest"),
1522 }
1523 }
1524
1525 #[test]
1526 fn test_manifest_all_flags_combined() {
1527 let cli = parse(&[
1528 "timebomb",
1529 "manifest",
1530 "./src",
1531 "--detonated",
1532 "--format",
1533 "github",
1534 "--fuse",
1535 "30d",
1536 "--config",
1537 "custom.toml",
1538 "--message",
1539 "migration",
1540 ]);
1541 match cli.command {
1542 Command::Manifest(args) => {
1543 assert_eq!(args.path, "./src");
1544 assert!(args.detonated);
1545 assert_eq!(args.format, Some(FormatArg::Github));
1546 assert_eq!(args.fuse, Some("30d".to_string()));
1547 assert_eq!(args.config, Some("custom.toml".to_string()));
1548 assert_eq!(args.message, Some("migration".to_string()));
1549 }
1550 _ => panic!("expected Manifest"),
1551 }
1552 }
1553
1554 #[test]
1557 fn test_format_arg_to_output_format_terminal() {
1558 assert_eq!(
1559 FormatArg::Terminal.to_output_format(),
1560 crate::output::OutputFormat::Terminal
1561 );
1562 }
1563
1564 #[test]
1565 fn test_format_arg_to_output_format_json() {
1566 assert_eq!(
1567 FormatArg::Json.to_output_format(),
1568 crate::output::OutputFormat::Json
1569 );
1570 }
1571
1572 #[test]
1573 fn test_format_arg_to_output_format_github() {
1574 assert_eq!(
1575 FormatArg::Github.to_output_format(),
1576 crate::output::OutputFormat::GitHub
1577 );
1578 }
1579
1580 #[test]
1583 fn test_unknown_subcommand_is_error() {
1584 let result = try_parse(&["timebomb", "run"]);
1585 assert!(result.is_err());
1586 }
1587
1588 #[test]
1589 fn test_no_subcommand_is_error() {
1590 let result = try_parse(&["timebomb"]);
1591 assert!(result.is_err());
1592 }
1593}