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
70pub const PORTS_LIST: &[Field] = &[
71    ("device_mac", "string"),
72    ("device_name", "string"),
73    ("port_idx", "integer"),
74    ("name", "string"),
75    ("media", "string"),
76    ("up", "boolean"),
77    ("speed", "integer"),
78    ("full_duplex", "boolean"),
79    ("poe_enable", "boolean"),
80    ("poe_power", "number"),
81    ("port_poe", "boolean"),
82    ("tx_bytes", "integer"),
83    ("rx_bytes", "integer"),
84];
85
86/// `ports find` rows: the `PORTS_LIST` set plus `connected`, which
87/// distinguishes a live attachment from a stale record. Kept separate from
88/// `PORTS_LIST` so the `devices ports` alias gains exactly two new keys.
89pub const PORTS_FIND: &[Field] = &[
90    ("device_mac", "string"),
91    ("device_name", "string"),
92    ("port_idx", "integer"),
93    ("name", "string"),
94    ("media", "string"),
95    ("up", "boolean"),
96    ("speed", "integer"),
97    ("full_duplex", "boolean"),
98    ("poe_enable", "boolean"),
99    ("poe_power", "number"),
100    ("port_poe", "boolean"),
101    ("tx_bytes", "integer"),
102    ("rx_bytes", "integer"),
103    ("connected", "boolean"),
104];
105
106/// A `--fields` request naming one or more unknown fields.
107#[derive(Debug, PartialEq, Eq)]
108pub struct InvalidFields {
109    pub unknown: Vec<String>,
110    pub valid: Vec<&'static str>,
111}
112
113impl std::fmt::Display for InvalidFields {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        let plural = if self.unknown.len() == 1 {
116            "field"
117        } else {
118            "fields"
119        };
120        write!(
121            f,
122            "unknown {plural} in --fields: {}. Valid fields: {}",
123            self.unknown.join(", "),
124            self.valid.join(", ")
125        )
126    }
127}
128
129impl std::error::Error for InvalidFields {}
130
131/// Field names of a table, in declaration order.
132pub fn names(table: &[Field]) -> Vec<&'static str> {
133    table.iter().map(|(n, _)| *n).collect()
134}
135
136/// Parse and validate a comma-separated `--fields` spec against a table.
137///
138/// Returns the requested names in the order given. Empty segments (`a,,b`) and
139/// surrounding whitespace are tolerated; anything not in the table is an error.
140pub fn validate(spec: &str, table: &[Field]) -> Result<Vec<String>, InvalidFields> {
141    let valid = names(table);
142    let requested: Vec<&str> = spec
143        .split(',')
144        .map(str::trim)
145        .filter(|s| !s.is_empty())
146        .collect();
147
148    let mut unknown: Vec<String> = requested
149        .iter()
150        .filter(|r| !valid.contains(*r))
151        .map(|r| (*r).to_string())
152        .collect();
153    unknown.dedup();
154
155    if unknown.is_empty() {
156        Ok(requested.into_iter().map(str::to_string).collect())
157    } else {
158        Err(InvalidFields { unknown, valid })
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn accepts_a_single_known_field() {
168        assert_eq!(validate("mac", CLIENTS_LIST).unwrap(), vec!["mac"]);
169    }
170
171    #[test]
172    fn accepts_several_known_fields_preserving_order() {
173        assert_eq!(
174            validate("ip,mac,ssid", CLIENTS_LIST).unwrap(),
175            vec!["ip", "mac", "ssid"]
176        );
177    }
178
179    #[test]
180    fn tolerates_whitespace_and_empty_segments() {
181        assert_eq!(
182            validate(" mac , , ip ", CLIENTS_LIST).unwrap(),
183            vec!["mac", "ip"]
184        );
185    }
186
187    #[test]
188    fn rejects_an_unknown_field() {
189        let err = validate("bogus", CLIENTS_LIST).unwrap_err();
190        assert_eq!(err.unknown, vec!["bogus"]);
191        assert!(err.valid.contains(&"ssid"));
192    }
193
194    #[test]
195    fn rejects_an_unknown_field_mixed_with_known_ones() {
196        let err = validate("mac,bogus,ip", CLIENTS_LIST).unwrap_err();
197        assert_eq!(err.unknown, vec!["bogus"]);
198    }
199
200    #[test]
201    fn reports_every_unknown_field() {
202        let err = validate("bogus,mac,nope", CLIENTS_LIST).unwrap_err();
203        assert_eq!(err.unknown, vec!["bogus", "nope"]);
204    }
205
206    #[test]
207    fn an_all_empty_spec_selects_nothing_rather_than_erroring() {
208        // `--fields ""` is a request for no fields, not an invalid field.
209        assert!(validate("", CLIENTS_LIST).unwrap().is_empty());
210        assert!(validate(" , ", CLIENTS_LIST).unwrap().is_empty());
211    }
212
213    #[test]
214    fn error_message_names_the_offender_and_the_valid_set() {
215        let msg = validate("bogus", CLIENTS_LIST).unwrap_err().to_string();
216        assert!(msg.contains("bogus"), "{msg}");
217        assert!(msg.contains("Valid fields:"), "{msg}");
218        assert!(msg.contains("ssid"), "{msg}");
219        assert!(msg.contains("field in --fields"), "{msg}");
220    }
221
222    #[test]
223    fn error_message_pluralises() {
224        let msg = validate("a,b", CLIENTS_LIST).unwrap_err().to_string();
225        assert!(msg.contains("fields in --fields"), "{msg}");
226    }
227
228    #[test]
229    fn every_table_has_unique_field_names() {
230        for table in [
231            CLIENTS_LIST,
232            CLIENTS_SHOW,
233            DEVICES_LIST,
234            EVENTS_LIST,
235            NETWORKS_LIST,
236            PORTS_LIST,
237            PORTS_FIND,
238        ] {
239            let mut seen = names(table);
240            let before = seen.len();
241            seen.sort_unstable();
242            seen.dedup();
243            assert_eq!(before, seen.len(), "duplicate field name in table");
244        }
245    }
246
247    #[test]
248    fn every_field_declares_a_json_type() {
249        for table in [
250            CLIENTS_LIST,
251            CLIENTS_SHOW,
252            DEVICES_LIST,
253            EVENTS_LIST,
254            NETWORKS_LIST,
255            PORTS_LIST,
256            PORTS_FIND,
257        ] {
258            for (name, ty) in table {
259                assert!(
260                    ["string", "integer", "boolean", "number"].contains(ty),
261                    "field {name} has unexpected type {ty}"
262                );
263            }
264        }
265    }
266
267    #[test]
268    fn clients_list_can_project_everything_clients_show_reports() {
269        // A field visible for one client must be reachable in bulk, otherwise
270        // answering "which SSID is each client on" costs one call per client.
271        for (name, _) in CLIENTS_SHOW {
272            if *name == "wired" || *name == "ap_mac" {
273                continue; // `type` covers wired; ap_mac is detail-only
274            }
275            assert!(
276                names(CLIENTS_LIST).contains(name),
277                "clients list cannot project {name}, which clients show reports"
278            );
279        }
280    }
281}