Skip to main content

max / makenotwork

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