use clap::{Parser, Subcommand, ValueEnum};
pub use clap_complete::Shell;
#[derive(Debug, Parser)]
#[command(
name = "timebomb",
version,
about = "Sweep source code for ticking fuses and detonate in CI when deadlines pass",
long_about = "timebomb sweeps your source code for structured TODO/FIXME fuses \
with expiry dates and fails in CI when deadlines have passed.\n\n\
Fuse format: // TODO[2026-06-01]: message\n\
With owner: // TODO[2026-06-01][alice]: message"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Sweep(SweepArgs),
Manifest(ManifestArgs),
Armory(ArmoryArgs),
Plant(PlantArgs),
Delay(DelayArgs),
Disarm(DisarmArgs),
Intel(IntelArgs),
Tripwire(TripwireArgs),
Fallout(FalloutArgs),
Defuse(DefuseArgs),
Bunker(BunkerArgs),
Completions(CompletionsArgs),
}
#[derive(Debug, clap::Args)]
pub struct SweepArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(long, value_name = "DURATION")]
pub fuse: Option<String>,
#[arg(long, default_value_t = false)]
pub fail_on_ticking: bool,
#[arg(long, value_name = "FORMAT")]
pub format: Option<FormatArg>,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long, value_name = "REF")]
pub since: Option<String>,
#[arg(long)]
pub blame: bool,
#[arg(long, default_value_t = false)]
pub changed: bool,
#[arg(long, value_name = "REF", requires = "changed")]
pub base: Option<String>,
#[arg(long, value_name = "OWNER")]
pub owner: Option<String>,
#[arg(long, value_name = "TAG")]
pub tag: Option<String>,
#[arg(long, value_name = "TEXT")]
pub message: Option<String>,
#[arg(long, default_value_t = false)]
pub quiet: bool,
#[arg(long, default_value_t = false, conflicts_with = "quiet")]
pub summary: bool,
#[arg(long, value_name = "N")]
pub max_detonated: Option<u32>,
#[arg(long, value_name = "N")]
pub max_ticking: Option<u32>,
#[arg(long, value_name = "FILE")]
pub output: Option<String>,
#[arg(long, default_value_t = false)]
pub no_inert: bool,
#[arg(long, default_value_t = false)]
pub stats: bool,
}
#[derive(Debug, clap::Args)]
pub struct ManifestArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(long, default_value_t = false)]
pub detonated: bool,
#[arg(long, value_name = "DURATION", conflicts_with = "detonated")]
pub ticking: Option<String>,
#[arg(long, value_name = "FORMAT")]
pub format: Option<FormatArg>,
#[arg(long, value_name = "DURATION")]
pub fuse: Option<String>,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long)]
pub blame: bool,
#[arg(long, value_name = "OWNER")]
pub owner: Option<String>,
#[arg(long, value_name = "TAG")]
pub tag: Option<String>,
#[arg(long, value_name = "TEXT")]
pub message: Option<String>,
#[arg(long, value_name = "N")]
pub next: Option<usize>,
#[arg(long, value_name = "BY")]
pub sort: Option<SortBy>,
#[arg(long, value_name = "PATH")]
pub file: Vec<String>,
#[arg(long, num_args = 2, value_names = ["START", "END"])]
pub between: Option<Vec<String>>,
#[arg(long, default_value_t = false, conflicts_with = "path_only")]
pub count: bool,
#[arg(long, default_value_t = false, conflicts_with_all = ["count", "output"])]
pub path_only: bool,
#[arg(long, default_value_t = false)]
pub no_inert: bool,
#[arg(long, default_value_t = false)]
pub owner_missing: bool,
#[arg(long, value_name = "FILE")]
pub output: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct ArmoryArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(
long,
default_value_t = 10,
value_name = "N",
conflicts_with = "oldest"
)]
pub limit: usize,
#[arg(long, default_value_t = false)]
pub oldest: bool,
#[arg(long, default_value_t = false, conflicts_with = "json")]
pub count: bool,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, value_name = "DURATION")]
pub fuse: Option<String>,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long)]
pub blame: bool,
#[arg(long, value_name = "OWNER")]
pub owner: Option<String>,
#[arg(long, value_name = "TAG")]
pub tag: Option<String>,
#[arg(long, value_name = "TEXT")]
pub message: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct PlantArgs {
#[arg(value_name = "FILE[:LINE]")]
pub target: String,
#[arg(value_name = "MESSAGE")]
pub message: String,
#[arg(long, value_name = "PATTERN")]
pub search: Option<String>,
#[arg(long, default_value = "TODO", value_name = "TAG")]
pub tag: String,
#[arg(long, value_name = "OWNER")]
pub owner: Option<String>,
#[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "in_days")]
pub date: Option<String>,
#[arg(long, value_name = "DAYS", conflicts_with = "date")]
pub in_days: Option<u32>,
#[arg(long, default_value_t = false)]
pub yes: bool,
}
#[derive(Debug, clap::Args)]
pub struct DelayArgs {
#[arg(value_name = "FILE[:LINE]")]
pub target: String,
#[arg(long, value_name = "DATE", conflicts_with = "in_days")]
pub date: Option<String>,
#[arg(long, value_name = "DAYS", conflicts_with = "date")]
pub in_days: Option<u32>,
#[arg(long, value_name = "TEXT")]
pub reason: Option<String>,
#[arg(long, value_name = "PATTERN")]
pub search: Option<String>,
#[arg(long, default_value_t = false)]
pub yes: bool,
}
#[derive(Debug, clap::Args)]
pub struct DisarmArgs {
#[arg(value_name = "FILE[:LINE]")]
pub target: Option<String>,
#[arg(long, value_name = "PATTERN", conflicts_with = "all_detonated")]
pub search: Option<String>,
#[arg(long, conflicts_with = "target")]
pub all_detonated: bool,
#[arg(long, default_value = ".", value_name = "PATH")]
pub path: String,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long, short, default_value_t = false)]
pub yes: bool,
}
#[derive(Debug, clap::Args)]
pub struct IntelArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(long, value_name = "DIMENSION")]
pub by: Option<GroupBy>,
#[arg(long, value_name = "FORMAT")]
pub format: Option<FormatArg>,
#[arg(long, value_name = "DURATION")]
pub fuse: Option<String>,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long, value_name = "OWNER")]
pub owner: Option<String>,
#[arg(long, value_name = "TAG")]
pub tag: Option<String>,
#[arg(long, value_name = "TEXT")]
pub message: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct TripwireArgs {
#[command(subcommand)]
pub command: TripwireCommand,
}
#[derive(Debug, Subcommand)]
pub enum TripwireCommand {
Set(TripwireSetArgs),
Cut(TripwireSetArgs),
}
#[derive(Debug, clap::Args)]
pub struct TripwireSetArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(short, long)]
pub yes: bool,
}
#[derive(Debug, clap::Args)]
pub struct FalloutArgs {
pub report_a: String,
pub report_b: String,
#[arg(long, value_name = "FORMAT")]
pub format: Option<FormatArg>,
}
#[derive(Debug, clap::Args)]
pub struct DefuseArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long, value_name = "DURATION")]
pub fuse: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct BunkerArgs {
#[command(subcommand)]
pub command: BaselineCommand,
}
#[derive(Debug, Subcommand)]
pub enum BaselineCommand {
Save(BunkerSaveArgs),
Show(BunkerShowArgs),
}
#[derive(Debug, clap::Args)]
pub struct BunkerSaveArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
pub baseline_file: String,
#[arg(long, value_name = "DURATION")]
pub fuse: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct BunkerShowArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(long, value_name = "FILE")]
pub config: Option<String>,
#[arg(long, default_value = ".timebomb-baseline.json", value_name = "FILE")]
pub baseline_file: String,
#[arg(long, value_name = "DURATION")]
pub fuse: Option<String>,
}
#[derive(Debug, clap::Args)]
pub struct CompletionsArgs {
pub shell: Shell,
}
#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
pub enum SortBy {
Date,
File,
Owner,
Status,
}
#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
pub enum GroupBy {
Owner,
Tag,
Month,
}
#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)]
pub enum FormatArg {
Terminal,
Json,
Github,
Csv,
Table,
}
impl FormatArg {
pub fn to_output_format(&self) -> crate::output::OutputFormat {
match self {
FormatArg::Terminal => crate::output::OutputFormat::Terminal,
FormatArg::Json => crate::output::OutputFormat::Json,
FormatArg::Github => crate::output::OutputFormat::GitHub,
FormatArg::Csv => crate::output::OutputFormat::Csv,
FormatArg::Table => crate::output::OutputFormat::Table,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
fn parse(args: &[&str]) -> Cli {
Cli::parse_from(args)
}
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(args)
}
#[test]
fn test_sweep_defaults() {
let cli = parse(&["timebomb", "sweep"]);
match cli.command {
Command::Sweep(args) => {
assert_eq!(args.path, ".");
assert!(args.fuse.is_none());
assert!(!args.fail_on_ticking);
assert!(args.format.is_none());
assert!(args.config.is_none());
assert!(args.since.is_none());
}
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_custom_path() {
let cli = parse(&["timebomb", "sweep", "./src"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.path, "./src"),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_fuse_flag() {
let cli = parse(&["timebomb", "sweep", "--fuse", "30d"]);
match cli.command {
Command::Sweep(args) => {
assert_eq!(args.fuse, Some("30d".to_string()));
}
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_fail_on_ticking() {
let cli = parse(&["timebomb", "sweep", "--fail-on-ticking"]);
match cli.command {
Command::Sweep(args) => assert!(args.fail_on_ticking),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_format_json() {
let cli = parse(&["timebomb", "sweep", "--format", "json"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Json)),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_format_github() {
let cli = parse(&["timebomb", "sweep", "--format", "github"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Github)),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_format_terminal() {
let cli = parse(&["timebomb", "sweep", "--format", "terminal"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.format, Some(FormatArg::Terminal)),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_config_flag() {
let cli = parse(&["timebomb", "sweep", "--config", "my.toml"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.config, Some("my.toml".to_string())),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_all_flags_combined() {
let cli = parse(&[
"timebomb",
"sweep",
"./src",
"--fuse",
"14d",
"--fail-on-ticking",
"--format",
"json",
"--config",
".timebomb.toml",
]);
match cli.command {
Command::Sweep(args) => {
assert_eq!(args.path, "./src");
assert_eq!(args.fuse, Some("14d".to_string()));
assert!(args.fail_on_ticking);
assert_eq!(args.format, Some(FormatArg::Json));
assert_eq!(args.config, Some(".timebomb.toml".to_string()));
}
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_since_flag() {
let cli = parse(&["timebomb", "sweep", "--since", "main"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.since, Some("main".to_string())),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_since_head() {
let cli = parse(&["timebomb", "sweep", "--since", "HEAD"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.since, Some("HEAD".to_string())),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_owner_flag() {
let cli = parse(&["timebomb", "sweep", "--owner", "alice"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.owner, Some("alice".to_string())),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_manifest_owner_flag() {
let cli = parse(&["timebomb", "manifest", "--owner", "bob"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.owner, Some("bob".to_string())),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_sweep_tag_flag() {
let cli = parse(&["timebomb", "sweep", "--tag", "FIXME"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.tag, Some("FIXME".to_string())),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_message_flag() {
let cli = parse(&["timebomb", "sweep", "--message", "oauth"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.message, Some("oauth".to_string())),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_quiet_flag() {
let cli = parse(&["timebomb", "sweep", "--quiet"]);
match cli.command {
Command::Sweep(args) => assert!(args.quiet),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_quiet_default_false() {
let cli = parse(&["timebomb", "sweep"]);
match cli.command {
Command::Sweep(args) => assert!(!args.quiet),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_manifest_tag_flag() {
let cli = parse(&["timebomb", "manifest", "--tag", "TODO"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.tag, Some("TODO".to_string())),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_message_flag() {
let cli = parse(&["timebomb", "manifest", "--message", "migration"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.message, Some("migration".to_string())),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_next_flag() {
let cli = parse(&["timebomb", "manifest", "--next", "5"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.next, Some(5)),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_next_default_none() {
let cli = parse(&["timebomb", "manifest"]);
match cli.command {
Command::Manifest(args) => assert!(args.next.is_none()),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_armory_defaults() {
let cli = parse(&["timebomb", "armory"]);
match cli.command {
Command::Armory(args) => {
assert_eq!(args.path, ".");
assert_eq!(args.limit, 10);
assert!(!args.oldest);
assert!(!args.count);
assert!(!args.json);
assert!(args.fuse.is_none());
assert!(args.config.is_none());
assert!(!args.blame);
assert!(args.owner.is_none());
assert!(args.tag.is_none());
assert!(args.message.is_none());
}
_ => panic!("expected Armory"),
}
}
#[test]
fn test_armory_all_flags() {
let cli = parse(&[
"timebomb",
"armory",
"./src",
"--limit",
"5",
"--fuse",
"14d",
"--config",
".timebomb.toml",
"--blame",
"--owner",
"alice",
"--tag",
"FIXME",
"--message",
"migration",
]);
match cli.command {
Command::Armory(args) => {
assert_eq!(args.path, "./src");
assert_eq!(args.limit, 5);
assert!(!args.oldest);
assert!(!args.count);
assert!(!args.json);
assert_eq!(args.fuse, Some("14d".to_string()));
assert_eq!(args.config, Some(".timebomb.toml".to_string()));
assert!(args.blame);
assert_eq!(args.owner, Some("alice".to_string()));
assert_eq!(args.tag, Some("FIXME".to_string()));
assert_eq!(args.message, Some("migration".to_string()));
}
_ => panic!("expected Armory"),
}
}
#[test]
fn test_armory_oldest_flag() {
let cli = parse(&["timebomb", "armory", "--oldest"]);
match cli.command {
Command::Armory(args) => assert!(args.oldest),
_ => panic!("expected Armory"),
}
}
#[test]
fn test_armory_count_flag() {
let cli = parse(&["timebomb", "armory", "--count"]);
match cli.command {
Command::Armory(args) => assert!(args.count),
_ => panic!("expected Armory"),
}
}
#[test]
fn test_armory_json_flag() {
let cli = parse(&["timebomb", "armory", "--json"]);
match cli.command {
Command::Armory(args) => assert!(args.json),
_ => panic!("expected Armory"),
}
}
#[test]
fn test_armory_count_conflicts_with_json() {
let result = try_parse(&["timebomb", "armory", "--count", "--json"]);
assert!(result.is_err(), "--count and --json should conflict");
}
#[test]
fn test_armory_oldest_conflicts_with_limit() {
let result = try_parse(&["timebomb", "armory", "--oldest", "--limit", "5"]);
assert!(result.is_err(), "--oldest and --limit should conflict");
}
#[test]
fn test_sweep_summary_flag() {
let cli = parse(&["timebomb", "sweep", "--summary"]);
match cli.command {
Command::Sweep(args) => assert!(args.summary),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_summary_and_quiet_conflict() {
let result = try_parse(&["timebomb", "sweep", "--summary", "--quiet"]);
assert!(result.is_err(), "--summary and --quiet should conflict");
}
#[test]
fn test_sweep_max_detonated_flag() {
let cli = parse(&["timebomb", "sweep", "--max-detonated", "0"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.max_detonated, Some(0)),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_max_ticking_flag() {
let cli = parse(&["timebomb", "sweep", "--max-ticking", "5"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.max_ticking, Some(5)),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_max_flags_default_none() {
let cli = parse(&["timebomb", "sweep"]);
match cli.command {
Command::Sweep(args) => {
assert!(args.max_detonated.is_none());
assert!(args.max_ticking.is_none());
}
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_manifest_sort_date() {
let cli = parse(&["timebomb", "manifest", "--sort", "date"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Date)),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_sort_file() {
let cli = parse(&["timebomb", "manifest", "--sort", "file"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::File)),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_sort_owner() {
let cli = parse(&["timebomb", "manifest", "--sort", "owner"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Owner)),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_sort_status() {
let cli = parse(&["timebomb", "manifest", "--sort", "status"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.sort, Some(SortBy::Status)),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_sort_default_none() {
let cli = parse(&["timebomb", "manifest"]);
match cli.command {
Command::Manifest(args) => assert!(args.sort.is_none()),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_sweep_output_flag() {
let cli = parse(&["timebomb", "sweep", "--output", "report.json"]);
match cli.command {
Command::Sweep(args) => assert_eq!(args.output, Some("report.json".to_string())),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_sweep_output_default_none() {
let cli = parse(&["timebomb", "sweep"]);
match cli.command {
Command::Sweep(args) => assert!(args.output.is_none()),
_ => panic!("expected Sweep"),
}
}
#[test]
fn test_manifest_file_single() {
let cli = parse(&["timebomb", "manifest", "--file", "src/auth/login.rs"]);
match cli.command {
Command::Manifest(args) => {
assert_eq!(args.file, vec!["src/auth/login.rs".to_string()])
}
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_file_multiple() {
let cli = parse(&[
"timebomb",
"manifest",
"--file",
"src/auth/login.rs",
"--file",
"src/db/schema.sql",
]);
match cli.command {
Command::Manifest(args) => {
assert_eq!(
args.file,
vec![
"src/auth/login.rs".to_string(),
"src/db/schema.sql".to_string(),
]
)
}
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_file_default_empty() {
let cli = parse(&["timebomb", "manifest"]);
match cli.command {
Command::Manifest(args) => assert!(args.file.is_empty()),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_between_flag() {
let cli = parse(&[
"timebomb",
"manifest",
"--between",
"2026-01-01",
"2026-06-30",
]);
match cli.command {
Command::Manifest(args) => {
let dates = args.between.unwrap();
assert_eq!(dates[0], "2026-01-01");
assert_eq!(dates[1], "2026-06-30");
}
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_between_default_none() {
let cli = parse(&["timebomb", "manifest"]);
match cli.command {
Command::Manifest(args) => assert!(args.between.is_none()),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_count_flag() {
let cli = parse(&["timebomb", "manifest", "--count"]);
match cli.command {
Command::Manifest(args) => assert!(args.count),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_count_default_false() {
let cli = parse(&["timebomb", "manifest"]);
match cli.command {
Command::Manifest(args) => assert!(!args.count),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_path_only_flag() {
let cli = parse(&["timebomb", "manifest", "--path-only"]);
match cli.command {
Command::Manifest(args) => assert!(args.path_only),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_path_only_conflicts_with_count() {
let result = try_parse(&["timebomb", "manifest", "--path-only", "--count"]);
assert!(result.is_err(), "--path-only and --count should conflict");
}
#[test]
fn test_manifest_path_only_conflicts_with_output() {
let result = try_parse(&[
"timebomb",
"manifest",
"--path-only",
"--output",
"fuses.json",
]);
assert!(result.is_err(), "--path-only and --output should conflict");
}
#[test]
fn test_plant_message_positional() {
let cli = parse(&[
"timebomb",
"plant",
"src/main.rs:42",
"--in-days",
"90",
"the message",
]);
match cli.command {
Command::Plant(args) => {
assert_eq!(args.target, "src/main.rs:42");
assert_eq!(args.message, "the message");
assert_eq!(args.in_days, Some(90));
}
_ => panic!("expected Plant"),
}
}
#[test]
fn test_plant_with_search() {
let cli = parse(&[
"timebomb",
"plant",
"src/foo.rs",
"--search",
"legacy_auth",
"--in-days",
"30",
"msg",
]);
match cli.command {
Command::Plant(args) => {
assert_eq!(args.target, "src/foo.rs");
assert_eq!(args.search, Some("legacy_auth".to_string()));
assert_eq!(args.in_days, Some(30));
assert_eq!(args.message, "msg");
}
_ => panic!("expected Plant"),
}
}
#[test]
fn test_plant_defaults() {
let cli = parse(&["timebomb", "plant", "src/main.rs:42", "remove this"]);
match cli.command {
Command::Plant(args) => {
assert_eq!(args.target, "src/main.rs:42");
assert_eq!(args.message, "remove this");
assert_eq!(args.tag, "TODO");
assert!(args.owner.is_none());
assert!(args.date.is_none());
assert!(args.in_days.is_none());
assert!(!args.yes);
assert!(args.search.is_none());
}
_ => panic!("expected Plant"),
}
}
#[test]
fn test_plant_all_flags() {
let cli = parse(&[
"timebomb",
"plant",
"src/auth.rs:10",
"remove oauth flow",
"--tag",
"FIXME",
"--owner",
"alice",
"--date",
"2026-09-01",
"--yes",
]);
match cli.command {
Command::Plant(args) => {
assert_eq!(args.target, "src/auth.rs:10");
assert_eq!(args.message, "remove oauth flow");
assert_eq!(args.tag, "FIXME");
assert_eq!(args.owner, Some("alice".to_string()));
assert_eq!(args.date, Some("2026-09-01".to_string()));
assert!(args.yes);
}
_ => panic!("expected Plant"),
}
}
#[test]
fn test_plant_in_days() {
let cli = parse(&[
"timebomb",
"plant",
"src/lib.rs:1",
"cleanup",
"--in-days",
"90",
]);
match cli.command {
Command::Plant(args) => assert_eq!(args.in_days, Some(90)),
_ => panic!("expected Plant"),
}
}
#[test]
fn test_plant_date_and_in_days_conflict() {
let result = try_parse(&[
"timebomb",
"plant",
"src/lib.rs:1",
"cleanup",
"--date",
"2026-01-01",
"--in-days",
"30",
]);
assert!(result.is_err(), "--date and --in-days should conflict");
}
#[test]
fn test_delay_defaults() {
let cli = parse(&["timebomb", "delay", "src/main.rs:42", "--in-days", "30"]);
match cli.command {
Command::Delay(args) => {
assert_eq!(args.target, "src/main.rs:42");
assert_eq!(args.in_days, Some(30));
assert!(args.date.is_none());
assert!(args.reason.is_none());
assert!(args.search.is_none());
assert!(!args.yes);
}
_ => panic!("expected Delay"),
}
}
#[test]
fn test_delay_with_search() {
let cli = parse(&[
"timebomb",
"delay",
"src/main.rs",
"--search",
"pattern",
"--in-days",
"30",
]);
match cli.command {
Command::Delay(args) => {
assert_eq!(args.target, "src/main.rs");
assert_eq!(args.search, Some("pattern".to_string()));
assert_eq!(args.in_days, Some(30));
}
_ => panic!("expected Delay"),
}
}
#[test]
fn test_delay_with_date() {
let cli = parse(&[
"timebomb",
"delay",
"src/main.rs:42",
"--date",
"2027-01-01",
]);
match cli.command {
Command::Delay(args) => {
assert_eq!(args.date, Some("2027-01-01".to_string()));
assert!(args.in_days.is_none());
}
_ => panic!("expected Delay"),
}
}
#[test]
fn test_delay_with_reason() {
let cli = parse(&[
"timebomb",
"delay",
"src/main.rs:42",
"--in-days",
"30",
"--reason",
"blocked upstream",
]);
match cli.command {
Command::Delay(args) => {
assert_eq!(args.reason, Some("blocked upstream".to_string()));
}
_ => panic!("expected Delay"),
}
}
#[test]
fn test_disarm_by_target() {
let cli = parse(&["timebomb", "disarm", "src/main.rs:42"]);
match cli.command {
Command::Disarm(args) => {
assert_eq!(args.target, Some("src/main.rs:42".to_string()));
assert!(args.search.is_none());
assert!(!args.all_detonated);
}
_ => panic!("expected Disarm"),
}
}
#[test]
fn test_disarm_with_search() {
let cli = parse(&["timebomb", "disarm", "src/main.rs", "--search", "pattern"]);
match cli.command {
Command::Disarm(args) => {
assert_eq!(args.target, Some("src/main.rs".to_string()));
assert_eq!(args.search, Some("pattern".to_string()));
}
_ => panic!("expected Disarm"),
}
}
#[test]
fn test_disarm_all_detonated() {
let cli = parse(&["timebomb", "disarm", "--all-detonated", "--path", "./src"]);
match cli.command {
Command::Disarm(args) => {
assert!(args.all_detonated);
assert_eq!(args.path, "./src");
assert!(args.target.is_none());
}
_ => panic!("expected Disarm"),
}
}
#[test]
fn test_disarm_all_detonated_default_path() {
let cli = parse(&["timebomb", "disarm", "--all-detonated"]);
match cli.command {
Command::Disarm(args) => {
assert!(args.all_detonated);
assert_eq!(args.path, ".");
}
_ => panic!("expected Disarm"),
}
}
#[test]
fn test_disarm_yes_flag() {
let cli = parse(&["timebomb", "disarm", "src/main.rs:42", "--yes"]);
match cli.command {
Command::Disarm(args) => assert!(args.yes),
_ => panic!("expected Disarm"),
}
}
#[test]
fn test_intel_defaults() {
let cli = parse(&["timebomb", "intel"]);
match cli.command {
Command::Intel(args) => {
assert_eq!(args.path, ".");
assert!(args.by.is_none());
assert!(args.format.is_none());
assert!(args.fuse.is_none());
assert!(args.config.is_none());
assert!(args.message.is_none());
}
_ => panic!("expected Intel"),
}
}
#[test]
fn test_intel_by_owner() {
let cli = parse(&["timebomb", "intel", "--by", "owner"]);
match cli.command {
Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Owner)),
_ => panic!("expected Intel"),
}
}
#[test]
fn test_intel_by_tag() {
let cli = parse(&["timebomb", "intel", "--by", "tag"]);
match cli.command {
Command::Intel(args) => assert_eq!(args.by, Some(GroupBy::Tag)),
_ => panic!("expected Intel"),
}
}
#[test]
fn test_intel_all_flags() {
let cli = parse(&[
"timebomb",
"intel",
"./src",
"--by",
"owner",
"--format",
"json",
"--fuse",
"14d",
"--config",
"custom.toml",
"--message",
"cleanup",
]);
match cli.command {
Command::Intel(args) => {
assert_eq!(args.path, "./src");
assert_eq!(args.by, Some(GroupBy::Owner));
assert_eq!(args.format, Some(FormatArg::Json));
assert_eq!(args.fuse, Some("14d".to_string()));
assert_eq!(args.config, Some("custom.toml".to_string()));
assert_eq!(args.message, Some("cleanup".to_string()));
}
_ => panic!("expected Intel"),
}
}
#[test]
fn test_manifest_defaults() {
let cli = parse(&["timebomb", "manifest"]);
match cli.command {
Command::Manifest(args) => {
assert_eq!(args.path, ".");
assert!(!args.detonated);
assert!(args.ticking.is_none());
assert!(args.format.is_none());
assert!(args.fuse.is_none());
assert!(args.config.is_none());
assert!(args.message.is_none());
}
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_detonated_flag() {
let cli = parse(&["timebomb", "manifest", "--detonated"]);
match cli.command {
Command::Manifest(args) => assert!(args.detonated),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_ticking() {
let cli = parse(&["timebomb", "manifest", "--ticking", "14d"]);
match cli.command {
Command::Manifest(args) => {
assert_eq!(args.ticking, Some("14d".to_string()));
assert!(!args.detonated);
}
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_detonated_and_ticking_conflict() {
let result = try_parse(&["timebomb", "manifest", "--detonated", "--ticking", "14d"]);
assert!(result.is_err(), "conflicting flags should produce an error");
}
#[test]
fn test_manifest_format_json() {
let cli = parse(&["timebomb", "manifest", "--format", "json"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.format, Some(FormatArg::Json)),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_fuse_flag() {
let cli = parse(&["timebomb", "manifest", "--fuse", "7d"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.fuse, Some("7d".to_string())),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_custom_path() {
let cli = parse(&["timebomb", "manifest", "./my/project"]);
match cli.command {
Command::Manifest(args) => assert_eq!(args.path, "./my/project"),
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_manifest_all_flags_combined() {
let cli = parse(&[
"timebomb",
"manifest",
"./src",
"--detonated",
"--format",
"github",
"--fuse",
"30d",
"--config",
"custom.toml",
"--message",
"migration",
]);
match cli.command {
Command::Manifest(args) => {
assert_eq!(args.path, "./src");
assert!(args.detonated);
assert_eq!(args.format, Some(FormatArg::Github));
assert_eq!(args.fuse, Some("30d".to_string()));
assert_eq!(args.config, Some("custom.toml".to_string()));
assert_eq!(args.message, Some("migration".to_string()));
}
_ => panic!("expected Manifest"),
}
}
#[test]
fn test_format_arg_to_output_format_terminal() {
assert_eq!(
FormatArg::Terminal.to_output_format(),
crate::output::OutputFormat::Terminal
);
}
#[test]
fn test_format_arg_to_output_format_json() {
assert_eq!(
FormatArg::Json.to_output_format(),
crate::output::OutputFormat::Json
);
}
#[test]
fn test_format_arg_to_output_format_github() {
assert_eq!(
FormatArg::Github.to_output_format(),
crate::output::OutputFormat::GitHub
);
}
#[test]
fn test_unknown_subcommand_is_error() {
let result = try_parse(&["timebomb", "run"]);
assert!(result.is_err());
}
#[test]
fn test_no_subcommand_is_error() {
let result = try_parse(&["timebomb"]);
assert!(result.is_err());
}
}