1#![allow(clippy::module_name_repetitions)]
16
17use std::time::Duration;
18
19pub const DEFAULT_ADDR: &str = "127.0.0.1:9991";
21pub const DEFAULT_DURATION_SECS: u64 = 5;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Command {
27 Snapshot(SnapshotArgs),
30 Serve(ServeArgs),
33 Names,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SnapshotArgs {
40 pub domain: u32,
42 pub duration: Duration,
44 pub format: SnapshotFormat,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SnapshotFormat {
51 Text,
53 Prometheus,
55}
56
57impl Default for SnapshotArgs {
58 fn default() -> Self {
59 Self {
60 domain: 0,
61 duration: Duration::from_secs(DEFAULT_DURATION_SECS),
62 format: SnapshotFormat::Text,
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ServeArgs {
70 pub domain: u32,
72 pub addr: String,
74 pub duration: Option<Duration>,
76}
77
78impl Default for ServeArgs {
79 fn default() -> Self {
80 Self {
81 domain: 0,
82 addr: DEFAULT_ADDR.to_string(),
83 duration: None,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum ParseError {
91 Missing,
93 Unknown(String),
95 MissingArg(&'static str),
97 BadValue {
99 flag: &'static str,
101 got: String,
103 },
104}
105
106impl std::fmt::Display for ParseError {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 match self {
109 Self::Missing => write!(f, "no sub-command given"),
110 Self::Unknown(s) => write!(f, "unknown sub-command: {s}"),
111 Self::MissingArg(a) => write!(f, "missing required arg: {a}"),
112 Self::BadValue { flag, got } => write!(f, "bad value for --{flag}: {got}"),
113 }
114 }
115}
116
117impl std::error::Error for ParseError {}
118
119pub fn parse_args(args: &[String]) -> Result<Command, ParseError> {
124 let sub = args.first().ok_or(ParseError::Missing)?;
125 match sub.as_str() {
126 "snapshot" => parse_snapshot(&args[1..]).map(Command::Snapshot),
127 "serve" => parse_serve(&args[1..]).map(Command::Serve),
128 "names" => {
129 if args.len() > 1 {
130 return Err(ParseError::Unknown(args[1].clone()));
131 }
132 Ok(Command::Names)
133 }
134 other => Err(ParseError::Unknown(other.to_string())),
135 }
136}
137
138fn parse_snapshot(rest: &[String]) -> Result<SnapshotArgs, ParseError> {
139 let mut out = SnapshotArgs::default();
140 let mut i = 0;
141 while i < rest.len() {
142 match rest[i].as_str() {
143 "--domain" | "-d" => {
144 i += 1;
145 let v = rest.get(i).ok_or(ParseError::MissingArg("domain"))?;
146 out.domain = v.parse().map_err(|_| ParseError::BadValue {
147 flag: "domain",
148 got: v.clone(),
149 })?;
150 }
151 "--duration" => {
152 i += 1;
153 let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
154 out.duration =
155 zerodds_cli_common::parse_duration(v).map_err(|_| ParseError::BadValue {
156 flag: "duration",
157 got: v.clone(),
158 })?;
159 }
160 "--format" | "-f" => {
161 i += 1;
162 let v = rest.get(i).ok_or(ParseError::MissingArg("format"))?;
163 out.format = match v.as_str() {
164 "text" => SnapshotFormat::Text,
165 "prometheus" | "prom" => SnapshotFormat::Prometheus,
166 other => {
167 return Err(ParseError::BadValue {
168 flag: "format",
169 got: other.to_string(),
170 });
171 }
172 };
173 }
174 other => return Err(ParseError::Unknown(other.to_string())),
175 }
176 i += 1;
177 }
178 Ok(out)
179}
180
181fn parse_serve(rest: &[String]) -> Result<ServeArgs, ParseError> {
182 let mut out = ServeArgs::default();
183 let mut i = 0;
184 while i < rest.len() {
185 match rest[i].as_str() {
186 "--domain" | "-d" => {
187 i += 1;
188 let v = rest.get(i).ok_or(ParseError::MissingArg("domain"))?;
189 out.domain = v.parse().map_err(|_| ParseError::BadValue {
190 flag: "domain",
191 got: v.clone(),
192 })?;
193 }
194 "--addr" | "-a" => {
195 i += 1;
196 out.addr = rest.get(i).ok_or(ParseError::MissingArg("addr"))?.clone();
197 }
198 "--duration" => {
199 i += 1;
200 let v = rest.get(i).ok_or(ParseError::MissingArg("duration"))?;
201 out.duration = Some(zerodds_cli_common::parse_duration(v).map_err(|_| {
202 ParseError::BadValue {
203 flag: "duration",
204 got: v.clone(),
205 }
206 })?);
207 }
208 other => return Err(ParseError::Unknown(other.to_string())),
209 }
210 i += 1;
211 }
212 Ok(out)
213}
214
215#[cfg(test)]
216#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
217mod tests {
218 use super::*;
219
220 fn s(args: &[&str]) -> Vec<String> {
221 args.iter().map(|s| (*s).to_string()).collect()
222 }
223
224 #[test]
225 fn parse_snapshot_default() {
226 let cmd = parse_args(&s(&["snapshot"])).unwrap();
227 assert_eq!(cmd, Command::Snapshot(SnapshotArgs::default()));
228 }
229
230 #[test]
231 fn parse_snapshot_full() {
232 let cmd = parse_args(&s(&[
233 "snapshot",
234 "-d",
235 "5",
236 "--duration",
237 "30s",
238 "-f",
239 "prom",
240 ]))
241 .unwrap();
242 let Command::Snapshot(s_args) = cmd else {
243 panic!("expected snapshot");
244 };
245 assert_eq!(s_args.domain, 5);
246 assert_eq!(s_args.duration, Duration::from_secs(30));
247 assert_eq!(s_args.format, SnapshotFormat::Prometheus);
248 }
249
250 #[test]
251 fn parse_serve_with_addr() {
252 let cmd = parse_args(&s(&["serve", "-a", "0.0.0.0:9000", "--duration", "1s"])).unwrap();
253 let Command::Serve(srv) = cmd else {
254 panic!("expected serve");
255 };
256 assert_eq!(srv.addr, "0.0.0.0:9000");
257 assert_eq!(srv.duration, Some(Duration::from_secs(1)));
258 }
259
260 #[test]
261 fn parse_names_no_args() {
262 assert_eq!(parse_args(&s(&["names"])).unwrap(), Command::Names);
263 }
264
265 #[test]
266 fn parse_no_args_rejected() {
267 assert!(matches!(parse_args(&[]), Err(ParseError::Missing)));
268 }
269
270 #[test]
271 fn parse_unknown_subcommand_rejected() {
272 assert!(matches!(
273 parse_args(&s(&["unknown"])),
274 Err(ParseError::Unknown(_))
275 ));
276 }
277
278 #[test]
279 fn parse_bad_format_rejected() {
280 assert!(matches!(
281 parse_args(&s(&["snapshot", "--format", "json"])),
282 Err(ParseError::BadValue { .. })
283 ));
284 }
285}