Skip to main content

max / makenotwork

5.0 KB · 175 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(crate) struct Cli {
10 #[command(subcommand)]
11 pub command: Option<Command>,
12 }
13
14 #[derive(Subcommand)]
15 pub(crate) 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 /// Pain: how much it hurts a hit user, 1 (annoyance) to 5 (blocker)
25 #[arg(short, long, default_value = "3")]
26 pain: u8,
27 /// Scale: how broadly it hits, 1 (isolated) to 5 (widespread)
28 #[arg(long, default_value = "3")]
29 scale: u8,
30 /// Channel (system, request, task)
31 #[arg(short, long, default_value = "task")]
32 channel: Channel,
33 /// Source system (e.g. "refund-escalation", "pom")
34 #[arg(short, long, default_value = "manual")]
35 source: String,
36 /// Reference ID in the source system
37 #[arg(long)]
38 source_ref: Option<String>,
39 },
40 /// List tickets
41 List {
42 /// Filter by status
43 #[arg(short, long)]
44 status: Option<Status>,
45 /// Filter by painhours band (low, medium, high, critical)
46 #[arg(short, long)]
47 priority: Option<Priority>,
48 /// Filter by channel
49 #[arg(short, long)]
50 channel: Option<Channel>,
51 /// Filter by source
52 #[arg(long)]
53 source: Option<String>,
54 },
55 /// Show ticket details
56 Show {
57 /// Ticket ID (or unique prefix)
58 id: String,
59 },
60 /// Mark a ticket as resolved
61 Resolve {
62 /// Ticket ID (or unique prefix)
63 id: String,
64 },
65 /// Mark a ticket as closed
66 Close {
67 /// Ticket ID (or unique prefix)
68 id: String,
69 },
70 /// Start the HTTP API server with optional peer sync
71 Serve {
72 /// Port to listen on
73 #[arg(short, long, default_value = "7890")]
74 port: u16,
75 /// Peer WAM URLs to sync with (repeatable)
76 #[arg(long)]
77 peer: Vec<String>,
78 /// Shared bearer token required on every request (and presented to
79 /// peers). Falls back to the WAM_TOKEN env var, then a token persisted
80 /// beside the database, then a freshly generated one. The API always
81 /// requires a token; it never serves unauthenticated.
82 #[arg(long)]
83 token: Option<String>,
84 },
85 /// Show ticket aggregates: open by painhours band/source + avg resolution time
86 Stats,
87 /// Dump tickets as JSON or CSV
88 Export {
89 /// Output format
90 #[arg(long, default_value = "json")]
91 format: ExportFormat,
92 /// Output file. Defaults to stdout.
93 #[arg(short, long)]
94 output: Option<std::path::PathBuf>,
95 },
96 /// Delete tickets matching the given status that have been untouched for
97 /// at least `--older-than`. Duration format: `90d`, `12h`, `30m`.
98 Prune {
99 /// Age threshold (e.g. `90d`, `12h`, `30m`)
100 #[arg(long)]
101 older_than: String,
102 /// Status to prune (defaults to closed)
103 #[arg(long, default_value = "closed")]
104 status: Status,
105 /// Print what would be deleted without modifying the database
106 #[arg(long)]
107 dry_run: bool,
108 },
109 }
110
111 #[derive(Clone, Copy, Debug, clap::ValueEnum)]
112 pub(crate) enum ExportFormat {
113 Json,
114 Csv,
115 }
116
117 /// Parse a duration string like `90d`, `12h`, `30m` into `chrono::Duration`.
118 pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration, String> {
119 let s = s.trim();
120 if s.is_empty() {
121 return Err("duration is empty".to_string());
122 }
123 let (num, unit) = s.split_at(s.len() - 1);
124 let n: i64 = num
125 .parse()
126 .map_err(|_| format!("invalid duration number in '{s}'"))?;
127 if n < 0 {
128 return Err(format!("duration must be non-negative: '{s}'"));
129 }
130 match unit {
131 "d" => Ok(chrono::Duration::days(n)),
132 "h" => Ok(chrono::Duration::hours(n)),
133 "m" => Ok(chrono::Duration::minutes(n)),
134 other => Err(format!("unknown duration unit '{other}' (use d/h/m)")),
135 }
136 }
137
138 #[cfg(test)]
139 mod tests {
140 use super::*;
141
142 #[test]
143 fn parse_duration_days() {
144 assert_eq!(parse_duration("90d").unwrap(), chrono::Duration::days(90));
145 }
146
147 #[test]
148 fn parse_duration_hours() {
149 assert_eq!(parse_duration("12h").unwrap(), chrono::Duration::hours(12));
150 }
151
152 #[test]
153 fn parse_duration_minutes() {
154 assert_eq!(
155 parse_duration("30m").unwrap(),
156 chrono::Duration::minutes(30)
157 );
158 }
159
160 #[test]
161 fn parse_duration_rejects_unknown_unit() {
162 assert!(parse_duration("5y").is_err());
163 }
164
165 #[test]
166 fn parse_duration_rejects_negative() {
167 assert!(parse_duration("-1d").is_err());
168 }
169
170 #[test]
171 fn parse_duration_rejects_empty() {
172 assert!(parse_duration("").is_err());
173 }
174 }
175