Skip to main content

zerodds_monitor_cli/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! `zerodds-monitor-cli` library — argument parsing for the
5//! `zerodds-monitor` Binary.
6//!
7//! Crate `zerodds-monitor-cli`. Safety classification: **COMFORT**.
8//! User-facing Monitoring-CLI; backend lives in `zerodds-monitor`.
9//!
10//! The tool uses the backend in `crates/monitor/` (published as
11//! `zerodds-monitor`). The separation between tool and backend
12//! crate is needed so both consumers (library user and CLI
13//! user) resolve unambiguously.
14
15#![allow(clippy::module_name_repetitions)]
16
17use std::time::Duration;
18
19/// Default listen address for `monitor serve`.
20pub const DEFAULT_ADDR: &str = "127.0.0.1:9991";
21/// Default Snapshot-Duration.
22pub const DEFAULT_DURATION_SECS: u64 = 5;
23
24/// Sub-command of the monitor CLI.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum Command {
27    /// `snapshot` — starts the runtime, collects metrics for
28    /// `--duration`, prints registry content.
29    Snapshot(SnapshotArgs),
30    /// `serve` — starts the runtime + HTTP server on `--addr` for
31    /// `--duration` (or until SIGINT).
32    Serve(ServeArgs),
33    /// `names` — prints known metric names with HELP texts.
34    Names,
35}
36
37/// Arguments for `snapshot`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SnapshotArgs {
40    /// DDS-Domain.
41    pub domain: u32,
42    /// Collection duration.
43    pub duration: Duration,
44    /// Output-Format.
45    pub format: SnapshotFormat,
46}
47
48/// Snapshot-Output-Format.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SnapshotFormat {
51    /// Human-readable text tree.
52    Text,
53    /// Prometheus exposition (same format as `/metrics`).
54    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/// Arguments for `serve`.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ServeArgs {
70    /// DDS-Domain.
71    pub domain: u32,
72    /// Listen address.
73    pub addr: String,
74    /// Maximum lifetime; `None` = until SIGINT.
75    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/// Parse error.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum ParseError {
91    /// No subcommand.
92    Missing,
93    /// Unknown subcommand.
94    Unknown(String),
95    /// Required arg missing.
96    MissingArg(&'static str),
97    /// Value not parseable.
98    BadValue {
99        /// Which flag.
100        flag: &'static str,
101        /// What was entered.
102        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
119/// Parses the `args` slice (without `argv[0]`) into a [`Command`].
120///
121/// # Errors
122/// See [`ParseError`].
123pub 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}