Skip to main content

max / makenotwork

4.4 KB · 163 lines History Blame Raw
1 //! CLI argument parsing via clap.
2
3 use clap::{Parser, Subcommand};
4
5 use crate::types::{Channel, Priority, Status};
6
7 #[derive(Parser)]
8 #[command(name = "wam", about = "Whack-a-Mole -- distributed ticket manager")]
9 pub struct Cli {
10 #[command(subcommand)]
11 pub command: Option<Command>,
12 }
13
14 #[derive(Subcommand)]
15 pub enum Command {
16 /// Create a new ticket
17 Create {
18 /// Ticket title
19 #[arg(short, long)]
20 title: String,
21 /// Ticket body / description
22 #[arg(short, long)]
23 body: Option<String>,
24 /// Priority (low, medium, high, critical)
25 #[arg(short, long, default_value = "medium")]
26 priority: Priority,
27 /// Channel (system, request, task)
28 #[arg(short, long, default_value = "task")]
29 channel: Channel,
30 /// Source system (e.g. "refund-escalation", "pom")
31 #[arg(short, long, default_value = "manual")]
32 source: String,
33 /// Reference ID in the source system
34 #[arg(long)]
35 source_ref: Option<String>,
36 },
37 /// List tickets
38 List {
39 /// Filter by status
40 #[arg(short, long)]
41 status: Option<Status>,
42 /// Filter by priority
43 #[arg(short, long)]
44 priority: Option<Priority>,
45 /// Filter by channel
46 #[arg(short, long)]
47 channel: Option<Channel>,
48 /// Filter by source
49 #[arg(long)]
50 source: Option<String>,
51 },
52 /// Show ticket details
53 Show {
54 /// Ticket ID (or unique prefix)
55 id: String,
56 },
57 /// Mark a ticket as resolved
58 Resolve {
59 /// Ticket ID (or unique prefix)
60 id: String,
61 },
62 /// Mark a ticket as closed
63 Close {
64 /// Ticket ID (or unique prefix)
65 id: String,
66 },
67 /// Start the HTTP API server with optional peer sync
68 Serve {
69 /// Port to listen on
70 #[arg(short, long, default_value = "7890")]
71 port: u16,
72 /// Peer WAM URLs to sync with (repeatable)
73 #[arg(long)]
74 peer: Vec<String>,
75 },
76 /// Show ticket aggregates: open by priority/source + avg resolution time
77 Stats,
78 /// Dump tickets as JSON or CSV
79 Export {
80 /// Output format
81 #[arg(long, default_value = "json")]
82 format: ExportFormat,
83 /// Output file. Defaults to stdout.
84 #[arg(short, long)]
85 output: Option<std::path::PathBuf>,
86 },
87 /// Delete tickets matching the given status that have been untouched for
88 /// at least `--older-than`. Duration format: `90d`, `12h`, `30m`.
89 Prune {
90 /// Age threshold (e.g. `90d`, `12h`, `30m`)
91 #[arg(long)]
92 older_than: String,
93 /// Status to prune (defaults to closed)
94 #[arg(long, default_value = "closed")]
95 status: Status,
96 /// Print what would be deleted without modifying the database
97 #[arg(long)]
98 dry_run: bool,
99 },
100 }
101
102 #[derive(Clone, Copy, Debug, clap::ValueEnum)]
103 pub enum ExportFormat {
104 Json,
105 Csv,
106 }
107
108 /// Parse a duration string like `90d`, `12h`, `30m` into `chrono::Duration`.
109 pub fn parse_duration(s: &str) -> Result<chrono::Duration, String> {
110 let s = s.trim();
111 if s.is_empty() {
112 return Err("duration is empty".to_string());
113 }
114 let (num, unit) = s.split_at(s.len() - 1);
115 let n: i64 = num
116 .parse()
117 .map_err(|_| format!("invalid duration number in '{s}'"))?;
118 if n < 0 {
119 return Err(format!("duration must be non-negative: '{s}'"));
120 }
121 match unit {
122 "d" => Ok(chrono::Duration::days(n)),
123 "h" => Ok(chrono::Duration::hours(n)),
124 "m" => Ok(chrono::Duration::minutes(n)),
125 other => Err(format!("unknown duration unit '{other}' (use d/h/m)")),
126 }
127 }
128
129 #[cfg(test)]
130 mod tests {
131 use super::*;
132
133 #[test]
134 fn parse_duration_days() {
135 assert_eq!(parse_duration("90d").unwrap(), chrono::Duration::days(90));
136 }
137
138 #[test]
139 fn parse_duration_hours() {
140 assert_eq!(parse_duration("12h").unwrap(), chrono::Duration::hours(12));
141 }
142
143 #[test]
144 fn parse_duration_minutes() {
145 assert_eq!(parse_duration("30m").unwrap(), chrono::Duration::minutes(30));
146 }
147
148 #[test]
149 fn parse_duration_rejects_unknown_unit() {
150 assert!(parse_duration("5y").is_err());
151 }
152
153 #[test]
154 fn parse_duration_rejects_negative() {
155 assert!(parse_duration("-1d").is_err());
156 }
157
158 #[test]
159 fn parse_duration_rejects_empty() {
160 assert!(parse_duration("").is_err());
161 }
162 }
163