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