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
70#[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
95pub fn names(table: &[Field]) -> Vec<&'static str> {
97 table.iter().map(|(n, _)| *n).collect()
98}
99
100pub 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 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 for (name, _) in CLIENTS_SHOW {
232 if *name == "wired" || *name == "ap_mac" {
233 continue; }
235 assert!(
236 names(CLIENTS_LIST).contains(name),
237 "clients list cannot project {name}, which clients show reports"
238 );
239 }
240 }
241}