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