1pub 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
86pub 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#[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
131pub fn names(table: &[Field]) -> Vec<&'static str> {
133 table.iter().map(|(n, _)| *n).collect()
134}
135
136pub 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 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 for (name, _) in CLIENTS_SHOW {
272 if *name == "wired" || *name == "ap_mac" {
273 continue; }
275 assert!(
276 names(CLIENTS_LIST).contains(name),
277 "clients list cannot project {name}, which clients show reports"
278 );
279 }
280 }
281}