Skip to main content

timebomb/
cli.rs

1use clap::{Parser, Subcommand, ValueEnum};
2pub use clap_complete::Shell;
3
4/// timebomb — enforce expiring TODO/FIXME fuses in source code
5#[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 for fuses and exit non-zero if any have detonated
23    Sweep(SweepArgs),
24
25    /// List all fuses sorted by expiry date
26    Manifest(ManifestArgs),
27
28    /// Show the most urgent detonated and ticking fuses
29    Armory(ArmoryArgs),
30
31    /// Insert a timebomb fuse into a source file
32    Plant(PlantArgs),
33
34    /// Bump the expiry date on an existing fuse in-place
35    Delay(DelayArgs),
36
37    /// Remove a fuse from a source file
38    Disarm(DisarmArgs),
39
40    /// Show fuse counts broken down by owner and tag
41    Intel(IntelArgs),
42
43    /// Manage the git pre-commit tripwire
44    Tripwire(TripwireArgs),
45
46    /// Compare two report JSON snapshots and show fuse debt trajectory
47    Fallout(FalloutArgs),
48
49    /// Interactively defuse detonated fuses: extend, delete, or skip each one
50    Defuse(DefuseArgs),
51
52    /// Save or show the fuse count baseline for ratchet enforcement
53    Bunker(BunkerArgs),
54
55    /// Print a shell completion script to stdout
56    Completions(CompletionsArgs),
57}
58
59/// Arguments for the `sweep` subcommand.
60#[derive(Debug, clap::Args)]
61pub struct SweepArgs {
62    /// Path to scan (default: current directory)
63    #[arg(default_value = ".")]
64    pub path: String,
65
66    /// Warn on fuses expiring within this window (e.g. "30d")
67    #[arg(long, value_name = "DURATION")]
68    pub fuse: Option<String>,
69
70    /// Exit with code 1 if any fuses are in the ticking window (not just detonated)
71    #[arg(long, default_value_t = false)]
72    pub fail_on_ticking: bool,
73
74    /// Output format
75    #[arg(long, value_name = "FORMAT")]
76    pub format: Option<FormatArg>,
77
78    /// Path to config file (default: .timebomb.toml in scan root or cwd)
79    #[arg(long, value_name = "FILE")]
80    pub config: Option<String>,
81
82    /// Only report fuses touched in the git diff against this ref (e.g. "HEAD", "main")
83    #[arg(long, value_name = "REF")]
84    pub since: Option<String>,
85
86    /// Enrich fuses without an explicit owner with git blame author
87    #[arg(long)]
88    pub blame: bool,
89
90    /// Only report fuses on lines changed in the git diff
91    #[arg(long, default_value_t = false)]
92    pub changed: bool,
93
94    /// Base ref for --changed (default: HEAD)
95    #[arg(long, value_name = "REF", requires = "changed")]
96    pub base: Option<String>,
97
98    /// Only show fuses belonging to this owner (case-insensitive)
99    #[arg(long, value_name = "OWNER")]
100    pub owner: Option<String>,
101
102    /// Only show fuses with this tag (case-insensitive, e.g. "FIXME")
103    #[arg(long, value_name = "TAG")]
104    pub tag: Option<String>,
105
106    /// Suppress all output; rely on the exit code only
107    #[arg(long, default_value_t = false)]
108    pub quiet: bool,
109
110    /// Print only the summary line, not individual fuses
111    #[arg(long, default_value_t = false, conflicts_with = "quiet")]
112    pub summary: bool,
113
114    /// Hard ceiling on detonated fuses; sweep exits 1 if exceeded (overrides config)
115    #[arg(long, value_name = "N")]
116    pub max_detonated: Option<u32>,
117
118    /// Hard ceiling on ticking fuses; sweep exits 1 if exceeded (overrides config)
119    #[arg(long, value_name = "N")]
120    pub max_ticking: Option<u32>,
121
122    /// Write a JSON report to this file in addition to normal output
123    #[arg(long, value_name = "FILE")]
124    pub output: Option<String>,
125
126    /// Hide inert (safe) fuses from output
127    #[arg(long, default_value_t = false)]
128    pub no_inert: bool,
129
130    /// Print a per-tag breakdown of detonated/ticking counts after the summary (terminal only)
131    #[arg(long, default_value_t = false)]
132    pub stats: bool,
133}
134
135/// Arguments for the `manifest` subcommand.
136#[derive(Debug, clap::Args)]
137pub struct ManifestArgs {
138    /// Path to scan (default: current directory)
139    #[arg(default_value = ".")]
140    pub path: String,
141
142    /// Only show detonated fuses
143    #[arg(long, default_value_t = false)]
144    pub detonated: bool,
145
146    /// Only show fuses ticking within this window (e.g. "14d")
147    #[arg(long, value_name = "DURATION", conflicts_with = "detonated")]
148    pub ticking: Option<String>,
149
150    /// Output format
151    #[arg(long, value_name = "FORMAT")]
152    pub format: Option<FormatArg>,
153
154    /// Fuse-days threshold used for status classification (e.g. "14d")
155    #[arg(long, value_name = "DURATION")]
156    pub fuse: Option<String>,
157
158    /// Path to config file (default: .timebomb.toml in scan root or cwd)
159    #[arg(long, value_name = "FILE")]
160    pub config: Option<String>,
161
162    /// Enrich fuses without an explicit owner with git blame author
163    #[arg(long)]
164    pub blame: bool,
165
166    /// Only show fuses belonging to this owner (case-insensitive)
167    #[arg(long, value_name = "OWNER")]
168    pub owner: Option<String>,
169
170    /// Only show fuses with this tag (case-insensitive, e.g. "TODO")
171    #[arg(long, value_name = "TAG")]
172    pub tag: Option<String>,
173
174    /// Show only the N soonest-to-detonate fuses
175    #[arg(long, value_name = "N")]
176    pub next: Option<usize>,
177
178    /// Sort order for the fuse list (default: date)
179    #[arg(long, value_name = "BY")]
180    pub sort: Option<SortBy>,
181
182    /// Only show fuses from these files; may be repeated, supports globs (e.g. "src/auth/**")
183    #[arg(long, value_name = "PATH")]
184    pub file: Vec<String>,
185
186    /// Only show fuses with expiry dates in this range (inclusive), e.g. --between 2026-01-01 2026-06-30
187    #[arg(long, num_args = 2, value_names = ["START", "END"])]
188    pub between: Option<Vec<String>>,
189
190    /// Print only the count of matching fuses as a plain integer
191    #[arg(long, default_value_t = false)]
192    pub count: bool,
193
194    /// Hide inert (safe) fuses from output
195    #[arg(long, default_value_t = false)]
196    pub no_inert: bool,
197
198    /// Only show fuses with no explicit owner and no git blame result (combine with --blame)
199    #[arg(long, default_value_t = false)]
200    pub owner_missing: bool,
201
202    /// Write the matching fuses as a JSON file (in addition to stdout output)
203    #[arg(long, value_name = "FILE")]
204    pub output: Option<String>,
205}
206
207/// Arguments for the `armory` subcommand.
208#[derive(Debug, clap::Args)]
209pub struct ArmoryArgs {
210    /// Path to scan (default: current directory)
211    #[arg(default_value = ".")]
212    pub path: String,
213
214    /// Maximum number of fuses to show
215    #[arg(long, default_value_t = 10, value_name = "N")]
216    pub limit: usize,
217
218    /// Fuse-days threshold used for ticking classification (e.g. "14d")
219    #[arg(long, value_name = "DURATION")]
220    pub fuse: Option<String>,
221
222    /// Path to config file (default: .timebomb.toml in scan root or cwd)
223    #[arg(long, value_name = "FILE")]
224    pub config: Option<String>,
225
226    /// Enrich fuses without an explicit owner with git blame author
227    #[arg(long)]
228    pub blame: bool,
229
230    /// Only show fuses belonging to this owner (case-insensitive)
231    #[arg(long, value_name = "OWNER")]
232    pub owner: Option<String>,
233
234    /// Only show fuses with this tag (case-insensitive, e.g. "TODO")
235    #[arg(long, value_name = "TAG")]
236    pub tag: Option<String>,
237}
238
239/// Arguments for the `plant` subcommand.
240#[derive(Debug, clap::Args)]
241pub struct PlantArgs {
242    /// File and line to annotate, e.g. "src/main.rs:42"
243    #[arg(value_name = "FILE[:LINE]")]
244    pub target: String,
245
246    /// Fuse message (what needs to be done / why)
247    #[arg(value_name = "MESSAGE")]
248    pub message: String,
249
250    /// Search for a pattern instead of specifying :LINE
251    #[arg(long, value_name = "PATTERN")]
252    pub search: Option<String>,
253
254    /// Tag to use (default: TODO)
255    #[arg(long, default_value = "TODO", value_name = "TAG")]
256    pub tag: String,
257
258    /// Owner of the fuse, e.g. "alice" or "team-backend"
259    #[arg(long, value_name = "OWNER")]
260    pub owner: Option<String>,
261
262    /// Expiry date in YYYY-MM-DD format
263    #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "in_days")]
264    pub date: Option<String>,
265
266    /// Expiry date as number of days from today
267    #[arg(long, value_name = "DAYS", conflicts_with = "date")]
268    pub in_days: Option<u32>,
269
270    /// Skip the confirmation prompt and write immediately
271    #[arg(long, default_value_t = false)]
272    pub yes: bool,
273}
274
275/// Arguments for the `delay` subcommand.
276#[derive(Debug, clap::Args)]
277pub struct DelayArgs {
278    /// Target file and line, e.g. "src/main.rs:42"
279    #[arg(value_name = "FILE[:LINE]")]
280    pub target: String,
281
282    /// New expiry date as YYYY-MM-DD
283    #[arg(long, value_name = "DATE", conflicts_with = "in_days")]
284    pub date: Option<String>,
285
286    /// New expiry as number of days from today
287    #[arg(long, value_name = "DAYS", conflicts_with = "date")]
288    pub in_days: Option<u32>,
289
290    /// Reason for delaying (appended to the fuse message)
291    #[arg(long, value_name = "TEXT")]
292    pub reason: Option<String>,
293
294    /// Search for a pattern instead of specifying :LINE
295    #[arg(long, value_name = "PATTERN")]
296    pub search: Option<String>,
297
298    /// Skip confirmation prompt
299    #[arg(long, default_value_t = false)]
300    pub yes: bool,
301}
302
303/// Arguments for the `disarm` subcommand.
304#[derive(Debug, clap::Args)]
305pub struct DisarmArgs {
306    /// File and line to remove, e.g. "src/main.rs:42"
307    /// Omit when using --all-detonated
308    #[arg(value_name = "FILE[:LINE]")]
309    pub target: Option<String>,
310
311    /// Search for a pattern to find the fuse to disarm
312    #[arg(long, value_name = "PATTERN", conflicts_with = "all_detonated")]
313    pub search: Option<String>,
314
315    /// Remove all detonated fuses across the scan path
316    #[arg(long, conflicts_with = "target")]
317    pub all_detonated: bool,
318
319    /// Path to scan (used with --all-detonated, default: current directory)
320    #[arg(long, default_value = ".", value_name = "PATH")]
321    pub path: String,
322
323    /// Path to config file (used with --all-detonated)
324    #[arg(long, value_name = "FILE")]
325    pub config: Option<String>,
326
327    /// Skip confirmation prompt
328    #[arg(long, short, default_value_t = false)]
329    pub yes: bool,
330}
331
332/// Arguments for the `intel` subcommand.
333#[derive(Debug, clap::Args)]
334pub struct IntelArgs {
335    /// Path to scan (default: current directory)
336    #[arg(default_value = ".")]
337    pub path: String,
338
339    /// Group results by this dimension (default: both)
340    #[arg(long, value_name = "DIMENSION")]
341    pub by: Option<GroupBy>,
342
343    /// Output format
344    #[arg(long, value_name = "FORMAT")]
345    pub format: Option<FormatArg>,
346
347    /// Fuse-days threshold used for status classification (e.g. "14d")
348    #[arg(long, value_name = "DURATION")]
349    pub fuse: Option<String>,
350
351    /// Path to config file (default: .timebomb.toml in scan root or cwd)
352    #[arg(long, value_name = "FILE")]
353    pub config: Option<String>,
354
355    /// Only count fuses belonging to this owner (case-insensitive)
356    #[arg(long, value_name = "OWNER")]
357    pub owner: Option<String>,
358
359    /// Only count fuses with this tag (case-insensitive, e.g. "TODO")
360    #[arg(long, value_name = "TAG")]
361    pub tag: Option<String>,
362}
363
364/// Arguments for the `tripwire` subcommand.
365#[derive(Debug, clap::Args)]
366pub struct TripwireArgs {
367    #[command(subcommand)]
368    pub command: TripwireCommand,
369}
370
371/// Subcommands under `tripwire`.
372#[derive(Debug, Subcommand)]
373pub enum TripwireCommand {
374    /// Install the timebomb git pre-commit tripwire
375    Set(TripwireSetArgs),
376    /// Remove the timebomb git pre-commit tripwire
377    Cut(TripwireSetArgs),
378}
379
380/// Arguments for `tripwire set` / `tripwire cut`.
381#[derive(Debug, clap::Args)]
382pub struct TripwireSetArgs {
383    /// Path to the git repository root (default: current directory)
384    #[arg(default_value = ".")]
385    pub path: String,
386
387    /// Skip confirmation prompts
388    #[arg(short, long)]
389    pub yes: bool,
390}
391
392/// Arguments for the `fallout` subcommand.
393#[derive(Debug, clap::Args)]
394pub struct FalloutArgs {
395    /// Path to the earlier report JSON file (baseline)
396    pub report_a: String,
397    /// Path to the newer report JSON file (current)
398    pub report_b: String,
399    /// Output format
400    #[arg(long, value_name = "FORMAT")]
401    pub format: Option<FormatArg>,
402}
403
404/// Arguments for the `defuse` subcommand.
405#[derive(Debug, clap::Args)]
406pub struct DefuseArgs {
407    /// Directory to scan (default: current directory)
408    #[arg(default_value = ".")]
409    pub path: String,
410
411    /// Path to config file (default: .timebomb.toml in scan root or cwd)
412    #[arg(long, value_name = "FILE")]
413    pub config: Option<String>,
414
415    /// Fuse-days threshold used for status classification (e.g. "14d")
416    #[arg(long, value_name = "DURATION")]
417    pub fuse: Option<String>,
418}
419
420/// Arguments for the `bunker` subcommand.
421#[derive(Debug, clap::Args)]
422pub struct BunkerArgs {
423    #[command(subcommand)]
424    pub command: BaselineCommand,
425}
426
427/// Subcommands under `bunker`.
428#[derive(Debug, Subcommand)]
429pub enum BaselineCommand {
430    /// Record current fuse counts as the baseline
431    Save(BunkerSaveArgs),
432    /// Compare current counts against the saved baseline
433    Show(BunkerShowArgs),
434}
435
436/// Arguments for `bunker save`.
437#[derive(Debug, clap::Args)]
438pub struct BunkerSaveArgs {
439    /// Path to scan (default: current directory)
440    #[arg(default_value = ".")]
441    pub path: String,
442
443    /// Path to config file (default: .timebomb.toml in scan root or cwd)
444    #[arg(long, value_name = "FILE")]
445    pub config: Option<String>,
446
447    /// Path to the baseline file to write
448    #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
449    pub baseline_file: String,
450
451    /// Fuse-days threshold used for status classification (e.g. "14d")
452    #[arg(long, value_name = "DURATION")]
453    pub fuse: Option<String>,
454}
455
456/// Arguments for `bunker show`.
457#[derive(Debug, clap::Args)]
458pub struct BunkerShowArgs {
459    /// Path to scan (default: current directory)
460    #[arg(default_value = ".")]
461    pub path: String,
462
463    /// Path to config file (default: .timebomb.toml in scan root or cwd)
464    #[arg(long, value_name = "FILE")]
465    pub config: Option<String>,
466
467    /// Path to the baseline file to read
468    #[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
469    pub baseline_file: String,
470
471    /// Fuse-days threshold used for status classification (e.g. "14d")
472    #[arg(long, value_name = "DURATION")]
473    pub fuse: Option<String>,
474}
475
476/// Arguments for the `completions` subcommand.
477#[derive(Debug, clap::Args)]
478pub struct CompletionsArgs {
479    /// Shell to generate completions for
480    pub shell: Shell,
481}
482
483/// The --sort flag value for `manifest`.
484#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
485pub enum SortBy {
486    /// Sort by expiry date ascending (default)
487    Date,
488    /// Sort by file path then line number
489    File,
490    /// Sort by owner name then date
491    Owner,
492    /// Sort by status (detonated → ticking → inert) then date
493    Status,
494}
495
496/// The --by flag value for `intel`.
497#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
498pub enum GroupBy {
499    /// Break down by fuse owner
500    Owner,
501    /// Break down by tag (TODO, FIXME, etc.)
502    Tag,
503    /// Break down by expiry month (timeline view)
504    Month,
505}
506
507/// The --format flag value.
508#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
509pub enum FormatArg {
510    /// Human-readable terminal output with color
511    Terminal,
512    /// Machine-readable JSON
513    Json,
514    /// GitHub Actions annotation format
515    Github,
516    /// Comma-separated values
517    Csv,
518    /// Fixed-width aligned table (manifest only)
519    Table,
520}
521
522impl FormatArg {
523    /// Convert to the `output::OutputFormat` type.
524    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    // ── sweep subcommand ──────────────────────────────────────────────────────
549
550    #[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    // ── plant subcommand ────────────────────────────────────────────────────────
995
996    #[test]
997    fn test_plant_message_positional() {
998        // Message is now positional
999        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    // ── delay subcommand ─────────────────────────────────────────────────────
1118
1119    #[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    // ── disarm subcommand ─────────────────────────────────────────────────────
1194
1195    #[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    // ── intel subcommand ──────────────────────────────────────────────────────
1255
1256    #[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    // ── manifest subcommand ───────────────────────────────────────────────────
1317
1318    #[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        // --detonated and --ticking should conflict
1358        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    // ── FormatArg conversions ─────────────────────────────────────────────────
1416
1417    #[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    // ── unknown subcommand ────────────────────────────────────────────────────
1442
1443    #[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}