//! CLI argument parsing via clap. use clap::{Parser, Subcommand}; use crate::types::{Channel, Priority, Status}; #[derive(Parser)] #[command(name = "wam", about = "Whack-a-Mole -- distributed ticket manager")] pub struct Cli { #[command(subcommand)] pub command: Option, } #[derive(Subcommand)] pub enum Command { /// Create a new ticket Create { /// Ticket title #[arg(short, long)] title: String, /// Ticket body / description #[arg(short, long)] body: Option, /// Priority (low, medium, high, critical) #[arg(short, long, default_value = "medium")] priority: Priority, /// Channel (system, request, task) #[arg(short, long, default_value = "task")] channel: Channel, /// Source system (e.g. "refund-escalation", "pom") #[arg(short, long, default_value = "manual")] source: String, /// Reference ID in the source system #[arg(long)] source_ref: Option, }, /// List tickets List { /// Filter by status #[arg(short, long)] status: Option, /// Filter by priority #[arg(short, long)] priority: Option, /// Filter by channel #[arg(short, long)] channel: Option, /// Filter by source #[arg(long)] source: Option, }, /// Show ticket details Show { /// Ticket ID (or unique prefix) id: String, }, /// Mark a ticket as resolved Resolve { /// Ticket ID (or unique prefix) id: String, }, /// Mark a ticket as closed Close { /// Ticket ID (or unique prefix) id: String, }, /// Start the HTTP API server with optional peer sync Serve { /// Port to listen on #[arg(short, long, default_value = "7890")] port: u16, /// Peer WAM URLs to sync with (repeatable) #[arg(long)] peer: Vec, }, /// Show ticket aggregates: open by priority/source + avg resolution time Stats, /// Dump tickets as JSON or CSV Export { /// Output format #[arg(long, default_value = "json")] format: ExportFormat, /// Output file. Defaults to stdout. #[arg(short, long)] output: Option, }, /// Delete tickets matching the given status that have been untouched for /// at least `--older-than`. Duration format: `90d`, `12h`, `30m`. Prune { /// Age threshold (e.g. `90d`, `12h`, `30m`) #[arg(long)] older_than: String, /// Status to prune (defaults to closed) #[arg(long, default_value = "closed")] status: Status, /// Print what would be deleted without modifying the database #[arg(long)] dry_run: bool, }, } #[derive(Clone, Copy, Debug, clap::ValueEnum)] pub enum ExportFormat { Json, Csv, } /// Parse a duration string like `90d`, `12h`, `30m` into `chrono::Duration`. pub fn parse_duration(s: &str) -> Result { let s = s.trim(); if s.is_empty() { return Err("duration is empty".to_string()); } let (num, unit) = s.split_at(s.len() - 1); let n: i64 = num .parse() .map_err(|_| format!("invalid duration number in '{s}'"))?; if n < 0 { return Err(format!("duration must be non-negative: '{s}'")); } match unit { "d" => Ok(chrono::Duration::days(n)), "h" => Ok(chrono::Duration::hours(n)), "m" => Ok(chrono::Duration::minutes(n)), other => Err(format!("unknown duration unit '{other}' (use d/h/m)")), } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_duration_days() { assert_eq!(parse_duration("90d").unwrap(), chrono::Duration::days(90)); } #[test] fn parse_duration_hours() { assert_eq!(parse_duration("12h").unwrap(), chrono::Duration::hours(12)); } #[test] fn parse_duration_minutes() { assert_eq!(parse_duration("30m").unwrap(), chrono::Duration::minutes(30)); } #[test] fn parse_duration_rejects_unknown_unit() { assert!(parse_duration("5y").is_err()); } #[test] fn parse_duration_rejects_negative() { assert!(parse_duration("-1d").is_err()); } #[test] fn parse_duration_rejects_empty() { assert!(parse_duration("").is_err()); } }