Skip to main content

unifi_cli/
fields.rs

1//! The set of fields each list command can emit, and validation of `--fields`.
2//!
3//! These tables are the single source of truth. `schema` publishes them as
4//! `output_fields`, and `--fields` validates against them, so the contract an
5//! agent reads is exactly the contract the CLI enforces.
6//!
7//! A `--fields` request naming something outside the table is a usage error.
8//! Silently dropping it, as this once did, hands back `{}` per row with exit 0,
9//! which is indistinguishable from a successful query that found nothing.
10
11/// A field name paired with its JSON type, as published in `unifi schema`.
12pub type Field = (&'static str, &'static str);
13
14pub const CLIENTS_LIST: &[Field] = &[
15    ("name", "string"),
16    ("mac", "string"),
17    ("ip", "string"),
18    ("type", "string"),
19    ("ssid", "string"),
20    ("signal", "integer"),
21    ("uptime", "integer"),
22    ("network", "string"),
23    ("vlan", "integer"),
24    ("tx_bytes", "integer"),
25    ("rx_bytes", "integer"),
26    ("blocked", "boolean"),
27    ("connected_at", "string"),
28];
29
30pub const CLIENTS_SHOW: &[Field] = &[
31    ("name", "string"),
32    ("mac", "string"),
33    ("ip", "string"),
34    ("wired", "boolean"),
35    ("uptime", "integer"),
36    ("tx_bytes", "integer"),
37    ("rx_bytes", "integer"),
38    ("signal", "integer"),
39    ("ssid", "string"),
40    ("ap_mac", "string"),
41    ("network", "string"),
42    ("vlan", "integer"),
43    ("blocked", "boolean"),
44];
45
46pub const DEVICES_LIST: &[Field] = &[
47    ("name", "string"),
48    ("model", "string"),
49    ("mac", "string"),
50    ("ip", "string"),
51    ("state", "string"),
52    ("firmware", "string"),
53];
54
55pub const EVENTS_LIST: &[Field] = &[
56    ("key", "string"),
57    ("msg", "string"),
58    ("subsystem", "string"),
59    ("time", "integer"),
60    ("datetime", "string"),
61];
62
63pub const NETWORKS_LIST: &[Field] = &[
64    ("name", "string"),
65    ("vlan_id", "integer"),
66    ("enabled", "boolean"),
67    ("default", "boolean"),
68];
69
70/// A `--fields` request naming one or more unknown fields.
71#[derive(Debug, PartialEq, Eq)]
72pub struct InvalidFields {
73    pub unknown: Vec<String>,
74    pub valid: Vec<&'static str>,
75}
76
77impl std::fmt::Display for InvalidFields {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        let plural = if self.unknown.len() == 1 {
80            "field"
81        } else {
82            "fields"
83        };
84        write!(
85            f,
86            "unknown {plural} in --fields: {}. Valid fields: {}",
87            self.unknown.join(", "),
88            self.valid.join(", ")
89        )
90    }
91}
92
93impl std::error::Error for InvalidFields {}
94
95/// Field names of a table, in declaration order.
96pub fn names(table: &[Field]) -> Vec<&'static str> {
97    table.iter().map(|(n, _)| *n).collect()
98}
99
100/// Parse and validate a comma-separated `--fields` spec against a table.
101///
102/// Returns the requested names in the order given. Empty segments (`a,,b`) and
103/// surrounding whitespace are tolerated; anything not in the table is an error.
104pub fn validate(spec: &str, table: &[Field]) -> Result<Vec<String>, InvalidFields> {
105    let valid = names(table);
106    let requested: Vec<&str> = spec
107        .split(',')
108        .map(str::trim)
109        .filter(|s| !s.is_empty())
110        .collect();
111
112    let mut unknown: Vec<String> = requested
113        .iter()
114        .filter(|r| !valid.contains(*r))
115        .map(|r| (*r).to_string())
116        .collect();
117    unknown.dedup();
118
119    if unknown.is_empty() {
120        Ok(requested.into_iter().map(str::to_string).collect())
121    } else {
122        Err(InvalidFields { unknown, valid })
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn accepts_a_single_known_field() {
132        assert_eq!(validate("mac", CLIENTS_LIST).unwrap(), vec!["mac"]);
133    }
134
135    #[test]
136    fn accepts_several_known_fields_preserving_order() {
137        assert_eq!(
138            validate("ip,mac,ssid", CLIENTS_LIST).unwrap(),
139            vec!["ip", "mac", "ssid"]
140        );
141    }
142
143    #[test]
144    fn tolerates_whitespace_and_empty_segments() {
145        assert_eq!(
146            validate(" mac , , ip ", CLIENTS_LIST).unwrap(),
147            vec!["mac", "ip"]
148        );
149    }
150
151    #[test]
152    fn rejects_an_unknown_field() {
153        let err = validate("bogus", CLIENTS_LIST).unwrap_err();
154        assert_eq!(err.unknown, vec!["bogus"]);
155        assert!(err.valid.contains(&"ssid"));
156    }
157
158    #[test]
159    fn rejects_an_unknown_field_mixed_with_known_ones() {
160        let err = validate("mac,bogus,ip", CLIENTS_LIST).unwrap_err();
161        assert_eq!(err.unknown, vec!["bogus"]);
162    }
163
164    #[test]
165    fn reports_every_unknown_field() {
166        let err = validate("bogus,mac,nope", CLIENTS_LIST).unwrap_err();
167        assert_eq!(err.unknown, vec!["bogus", "nope"]);
168    }
169
170    #[test]
171    fn an_all_empty_spec_selects_nothing_rather_than_erroring() {
172        // `--fields ""` is a request for no fields, not an invalid field.
173        assert!(validate("", CLIENTS_LIST).unwrap().is_empty());
174        assert!(validate(" , ", CLIENTS_LIST).unwrap().is_empty());
175    }
176
177    #[test]
178    fn error_message_names_the_offender_and_the_valid_set() {
179        let msg = validate("bogus", CLIENTS_LIST).unwrap_err().to_string();
180        assert!(msg.contains("bogus"), "{msg}");
181        assert!(msg.contains("Valid fields:"), "{msg}");
182        assert!(msg.contains("ssid"), "{msg}");
183        assert!(msg.contains("field in --fields"), "{msg}");
184    }
185
186    #[test]
187    fn error_message_pluralises() {
188        let msg = validate("a,b", CLIENTS_LIST).unwrap_err().to_string();
189        assert!(msg.contains("fields in --fields"), "{msg}");
190    }
191
192    #[test]
193    fn every_table_has_unique_field_names() {
194        for table in [
195            CLIENTS_LIST,
196            CLIENTS_SHOW,
197            DEVICES_LIST,
198            EVENTS_LIST,
199            NETWORKS_LIST,
200        ] {
201            let mut seen = names(table);
202            let before = seen.len();
203            seen.sort_unstable();
204            seen.dedup();
205            assert_eq!(before, seen.len(), "duplicate field name in table");
206        }
207    }
208
209    #[test]
210    fn every_field_declares_a_json_type() {
211        for table in [
212            CLIENTS_LIST,
213            CLIENTS_SHOW,
214            DEVICES_LIST,
215            EVENTS_LIST,
216            NETWORKS_LIST,
217        ] {
218            for (name, ty) in table {
219                assert!(
220                    ["string", "integer", "boolean"].contains(ty),
221                    "field {name} has unexpected type {ty}"
222                );
223            }
224        }
225    }
226
227    #[test]
228    fn clients_list_can_project_everything_clients_show_reports() {
229        // A field visible for one client must be reachable in bulk, otherwise
230        // answering "which SSID is each client on" costs one call per client.
231        for (name, _) in CLIENTS_SHOW {
232            if *name == "wired" || *name == "ap_mac" {
233                continue; // `type` covers wired; ap_mac is detail-only
234            }
235            assert!(
236                names(CLIENTS_LIST).contains(name),
237                "clients list cannot project {name}, which clients show reports"
238            );
239        }
240    }
241}