unifi-cli 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/CHANGELOG.md +7 -0
  2. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/Cargo.lock +1 -1
  3. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/Cargo.toml +1 -1
  4. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/PKG-INFO +4 -2
  5. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/README.md +3 -1
  6. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/api/types.rs +11 -0
  7. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/commands/clients.rs +251 -49
  8. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/commands/devices.rs +5 -5
  9. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/commands/events.rs +5 -5
  10. unifi_cli-0.3.0/src/fields.rs +241 -0
  11. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/lib.rs +1 -0
  12. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/main.rs +130 -13
  13. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/schema.rs +7 -74
  14. unifi_cli-0.3.0/tests/cli_contract.rs +247 -0
  15. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/tests/mock_server.rs +35 -1
  16. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  17. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/.github/workflows/release.yml +0 -0
  18. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/.gitignore +0 -0
  19. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/LICENSE +0 -0
  20. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/Makefile +0 -0
  21. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/prek.toml +0 -0
  22. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/pyproject.toml +0 -0
  23. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/api/client.rs +0 -0
  24. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/api/mod.rs +0 -0
  25. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/api/tests.rs +0 -0
  26. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/commands/mod.rs +0 -0
  27. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/commands/networks.rs +0 -0
  28. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/commands/protect.rs +0 -0
  29. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/commands/system.rs +0 -0
  30. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/output.rs +0 -0
  31. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/src/tui.rs +0 -0
  32. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/tests/fixtures/clispec-v0.2.json +0 -0
  33. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/tests/spec_compliance.rs +0 -0
  34. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/unifi_cli/__init__.py +0 -0
  35. {unifi_cli-0.2.3 → unifi_cli-0.3.0}/unifi_cli/__main__.py +0 -0
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
 
6
6
 
7
7
 
8
+
9
+ ## [0.3.0](https://github.com/rvben/unifi-cli/compare/v0.2.3...v0.3.0) - 2026-07-09
10
+
11
+ ### Fixed
12
+
13
+ - **clients**: stop three silent-failure modes in the agent contract ([5e50bb7](https://github.com/rvben/unifi-cli/commit/5e50bb704777407acf244856bc435f7ceccd7b00))
14
+
8
15
  ## [0.2.3](https://github.com/rvben/unifi-cli/compare/v0.2.2...v0.2.3) - 2026-07-07
9
16
 
10
17
  ### Fixed
@@ -2786,7 +2786,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2786
2786
 
2787
2787
  [[package]]
2788
2788
  name = "unifi-cli"
2789
- version = "0.2.3"
2789
+ version = "0.3.0"
2790
2790
  dependencies = [
2791
2791
  "clap",
2792
2792
  "clap_complete",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "unifi-cli"
3
- version = "0.2.3"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "CLI for UniFi Network controller"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-cli
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: System Administrators
@@ -151,6 +151,7 @@ unifi clients list # List connected clients
151
151
  unifi clients list --wired # Wired clients only
152
152
  unifi clients list --wireless --name tasmota # Filter by type and name
153
153
  unifi clients list --watch # Auto-refresh
154
+ unifi clients list --fields name,ssid,ip # Project specific fields (see `unifi schema`)
154
155
  unifi clients show aa:bb:cc:dd:ee:ff # Show client details
155
156
  unifi clients top # Top clients by bandwidth
156
157
  unifi clients block aa:bb:cc:dd:ee:ff # Block a client
@@ -182,7 +183,8 @@ unifi events list --limit 50 # Last 50 events
182
183
  ### Networks
183
184
 
184
185
  ```bash
185
- unifi networks # List all networks
186
+ unifi networks list # List all networks
187
+ unifi networks # Same thing
186
188
  ```
187
189
 
188
190
  ### System
@@ -130,6 +130,7 @@ unifi clients list # List connected clients
130
130
  unifi clients list --wired # Wired clients only
131
131
  unifi clients list --wireless --name tasmota # Filter by type and name
132
132
  unifi clients list --watch # Auto-refresh
133
+ unifi clients list --fields name,ssid,ip # Project specific fields (see `unifi schema`)
133
134
  unifi clients show aa:bb:cc:dd:ee:ff # Show client details
134
135
  unifi clients top # Top clients by bandwidth
135
136
  unifi clients block aa:bb:cc:dd:ee:ff # Block a client
@@ -161,7 +162,8 @@ unifi events list --limit 50 # Last 50 events
161
162
  ### Networks
162
163
 
163
164
  ```bash
164
- unifi networks # List all networks
165
+ unifi networks list # List all networks
166
+ unifi networks # Same thing
165
167
  ```
166
168
 
167
169
  ### System
@@ -38,12 +38,19 @@ pub struct Site {
38
38
  pub struct Client {
39
39
  #[serde(alias = "macAddress")]
40
40
  pub mac_address: Option<String>,
41
+ /// The last address the controller recorded for this client. It outlives the
42
+ /// lease, so it can name an address the client no longer holds. `clients list`
43
+ /// reports the live address from `stat/sta` instead, so that it agrees with
44
+ /// `clients show`.
41
45
  #[serde(alias = "ipAddress")]
42
46
  pub ip_address: Option<String>,
43
47
  pub name: Option<String>,
44
48
  pub hostname: Option<String>,
45
49
  #[serde(alias = "type")]
46
50
  pub client_type: Option<String>,
51
+ /// ISO8601 timestamp of the current association.
52
+ #[serde(alias = "connectedAt")]
53
+ pub connected_at: Option<String>,
47
54
  }
48
55
 
49
56
  impl Client {
@@ -83,6 +90,10 @@ pub struct LegacyClient {
83
90
  pub ap_mac: Option<String>,
84
91
  #[serde(rename = "essid")]
85
92
  pub ssid: Option<String>,
93
+ /// Name of the network the client landed on ("Default", "IoT", ...). Absent
94
+ /// while the client is associated but has not obtained an address.
95
+ pub network: Option<String>,
96
+ pub vlan: Option<u32>,
86
97
  }
87
98
 
88
99
  impl LegacyClient {
@@ -14,7 +14,77 @@ pub struct ListFilter {
14
14
  pub struct Pagination {
15
15
  pub limit: usize,
16
16
  pub offset: usize,
17
- pub fields: Option<String>,
17
+ /// Field names already validated against `fields::CLIENTS_LIST`.
18
+ pub fields: Option<Vec<String>>,
19
+ }
20
+
21
+ /// A client as `clients list` reports it: the integration-API record joined with
22
+ /// the live legacy record for the same MAC.
23
+ ///
24
+ /// The integration API knows every client the controller has seen and carries
25
+ /// `connectedAt`. Only the legacy `stat/sta` record carries the live address,
26
+ /// SSID, signal, network and VLAN. Neither alone answers "which SSID is each
27
+ /// client on", and reading `ip` from the integration record makes `clients list`
28
+ /// disagree with `clients show` whenever a client has lost its lease.
29
+ pub(crate) struct ClientRow {
30
+ pub client: Client,
31
+ pub detail: Option<LegacyClient>,
32
+ }
33
+
34
+ impl ClientRow {
35
+ /// The address the client holds right now, or None while it has none.
36
+ ///
37
+ /// Deliberately not `Client::ip_address`, which is the last address the
38
+ /// controller ever saw and lingers long after the lease is gone.
39
+ pub fn live_ip(&self) -> Option<&str> {
40
+ self.detail.as_ref().and_then(|d| d.ip.as_deref())
41
+ }
42
+
43
+ pub fn to_json(&self) -> serde_json::Value {
44
+ let c = &self.client;
45
+ let d = self.detail.as_ref();
46
+ serde_json::json!({
47
+ "name": c.clean_name(),
48
+ "mac": c.mac_address.as_deref().map(|m| format_mac(&normalize_mac(m))),
49
+ "ip": self.live_ip(),
50
+ "type": c.client_type,
51
+ "ssid": d.and_then(|d| d.ssid.as_deref()),
52
+ "signal": d.and_then(|d| d.signal),
53
+ "uptime": d.and_then(|d| d.uptime),
54
+ "network": d.and_then(|d| d.network.as_deref()),
55
+ "vlan": d.and_then(|d| d.vlan),
56
+ "tx_bytes": d.and_then(|d| d.tx_bytes),
57
+ "rx_bytes": d.and_then(|d| d.rx_bytes),
58
+ "blocked": d.map(|d| d.blocked),
59
+ "connected_at": c.connected_at,
60
+ })
61
+ }
62
+ }
63
+
64
+ /// Join integration-API clients to their live legacy records by MAC.
65
+ ///
66
+ /// A client with no legacy record is kept, with its live fields null. That is
67
+ /// the honest answer: it is associated (the controller lists it) but the
68
+ /// controller has no current address or SSID for it.
69
+ pub(crate) fn merge_rows(clients: Vec<Client>, legacy: Vec<LegacyClient>) -> Vec<ClientRow> {
70
+ let mut by_mac: std::collections::HashMap<String, LegacyClient> = legacy
71
+ .into_iter()
72
+ .filter_map(|l| {
73
+ let key = l.mac.as_deref().map(normalize_mac)?;
74
+ Some((key, l))
75
+ })
76
+ .collect();
77
+
78
+ clients
79
+ .into_iter()
80
+ .map(|c| {
81
+ let detail = c
82
+ .mac_address
83
+ .as_deref()
84
+ .and_then(|m| by_mac.remove(&normalize_mac(m)));
85
+ ClientRow { client: c, detail }
86
+ })
87
+ .collect()
18
88
  }
19
89
 
20
90
  pub(crate) fn apply_filter(clients: Vec<Client>, filter: &ListFilter) -> Vec<Client> {
@@ -39,27 +109,15 @@ pub(crate) fn apply_filter(clients: Vec<Client>, filter: &ListFilter) -> Vec<Cli
39
109
  .collect()
40
110
  }
41
111
 
42
- fn render_clients(clients: &[Client], out: &OutputConfig) {
112
+ fn render_clients(rows: &[ClientRow], out: &OutputConfig) {
43
113
  if out.is_json() {
44
114
  out.print_data(
45
- &serde_json::to_string_pretty(
46
- &clients
47
- .iter()
48
- .map(|c| {
49
- serde_json::json!({
50
- "name": c.clean_name(),
51
- "mac": c.mac_address.as_deref().map(|m| format_mac(&normalize_mac(m))),
52
- "ip": c.ip_address,
53
- "type": c.client_type,
54
- })
55
- })
56
- .collect::<Vec<_>>(),
57
- )
58
- .expect("failed to serialize JSON"),
115
+ &serde_json::to_string_pretty(&rows.iter().map(ClientRow::to_json).collect::<Vec<_>>())
116
+ .expect("failed to serialize JSON"),
59
117
  );
60
118
  } else {
61
119
  let color = use_color();
62
- let names: Vec<String> = clients.iter().map(|c| c.clean_name()).collect();
120
+ let names: Vec<String> = rows.iter().map(|r| r.client.clean_name()).collect();
63
121
  let name_w = names.iter().map(|n| n.len()).max().unwrap_or(4).max(4) + 2;
64
122
  let total_w = name_w + 19 + 15 + 10;
65
123
  let header = format!("{:<name_w$} {:<19} {:<15} {}", "Name", "MAC", "IP", "Type");
@@ -71,14 +129,15 @@ fn render_clients(clients: &[Client], out: &OutputConfig) {
71
129
  println!("{}", "-".repeat(total_w));
72
130
  }
73
131
 
74
- for (c, name) in clients.iter().zip(&names) {
75
- let mac = c
132
+ for (r, name) in rows.iter().zip(&names) {
133
+ let mac = r
134
+ .client
76
135
  .mac_address
77
136
  .as_deref()
78
137
  .map(format_mac)
79
138
  .unwrap_or_else(|| "-".into());
80
- let ip = c.ip_address.as_deref().unwrap_or("-");
81
- let ctype = c.client_type.as_deref().unwrap_or("-");
139
+ let ip = r.live_ip().unwrap_or("-");
140
+ let ctype = r.client.client_type.as_deref().unwrap_or("-");
82
141
  let pad = name_w - 1;
83
142
 
84
143
  if color {
@@ -94,7 +153,7 @@ fn render_clients(clients: &[Client], out: &OutputConfig) {
94
153
  }
95
154
  }
96
155
  }
97
- out.print_message(&format!("\n{} clients", clients.len()));
156
+ out.print_message(&format!("\n{} clients", rows.len()));
98
157
  }
99
158
 
100
159
  pub async fn list(
@@ -118,22 +177,16 @@ pub async fn list(
118
177
  crossterm::terminal::Clear(crossterm::terminal::ClearType::All)
119
178
  )?;
120
179
  eprintln!("Every {interval}s | clients list (press Ctrl+C to exit)\n");
121
- match client.list_clients().await {
122
- Ok(clients) => {
123
- let filtered = apply_filter(clients, &filter);
124
- render_clients(&filtered, &out);
125
- }
126
- Err(e) => {
127
- eprintln!("Error: {e}");
128
- }
180
+ match fetch_rows(client, &filter).await {
181
+ Ok(rows) => render_clients(&rows, &out),
182
+ Err(e) => eprintln!("Error: {e}"),
129
183
  }
130
184
  tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
131
185
  }
132
186
  } else {
133
- let clients = client.list_clients().await?;
134
- let filtered = apply_filter(clients, &filter);
135
- let total = filtered.len();
136
- let paginated: Vec<Client> = filtered
187
+ let rows = fetch_rows(client, &filter).await?;
188
+ let total = rows.len();
189
+ let paginated: Vec<ClientRow> = rows
137
190
  .into_iter()
138
191
  .skip(pagination.offset)
139
192
  .take(pagination.limit)
@@ -141,21 +194,11 @@ pub async fn list(
141
194
  if out.is_json() {
142
195
  let items: Vec<serde_json::Value> = paginated
143
196
  .iter()
144
- .map(|c| {
145
- let mac_str = c
146
- .mac_address
147
- .as_deref()
148
- .map(|m| format_mac(&normalize_mac(m)));
149
- let mut obj = serde_json::json!({
150
- "name": c.clean_name(),
151
- "mac": mac_str,
152
- "ip": c.ip_address,
153
- "type": c.client_type,
154
- });
155
- if let Some(ref fields_str) = pagination.fields {
156
- let keep: Vec<&str> = fields_str.split(',').map(str::trim).collect();
157
- let map = obj.as_object_mut().unwrap();
158
- map.retain(|k, _| keep.contains(&k.as_str()));
197
+ .map(|r| {
198
+ let mut obj = r.to_json();
199
+ if let Some(ref keep) = pagination.fields {
200
+ let map = obj.as_object_mut().expect("row is a JSON object");
201
+ map.retain(|k, _| keep.iter().any(|f| f == k));
159
202
  }
160
203
  obj
161
204
  })
@@ -176,6 +219,17 @@ pub async fn list(
176
219
  }
177
220
  }
178
221
 
222
+ /// Fetch both client views and join them. Two calls, once, rather than one call
223
+ /// per client for anything beyond name/mac/ip/type.
224
+ async fn fetch_rows(
225
+ client: &mut UnifiClient,
226
+ filter: &ListFilter,
227
+ ) -> Result<Vec<ClientRow>, Box<dyn std::error::Error>> {
228
+ let clients = client.list_clients().await?;
229
+ let legacy = client.list_clients_legacy().await?;
230
+ Ok(merge_rows(apply_filter(clients, filter), legacy))
231
+ }
232
+
179
233
  pub async fn show(
180
234
  client: &UnifiClient,
181
235
  mac: &str,
@@ -195,6 +249,9 @@ pub async fn show(
195
249
  "signal": c.signal,
196
250
  "ssid": c.ssid,
197
251
  "ap_mac": c.ap_mac,
252
+ "network": c.network,
253
+ "vlan": c.vlan,
254
+ "blocked": c.blocked,
198
255
  }))?);
199
256
  return Ok(());
200
257
  }
@@ -250,6 +307,15 @@ pub async fn show(
250
307
  println!(" {} {}", label("AP: "), format_mac(ap));
251
308
  }
252
309
  }
310
+ if let Some(ref network) = c.network {
311
+ println!(" {} {network}", label("Network:"));
312
+ }
313
+ if let Some(vlan) = c.vlan {
314
+ println!(" {} {vlan}", label("VLAN: "));
315
+ }
316
+ if c.blocked {
317
+ println!(" {} yes", label("Blocked:"));
318
+ }
253
319
 
254
320
  Ok(())
255
321
  }
@@ -419,6 +485,142 @@ mod tests {
419
485
  .unwrap()
420
486
  }
421
487
 
488
+ fn legacy(json: &str) -> LegacyClient {
489
+ serde_json::from_str(json).unwrap()
490
+ }
491
+
492
+ // --- merge_rows: `clients list` must describe a client the way `show` does ---
493
+
494
+ #[test]
495
+ fn merge_joins_legacy_detail_by_mac() {
496
+ let clients = vec![make_client(
497
+ "Plug",
498
+ "70:03:9f:90:3c:29",
499
+ "10.10.20.202",
500
+ "WIRELESS",
501
+ )];
502
+ let legacy_records = vec![legacy(
503
+ r#"{"_id":"1","mac":"70:03:9f:90:3c:29","ip":"10.10.20.202","essid":"Notwork",
504
+ "signal":-57,"uptime":321125,"network":"IoT","vlan":20}"#,
505
+ )];
506
+
507
+ let rows = merge_rows(clients, legacy_records);
508
+ assert_eq!(rows.len(), 1);
509
+ let json = rows[0].to_json();
510
+ assert_eq!(json["ssid"], "Notwork");
511
+ assert_eq!(json["signal"], -57);
512
+ assert_eq!(json["network"], "IoT");
513
+ assert_eq!(json["vlan"], 20);
514
+ assert_eq!(json["ip"], "10.10.20.202");
515
+ }
516
+
517
+ #[test]
518
+ fn merge_matches_macs_in_different_formats() {
519
+ let clients = vec![make_client("X", "AA-BB-CC-DD-EE-FF", "10.0.0.1", "WIRED")];
520
+ let legacy_records = vec![legacy(
521
+ r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:ff","ip":"10.0.0.5"}"#,
522
+ )];
523
+ let rows = merge_rows(clients, legacy_records);
524
+ assert_eq!(rows[0].live_ip(), Some("10.0.0.5"));
525
+ }
526
+
527
+ /// The defect this guards: `clients list` read `ip` from the integration API,
528
+ /// which keeps the last address a client ever held. A plug that had dropped
529
+ /// its lease four days earlier was still reported at that address, while
530
+ /// `clients show` correctly reported none.
531
+ #[test]
532
+ fn live_ip_wins_over_the_integration_apis_last_known_address() {
533
+ let clients = vec![make_client(
534
+ "Refrigerator",
535
+ "c4:dd:57:1d:07:e6",
536
+ "10.10.20.203",
537
+ "WIRELESS",
538
+ )];
539
+ let legacy_records = vec![legacy(
540
+ r#"{"_id":"1","mac":"c4:dd:57:1d:07:e6","essid":"Network"}"#,
541
+ )];
542
+
543
+ let rows = merge_rows(clients, legacy_records);
544
+ assert_eq!(
545
+ rows[0].live_ip(),
546
+ None,
547
+ "a client with no current lease must report no ip"
548
+ );
549
+ assert_eq!(rows[0].to_json()["ip"], serde_json::Value::Null);
550
+ assert_eq!(rows[0].to_json()["ssid"], "Network");
551
+ }
552
+
553
+ #[test]
554
+ fn clients_without_a_legacy_record_survive_with_null_detail() {
555
+ let clients = vec![make_client(
556
+ "Ghost",
557
+ "aa:bb:cc:dd:ee:ff",
558
+ "10.0.0.1",
559
+ "WIRED",
560
+ )];
561
+ let rows = merge_rows(clients, vec![]);
562
+ assert_eq!(rows.len(), 1);
563
+ let json = rows[0].to_json();
564
+ assert_eq!(json["name"], "Ghost");
565
+ assert_eq!(json["ip"], serde_json::Value::Null);
566
+ assert_eq!(json["ssid"], serde_json::Value::Null);
567
+ assert_eq!(json["blocked"], serde_json::Value::Null);
568
+ }
569
+
570
+ #[test]
571
+ fn merge_preserves_client_order_and_count() {
572
+ let clients = vec![
573
+ make_client("A", "aa:bb:cc:dd:ee:01", "10.0.0.1", "WIRED"),
574
+ make_client("B", "aa:bb:cc:dd:ee:02", "10.0.0.2", "WIRELESS"),
575
+ make_client("C", "aa:bb:cc:dd:ee:03", "10.0.0.3", "WIRELESS"),
576
+ ];
577
+ let legacy_records = vec![legacy(
578
+ r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:02","ip":"1.2.3.4"}"#,
579
+ )];
580
+ let rows = merge_rows(clients, legacy_records);
581
+ let names: Vec<String> = rows
582
+ .iter()
583
+ .map(|r| r.client.clean_name())
584
+ .collect::<Vec<_>>();
585
+ assert_eq!(names, vec!["A", "B", "C"]);
586
+ assert_eq!(rows[1].live_ip(), Some("1.2.3.4"));
587
+ assert_eq!(rows[0].live_ip(), None);
588
+ }
589
+
590
+ #[test]
591
+ fn merge_ignores_legacy_records_with_no_matching_client() {
592
+ let clients = vec![make_client("A", "aa:bb:cc:dd:ee:01", "10.0.0.1", "WIRED")];
593
+ let legacy_records = vec![
594
+ legacy(r#"{"_id":"1","mac":"aa:bb:cc:dd:ee:01","ip":"1.1.1.1"}"#),
595
+ legacy(r#"{"_id":"2","mac":"ff:ff:ff:ff:ff:ff","ip":"2.2.2.2"}"#),
596
+ ];
597
+ let rows = merge_rows(clients, legacy_records);
598
+ assert_eq!(rows.len(), 1);
599
+ assert_eq!(rows[0].live_ip(), Some("1.1.1.1"));
600
+ }
601
+
602
+ #[test]
603
+ fn to_json_emits_exactly_the_published_field_set() {
604
+ let rows = merge_rows(
605
+ vec![make_client("A", "aa:bb:cc:dd:ee:01", "10.0.0.1", "WIRED")],
606
+ vec![],
607
+ );
608
+ let json = rows[0].to_json();
609
+ let mut emitted: Vec<&str> = json
610
+ .as_object()
611
+ .unwrap()
612
+ .keys()
613
+ .map(|s| s.as_str())
614
+ .collect();
615
+ let mut published = crate::fields::names(crate::fields::CLIENTS_LIST);
616
+ emitted.sort_unstable();
617
+ published.sort_unstable();
618
+ assert_eq!(
619
+ emitted, published,
620
+ "clients list output must match the fields the schema publishes"
621
+ );
622
+ }
623
+
422
624
  // --- total_bytes ---
423
625
 
424
626
  #[test]
@@ -6,7 +6,8 @@ use crate::output::{OutputConfig, use_color};
6
6
  pub struct Pagination {
7
7
  pub limit: usize,
8
8
  pub offset: usize,
9
- pub fields: Option<String>,
9
+ /// Field names already validated against `fields::DEVICES_LIST`.
10
+ pub fields: Option<Vec<String>>,
10
11
  }
11
12
 
12
13
  fn render_devices(devices: &[Device], out: &OutputConfig) {
@@ -146,10 +147,9 @@ pub async fn list(
146
147
  "state": d.state,
147
148
  "firmware": d.firmware_version,
148
149
  });
149
- if let Some(ref fields_str) = pagination.fields {
150
- let keep: Vec<&str> = fields_str.split(',').map(str::trim).collect();
151
- let map = obj.as_object_mut().unwrap();
152
- map.retain(|k, _| keep.contains(&k.as_str()));
150
+ if let Some(ref keep) = pagination.fields {
151
+ let map = obj.as_object_mut().expect("device is a JSON object");
152
+ map.retain(|k, _| keep.iter().any(|f| f == k));
153
153
  }
154
154
  obj
155
155
  })
@@ -6,7 +6,8 @@ use crate::output::{OutputConfig, use_color};
6
6
  pub struct Pagination {
7
7
  pub limit: usize,
8
8
  pub offset: usize,
9
- pub fields: Option<String>,
9
+ /// Field names already validated against `fields::EVENTS_LIST`.
10
+ pub fields: Option<Vec<String>>,
10
11
  }
11
12
 
12
13
  pub async fn list(
@@ -33,10 +34,9 @@ pub async fn list(
33
34
  "time": e.time,
34
35
  "datetime": e.datetime,
35
36
  });
36
- if let Some(ref fields_str) = pagination.fields {
37
- let keep: Vec<&str> = fields_str.split(',').map(str::trim).collect();
38
- let map = obj.as_object_mut().unwrap();
39
- map.retain(|k, _| keep.contains(&k.as_str()));
37
+ if let Some(ref keep) = pagination.fields {
38
+ let map = obj.as_object_mut().expect("event is a JSON object");
39
+ map.retain(|k, _| keep.iter().any(|f| f == k));
40
40
  }
41
41
  obj
42
42
  })