Skip to main content

mabi_cli/
validation.rs

1//! Reusable CLI argument validators.
2//!
3//! Provides `value_parser` compatible functions for clap argument validation.
4//! Each validator returns `Result<T, String>` as required by clap.
5
6use mabi_core::tags::{parse_tag_string, Tags};
7
8/// Validates that a port number is within the usable range (1–65535).
9///
10/// Port 0 is rejected because it causes OS-assigned ephemeral port binding,
11/// which is not meaningful for a simulator that clients need to connect to.
12pub fn parse_port(s: &str) -> Result<u16, String> {
13    let port: u16 = s
14        .parse()
15        .map_err(|_| format!("'{s}' is not a valid port number"))?;
16    if port == 0 {
17        return Err("port must be between 1 and 65535 (port 0 is not allowed)".to_string());
18    }
19    Ok(port)
20}
21
22/// Validates that a count value is at least 1.
23///
24/// Zero-count resources (devices, nodes, groups) produce a server
25/// with nothing to simulate, which is almost certainly a user mistake.
26pub fn parse_nonzero_count(s: &str) -> Result<usize, String> {
27    let n: usize = s
28        .parse()
29        .map_err(|_| format!("'{s}' is not a valid number"))?;
30    if n == 0 {
31        return Err("value must be at least 1".to_string());
32    }
33    Ok(n)
34}
35
36/// Validates that a count value is zero or greater.
37///
38/// BACnet uses this for demo/sample object counts: zero means the server
39/// exposes only the mandatory Device object and does not create sample points.
40pub fn parse_zero_or_more_count(s: &str) -> Result<usize, String> {
41    s.parse()
42        .map_err(|_| format!("'{s}' is not a valid number"))
43}
44
45/// Tag entry parsed from CLI argument.
46///
47/// Represents either a key-value tag or a label (key only).
48#[derive(Debug, Clone)]
49pub struct TagEntry {
50    pub key: String,
51    pub value: Option<String>,
52}
53
54/// Parses a tag argument in the format "key=value" or "label".
55///
56/// - "location=building-a" -> TagEntry { key: "location", value: Some("building-a") }
57/// - "critical" -> TagEntry { key: "critical", value: None } (label)
58pub fn parse_tag(s: &str) -> Result<TagEntry, String> {
59    let (key, value) = parse_tag_string(s)?;
60    Ok(TagEntry { key, value })
61}
62
63/// Converts a slice of TagEntry into a Tags object.
64pub fn tags_from_entries(entries: &[TagEntry]) -> Tags {
65    let mut tags = Tags::new();
66    for entry in entries {
67        match &entry.value {
68            Some(v) => tags.insert(&entry.key, v),
69            None => tags.add_label(&entry.key),
70        }
71    }
72    tags
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn test_parse_port_valid() {
81        assert_eq!(parse_port("1").unwrap(), 1);
82        assert_eq!(parse_port("3671").unwrap(), 3671);
83        assert_eq!(parse_port("65535").unwrap(), 65535);
84    }
85
86    #[test]
87    fn test_parse_port_zero_rejected() {
88        assert!(parse_port("0").is_err());
89    }
90
91    #[test]
92    fn test_parse_port_invalid_string() {
93        assert!(parse_port("abc").is_err());
94        assert!(parse_port("-1").is_err());
95        assert!(parse_port("99999").is_err());
96    }
97
98    #[test]
99    fn test_parse_nonzero_count_valid() {
100        assert_eq!(parse_nonzero_count("1").unwrap(), 1);
101        assert_eq!(parse_nonzero_count("50000").unwrap(), 50000);
102    }
103
104    #[test]
105    fn test_parse_nonzero_count_zero_rejected() {
106        assert!(parse_nonzero_count("0").is_err());
107    }
108
109    #[test]
110    fn test_parse_nonzero_count_invalid() {
111        assert!(parse_nonzero_count("abc").is_err());
112        assert!(parse_nonzero_count("-1").is_err());
113    }
114
115    #[test]
116    fn test_parse_zero_or_more_count_valid() {
117        assert_eq!(parse_zero_or_more_count("0").unwrap(), 0);
118        assert_eq!(parse_zero_or_more_count("100").unwrap(), 100);
119    }
120
121    #[test]
122    fn test_parse_zero_or_more_count_invalid() {
123        assert!(parse_zero_or_more_count("abc").is_err());
124        assert!(parse_zero_or_more_count("-1").is_err());
125    }
126
127    #[test]
128    fn test_parse_tag_key_value() {
129        let entry = parse_tag("location=building-a").unwrap();
130        assert_eq!(entry.key, "location");
131        assert_eq!(entry.value, Some("building-a".to_string()));
132    }
133
134    #[test]
135    fn test_parse_tag_label() {
136        let entry = parse_tag("critical").unwrap();
137        assert_eq!(entry.key, "critical");
138        assert_eq!(entry.value, None);
139    }
140
141    #[test]
142    fn test_parse_tag_empty_value() {
143        let entry = parse_tag("key=").unwrap();
144        assert_eq!(entry.key, "key");
145        assert_eq!(entry.value, Some("".to_string()));
146    }
147
148    #[test]
149    fn test_parse_tag_invalid() {
150        assert!(parse_tag("").is_err());
151        assert!(parse_tag("=value").is_err());
152    }
153
154    #[test]
155    fn test_tags_from_entries() {
156        let entries = vec![
157            TagEntry {
158                key: "location".to_string(),
159                value: Some("building-a".to_string()),
160            },
161            TagEntry {
162                key: "floor".to_string(),
163                value: Some("3".to_string()),
164            },
165            TagEntry {
166                key: "critical".to_string(),
167                value: None,
168            },
169        ];
170
171        let tags = tags_from_entries(&entries);
172        assert_eq!(tags.get("location"), Some("building-a"));
173        assert_eq!(tags.get("floor"), Some("3"));
174        assert!(tags.has_label("critical"));
175    }
176}