1use owo_colors::OwoColorize;
2
3use crate::api::{
4 ApiError, DeviceWithPorts, LegacyClient, PortEntry, UnifiClient, format_bytes, format_mac,
5 normalize_mac,
6};
7use crate::output::{OutputConfig, use_color};
8
9pub struct PortRow<'a> {
12 pub device_mac: String,
13 pub device_name: String,
14 pub port: &'a PortEntry,
15}
16
17pub struct Pagination {
18 pub limit: usize,
19 pub offset: usize,
20 pub fields: Option<Vec<String>>,
22}
23
24fn device_identity(device: &DeviceWithPorts, name_fallback: &str) -> (String, String) {
28 let device_mac = device
29 .mac
30 .as_deref()
31 .map(format_mac)
32 .unwrap_or_else(|| "-".into());
33 let device_name = device
34 .name
35 .as_deref()
36 .or(device.model.as_deref())
37 .unwrap_or(name_fallback)
38 .to_string();
39 (device_mac, device_name)
40}
41
42pub fn collect_rows(devices: &[DeviceWithPorts]) -> Vec<PortRow<'_>> {
46 collect_rows_with_fallback(devices, "-")
47}
48
49pub fn collect_rows_with_fallback<'a>(
54 devices: &'a [DeviceWithPorts],
55 name_fallback: &str,
56) -> Vec<PortRow<'a>> {
57 let mut rows = Vec::new();
58 for d in devices {
59 if d.port_table.is_empty() {
60 continue;
61 }
62 let (device_mac, device_name) = device_identity(d, name_fallback);
63 for port in &d.port_table {
64 rows.push(PortRow {
65 device_mac: device_mac.clone(),
66 device_name: device_name.clone(),
67 port,
68 });
69 }
70 }
71 rows
72}
73
74pub fn row_json(row: &PortRow) -> serde_json::Value {
76 let p = row.port;
77 serde_json::json!({
78 "device_mac": row.device_mac,
79 "device_name": row.device_name,
80 "port_idx": p.port_idx,
81 "name": p.name,
82 "media": p.media,
83 "up": p.up,
84 "speed": p.speed,
85 "full_duplex": p.full_duplex,
86 "poe_enable": p.poe_enable,
87 "poe_power": p.poe_power,
88 "port_poe": p.port_poe,
89 "tx_bytes": p.tx_bytes,
90 "rx_bytes": p.rx_bytes,
91 })
92}
93
94pub fn project(value: &mut serde_json::Value, fields: &Option<Vec<String>>) {
96 if let Some(keep) = fields
97 && let Some(map) = value.as_object_mut()
98 {
99 map.retain(|k, _| keep.iter().any(|f| f == k));
100 }
101}
102
103fn poe_cell(p: &PortEntry) -> String {
105 if p.poe_enable {
106 match p.poe_power {
107 Some(w) if w > 0.0 => format!("{w:.1}W"),
108 _ => "on".into(),
109 }
110 } else if p.port_poe {
111 "off".into()
112 } else {
113 "-".into()
114 }
115}
116
117fn speed_cell(p: &PortEntry) -> String {
118 if !p.up {
119 return "down".into();
120 }
121 match p.speed {
122 Some(s) => format!("{s}{}", if p.full_duplex { "FD" } else { "HD" }),
123 None => "up".into(),
124 }
125}
126
127struct Attachment {
130 attached_mac: Option<String>,
132 last_seen_mac: Option<String>,
134 connected: Option<bool>,
137}
138
139fn attachment(p: &PortEntry) -> Attachment {
152 let lc = match p.last_connection.as_ref() {
153 Some(lc) => lc,
154 None => {
155 return Attachment {
156 attached_mac: None,
157 last_seen_mac: None,
158 connected: None,
159 };
160 }
161 };
162 let mac = lc.mac.as_deref().map(format_mac);
163 Attachment {
164 attached_mac: match lc.connected {
165 Some(true) => mac.clone(),
166 _ => None,
167 },
168 last_seen_mac: mac,
169 connected: lc.connected,
170 }
171}
172
173fn pad_visible(rendered: &str, plain: &str, width: usize) -> String {
186 let pad = width.saturating_sub(plain.len());
187 format!("{rendered}{}", " ".repeat(pad))
188}
189
190fn port_noun(count: usize) -> &'static str {
193 if count == 1 { "port" } else { "ports" }
194}
195
196pub fn device_col_width(rows: &[&PortRow]) -> usize {
201 rows.iter()
202 .map(|r| r.device_name.len())
203 .max()
204 .unwrap_or(6)
205 .max(6)
206 + 2
207}
208
209pub fn render_text(rows: &[&PortRow], show_device_col: bool, dev_w: usize, out: &OutputConfig) {
215 render_rows(rows, show_device_col, dev_w, None, out);
216}
217
218pub fn render_text_with_connected(
225 rows: &[&PortRow],
226 dev_w: usize,
227 connected: &[bool],
228 out: &OutputConfig,
229) {
230 render_rows(rows, true, dev_w, Some(connected), out);
231}
232
233fn render_rows(
237 rows: &[&PortRow],
238 show_device_col: bool,
239 dev_w: usize,
240 connected: Option<&[bool]>,
241 out: &OutputConfig,
242) {
243 let color = use_color();
244 const LINK_W: usize = 6;
247 const CONNECTED_W: usize = 9;
248
249 let mut header = if show_device_col {
250 format!(
251 "{:<dev_w$} {:<6} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
252 "Device", "Port", "Name", "Link", "Speed", "PoE", "TX", "RX"
253 )
254 } else {
255 format!(
256 "{:<6} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}",
257 "Port", "Name", "Link", "Speed", "PoE", "TX", "RX"
258 )
259 };
260 let mut rule_w = if show_device_col { 70 + dev_w } else { 70 };
261 if connected.is_some() {
262 header.push_str(&format!(" {:<9}", "Connected"));
263 rule_w += 10;
264 }
265 if color {
266 println!("{}", header.bold());
267 println!("{}", "-".repeat(rule_w).dimmed());
268 } else {
269 println!("{header}");
270 println!("{}", "-".repeat(rule_w));
271 }
272
273 for (i, r) in rows.iter().enumerate() {
274 let p = r.port;
275 let port = p
276 .port_idx
277 .map(|i| i.to_string())
278 .unwrap_or_else(|| "-".into());
279 let name = p.name.as_deref().unwrap_or("-");
280 let link = if p.up { "up" } else { "down" };
281 let link_rendered = if color {
282 if p.up {
283 format!("{}", "up".green())
284 } else {
285 format!("{}", "down".dimmed())
286 }
287 } else {
288 link.to_string()
289 };
290 let link_cell = pad_visible(&link_rendered, link, LINK_W);
291 let speed = speed_cell(p);
292 let poe = poe_cell(p);
293 let tx = p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into());
294 let rx = p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into());
295
296 let mut line = if show_device_col {
297 format!(
298 " {:<dev_w$} {:<5} {:<16} {link_cell} {:<10} {:<8} {:>10} {:>10}",
299 r.device_name, port, name, speed, poe, tx, rx
300 )
301 } else {
302 format!(
303 " {:<5} {:<16} {link_cell} {:<10} {:<8} {:>10} {:>10}",
304 port, name, speed, poe, tx, rx
305 )
306 };
307 if let Some(flags) = connected {
308 let is_connected = flags[i];
309 let plain = if is_connected { "yes" } else { "-" };
310 let rendered = if color {
311 if is_connected {
312 format!("{}", "yes".green())
313 } else {
314 format!("{}", "-".dimmed())
315 }
316 } else {
317 plain.to_string()
318 };
319 let cell = pad_visible(&rendered, plain, CONNECTED_W);
320 line.push_str(&format!(" {cell}"));
321 }
322 println!("{line}");
323 }
324 out.print_message(&format!("\n{} {}", rows.len(), port_noun(rows.len())));
325}
326
327pub async fn list(
328 client: &UnifiClient,
329 mac: Option<&str>,
330 out: OutputConfig,
331 pagination: Pagination,
332) -> Result<(), Box<dyn std::error::Error>> {
333 let devices = match mac {
334 Some(m) => vec![client.get_device_ports(m).await?],
335 None => client.list_all_device_ports().await?,
336 };
337 let rows = collect_rows(&devices);
338 let total = rows.len();
339 let page: Vec<&PortRow> = rows
340 .iter()
341 .skip(pagination.offset)
342 .take(pagination.limit)
343 .collect();
344
345 if out.is_json() {
346 let items: Vec<serde_json::Value> = page
347 .iter()
348 .map(|r| {
349 let mut v = row_json(r);
350 project(&mut v, &pagination.fields);
351 v
352 })
353 .collect();
354 out.print_data(&serde_json::to_string_pretty(&serde_json::json!({
355 "items": items,
356 "total": total,
357 "limit": pagination.limit,
358 "offset": pagination.offset,
359 }))?);
360 } else {
361 let full_refs: Vec<&PortRow> = rows.iter().collect();
365 let dev_w = device_col_width(&full_refs);
366 render_text(&page, mac.is_none(), dev_w, &out);
367 }
368 Ok(())
369}
370
371pub fn find_port(device: &DeviceWithPorts, port_idx: u32) -> Result<&PortEntry, ApiError> {
373 device
374 .port_table
375 .iter()
376 .find(|p| p.port_idx == Some(port_idx))
377 .ok_or_else(|| {
378 let mac = device
379 .mac
380 .as_deref()
381 .map(format_mac)
382 .unwrap_or_else(|| "device".into());
383 ApiError::NotFound(format!("Port {port_idx} on {mac}"))
384 })
385}
386
387fn identifier_as_mac(identifier: &str) -> Option<String> {
392 let normalized = normalize_mac(identifier);
393 (normalized.len() == 12 && normalized.chars().all(|c| c.is_ascii_hexdigit()))
394 .then_some(normalized)
395}
396
397pub fn resolve_candidates(
411 identifier: &str,
412 clients: &[LegacyClient],
413) -> Result<Vec<String>, ApiError> {
414 if let Some(mac) = identifier_as_mac(identifier) {
415 return Ok(vec![mac]);
416 }
417
418 if let Some(c) = clients.iter().find(|c| c.ip.as_deref() == Some(identifier))
419 && let Some(mac) = c.mac.as_deref()
420 {
421 return Ok(vec![normalize_mac(mac)]);
422 }
423
424 let wanted = identifier.to_lowercase();
425 let by_name: Vec<&LegacyClient> = clients
426 .iter()
427 .filter(|c| {
428 c.name
429 .as_deref()
430 .is_some_and(|n| n.to_lowercase().contains(&wanted))
431 || c.hostname
432 .as_deref()
433 .is_some_and(|h| h.to_lowercase().contains(&wanted))
434 })
435 .collect();
436
437 if by_name.is_empty() {
438 return Err(ApiError::NotFound(format!(
439 "No client matching '{identifier}'"
440 )));
441 }
442
443 let macs: Vec<String> = by_name
444 .iter()
445 .filter_map(|c| c.mac.as_deref().map(normalize_mac))
446 .collect();
447 if macs.is_empty() {
448 return Err(ApiError::NotFound(format!(
449 "Client '{identifier}' has no MAC"
450 )));
451 }
452 Ok(macs)
453}
454
455fn candidate_descriptor(mac: &str, clients: &[LegacyClient], row: &PortRow) -> String {
460 let label = clients
461 .iter()
462 .find(|c| c.mac.as_deref().map(normalize_mac).as_deref() == Some(mac))
463 .and_then(|c| c.name.as_deref().or(c.hostname.as_deref()))
464 .unwrap_or("-");
465 let port = row
466 .port
467 .port_idx
468 .map(|i| i.to_string())
469 .unwrap_or_else(|| "-".into());
470 format!(
471 "{label} ({}) on {} port {port}",
472 format_mac(mac),
473 row.device_name
474 )
475}
476
477pub fn matching_rows<'a>(
480 rows: &'a [PortRow<'a>],
481 normalized_mac: &str,
482) -> Vec<(&'a PortRow<'a>, bool)> {
483 let mut hits: Vec<(&PortRow, bool)> = rows
484 .iter()
485 .filter_map(|r| {
486 let lc = r.port.last_connection.as_ref()?;
487 let m = lc.mac.as_deref()?;
488 (normalize_mac(m) == normalized_mac).then(|| (r, lc.connected.unwrap_or(false)))
489 })
490 .collect();
491 hits.sort_by_key(|(_, connected)| !*connected);
492 hits
493}
494
495pub async fn find(
505 client: &UnifiClient,
506 identifier: &str,
507 out: OutputConfig,
508 fields: Option<Vec<String>>,
509) -> Result<(), Box<dyn std::error::Error>> {
510 let (candidates, clients) = if let Some(mac) = identifier_as_mac(identifier) {
514 (vec![mac], Vec::new())
515 } else {
516 let clients = client.list_clients_legacy().await?;
517 let candidates = resolve_candidates(identifier, &clients)?;
518 (candidates, clients)
519 };
520
521 let devices = client.list_all_device_ports().await?;
522 let rows = collect_rows(&devices);
523
524 let mut ported: Vec<(String, Vec<(&PortRow, bool)>)> = candidates
529 .into_iter()
530 .filter_map(|mac| {
531 let hits = matching_rows(&rows, &mac);
532 (!hits.is_empty()).then_some((mac, hits))
533 })
534 .collect();
535
536 let hits = match ported.len() {
537 0 => {
538 return Err(Box::new(ApiError::NotFound(format!(
539 "No switch port with '{identifier}' attached"
540 ))));
541 }
542 1 => ported.pop().expect("checked len == 1 above").1,
543 _ => {
544 let list = ported
545 .iter()
546 .map(|(mac, hits)| candidate_descriptor(mac, &clients, hits[0].0))
547 .collect::<Vec<_>>()
548 .join(", ");
549 return Err(Box::new(ApiError::Conflict(format!(
550 "'{identifier}' matches {} devices on switch ports: {list}",
551 ported.len()
552 ))));
553 }
554 };
555
556 if out.is_json() {
557 let items: Vec<serde_json::Value> = hits
558 .iter()
559 .map(|(r, connected)| {
560 let mut v = row_json(r);
561 v["connected"] = (*connected).into();
562 project(&mut v, &fields);
563 v
564 })
565 .collect();
566 out.print_data(&serde_json::to_string_pretty(&items)?);
567 } else {
568 let refs: Vec<&PortRow> = hits.iter().map(|(r, _)| *r).collect();
569 let connected: Vec<bool> = hits.iter().map(|(_, c)| *c).collect();
570 let dev_w = device_col_width(&refs);
572 render_text_with_connected(&refs, dev_w, &connected, &out);
573 }
574 Ok(())
575}
576
577pub async fn show(
578 client: &UnifiClient,
579 mac: &str,
580 port_idx: u32,
581 out: OutputConfig,
582) -> Result<(), Box<dyn std::error::Error>> {
583 let device = client.get_device_ports(mac).await?;
584 let p = find_port(&device, port_idx)?;
585 let (device_mac, device_name) = device_identity(&device, "-");
586 let Attachment {
587 attached_mac,
588 last_seen_mac,
589 connected: attached_connected,
590 } = attachment(p);
591
592 if out.is_json() {
593 out.print_data(&serde_json::to_string_pretty(&serde_json::json!({
594 "device_mac": device_mac,
595 "device_name": device_name,
596 "port_idx": p.port_idx,
597 "name": p.name,
598 "media": p.media,
599 "up": p.up,
600 "speed": p.speed,
601 "full_duplex": p.full_duplex,
602 "autoneg": p.autoneg,
603 "enable": p.enable,
604 "is_uplink": p.is_uplink,
605 "stp_state": p.stp_state,
606 "port_poe": p.port_poe,
607 "poe_enable": p.poe_enable,
608 "poe_mode": p.poe_mode,
609 "poe_class": p.poe_class,
610 "poe_power": p.poe_power,
611 "poe_voltage": p.poe_voltage,
612 "poe_current": p.poe_current,
613 "poe_good": p.poe_good,
614 "attached_mac": attached_mac,
615 "attached_last_seen_mac": last_seen_mac,
616 "attached_connected": attached_connected,
617 "tx_bytes": p.tx_bytes,
618 "rx_bytes": p.rx_bytes,
619 "tx_errors": p.tx_errors,
620 "rx_errors": p.rx_errors,
621 }))?);
622 return Ok(());
623 }
624
625 let color = use_color();
626 let label = |l: &str| -> String {
627 if color {
628 format!("{}", l.dimmed())
629 } else {
630 l.to_string()
631 }
632 };
633 let title = format!("Port {port_idx} on {device_name} ({device_mac})");
634 if color {
635 println!("{}", title.bold());
636 } else {
637 println!("{title}");
638 }
639 println!(
640 " {} {}",
641 label("Name: "),
642 p.name.as_deref().unwrap_or("-")
643 );
644 println!(
645 " {} {}",
646 label("Link: "),
647 if p.up { "up" } else { "down" }
648 );
649 println!(" {} {}", label("Speed: "), speed_cell(p));
650 println!(
651 " {} {}",
652 label("Media: "),
653 p.media.as_deref().unwrap_or("-")
654 );
655 println!(
656 " {} {}",
657 label("PoE: "),
658 if p.port_poe {
659 poe_cell(p)
660 } else {
661 "not supported".into()
662 }
663 );
664 if p.port_poe {
665 println!(
666 " {} {}",
667 label("PoE mode: "),
668 p.poe_mode.as_deref().unwrap_or("-")
669 );
670 println!(
671 " {} {}",
672 label("PoE class:"),
673 p.poe_class.as_deref().unwrap_or("-")
674 );
675 if let Some(v) = p.poe_voltage {
676 println!(" {} {v:.2} V", label("Voltage: "));
677 }
678 if let Some(c) = p.poe_current {
679 println!(" {} {c:.2} mA", label("Current: "));
680 }
681 }
682 let attached_cell = match (&attached_mac, &last_seen_mac, attached_connected) {
687 (Some(mac), _, _) => mac.clone(),
688 (None, Some(seen), Some(false)) => format!("- (last seen {seen})"),
689 (None, Some(seen), _) => format!("unknown (last seen {seen})"),
690 (None, None, _) => "-".to_string(),
691 };
692 println!(" {} {}", label("Attached: "), attached_cell);
693 Ok(())
694}
695
696pub fn check_cyclable(port: &PortEntry, device_mac: &str) -> Result<(), ApiError> {
698 let idx = port
699 .port_idx
700 .map(|i| i.to_string())
701 .unwrap_or_else(|| "?".into());
702 let mac = format_mac(device_mac);
703
704 if !port.port_poe {
711 return Err(ApiError::Conflict(format!(
712 "Port {idx} on {mac} does not support PoE. \
713 Run `unifi ports list {mac}` to see PoE-capable ports."
714 )));
715 }
716 if port.poe_mode.as_deref() == Some("off") {
719 return Err(ApiError::Conflict(format!(
720 "PoE is administratively disabled on port {idx} of {mac} (poe_mode=off)"
721 )));
722 }
723 if !port.poe_enable {
731 return Err(ApiError::Conflict(format!(
732 "Port {idx} on {mac} is not currently delivering PoE (poe_enable=false), \
733 so there is no power to cycle."
734 )));
735 }
736 Ok(())
737}
738
739#[derive(Debug, PartialEq, Eq)]
742pub enum CycleOutcome {
743 Cycled,
744 Declined,
745}
746
747pub async fn cycle<F>(
755 client: &UnifiClient,
756 mac: &str,
757 port_idx: u32,
758 out: OutputConfig,
759 confirm: F,
760) -> Result<CycleOutcome, Box<dyn std::error::Error>>
761where
762 F: FnOnce(&str) -> std::io::Result<bool>,
763{
764 let device = client.get_device_ports(mac).await?;
765 let port = find_port(&device, port_idx)?;
766 let device_mac = device.mac.as_deref().unwrap_or(mac).to_string();
767 check_cyclable(port, &device_mac)?;
768
769 if !confirm(&cycle_summary(&device, port))? {
770 return Ok(CycleOutcome::Declined);
771 }
772
773 client.power_cycle_port(&device_mac, port_idx).await?;
774 out.print_result(
775 &serde_json::json!({
776 "status": "ok",
777 "action": "power-cycle",
778 "mac": format_mac(&device_mac),
779 "port_idx": port_idx,
780 }),
781 &format!(
782 "Power-cycling port {port_idx} on {}",
783 format_mac(&device_mac)
784 ),
785 );
786 Ok(CycleOutcome::Cycled)
787}
788
789pub fn cycle_summary(device: &DeviceWithPorts, port: &PortEntry) -> String {
791 let device_mac = device
792 .mac
793 .as_deref()
794 .map(format_mac)
795 .unwrap_or_else(|| "-".into());
796 let device_name = device
797 .name
798 .as_deref()
799 .or(device.model.as_deref())
800 .unwrap_or("-");
801 let idx = port
802 .port_idx
803 .map(|i| i.to_string())
804 .unwrap_or_else(|| "?".into());
805 let attached = port
806 .last_connection
807 .as_ref()
808 .filter(|lc| lc.connected.unwrap_or(false))
809 .and_then(|lc| lc.mac.as_deref())
810 .map(format_mac)
811 .unwrap_or_else(|| "nothing attached".into());
812 let draw = match port.poe_power {
813 Some(w) if w > 0.0 => format!("{w:.2} W"),
814 _ => "0 W".into(),
815 };
816 let class = port.poe_class.as_deref().unwrap_or("-");
817 format!(
818 "Port {idx} on {device_name} ({device_mac})\n attached: {attached} • {draw} • {class}"
819 )
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825
826 fn device(json: serde_json::Value) -> DeviceWithPorts {
829 serde_json::from_value(json).expect("test fixture must deserialize as DeviceWithPorts")
830 }
831
832 #[test]
833 fn collect_rows_flattens_multiple_devices_and_skips_empty_port_tables() {
834 let devices = vec![
835 device(serde_json::json!({
836 "mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA",
837 "port_table": [{"port_idx": 1}, {"port_idx": 2}]
838 })),
839 device(serde_json::json!({
840 "mac": "aa:bb:cc:dd:ee:02", "name": "APWithNoPorts",
841 "port_table": []
842 })),
843 device(serde_json::json!({
844 "mac": "aa:bb:cc:dd:ee:03", "name": "SwitchC",
845 "port_table": [{"port_idx": 1}]
846 })),
847 ];
848
849 let rows = collect_rows(&devices);
850
851 assert_eq!(
852 rows.len(),
853 3,
854 "device with an empty port_table must contribute no rows"
855 );
856 assert_eq!(rows[0].device_name, "SwitchA");
857 assert_eq!(rows[0].port.port_idx, Some(1));
858 assert_eq!(rows[1].device_name, "SwitchA");
859 assert_eq!(rows[1].port.port_idx, Some(2));
860 assert_eq!(rows[2].device_name, "SwitchC");
861 assert_eq!(rows[2].port.port_idx, Some(1));
862 assert!(
863 rows.iter().all(|r| r.device_name != "APWithNoPorts"),
864 "a device with no ports must never appear in the flattened rows"
865 );
866 }
867
868 #[test]
869 fn collect_rows_formats_device_mac_and_falls_back_to_model_when_name_is_absent() {
870 let devices = vec![
871 device(serde_json::json!({
872 "mac": "aabbccdd0643", "name": "USW-24-PoE",
873 "port_table": [{"port_idx": 1}]
874 })),
875 device(serde_json::json!({
876 "mac": "aabbccddeeff", "model": "USW-Lite-8",
877 "port_table": [{"port_idx": 1}]
878 })),
879 device(serde_json::json!({
880 "mac": "112233445566",
881 "port_table": [{"port_idx": 1}]
882 })),
883 ];
884
885 let rows = collect_rows(&devices);
886
887 assert_eq!(
888 rows[0].device_mac, "aa:bb:cc:dd:06:43",
889 "device_mac must be formatted via format_mac, not passed through raw"
890 );
891 assert_eq!(rows[0].device_name, "USW-24-PoE");
892
893 assert_eq!(rows[1].device_mac, "aa:bb:cc:dd:ee:ff");
894 assert_eq!(
895 rows[1].device_name, "USW-Lite-8",
896 "device_name must fall back to model when name is absent"
897 );
898
899 assert_eq!(rows[2].device_mac, "11:22:33:44:55:66");
900 assert_eq!(
901 rows[2].device_name, "-",
902 "device_name must fall back to '-' when both name and model are absent"
903 );
904 }
905
906 #[test]
907 fn collect_rows_with_fallback_uses_the_caller_supplied_fallback() {
908 let devices = vec![device(serde_json::json!({
912 "mac": "aa:bb:cc:dd:ee:ff",
913 "port_table": [{"port_idx": 1}]
914 }))];
915
916 let fallback_rows = collect_rows_with_fallback(&devices, "Device");
917 assert_eq!(fallback_rows[0].device_name, "Device");
918
919 let default_rows = collect_rows(&devices);
920 assert_eq!(
921 default_rows[0].device_name, "-",
922 "collect_rows must still fall back to '-', unaffected by the new parameter"
923 );
924 }
925
926 #[test]
927 fn row_json_emits_exactly_the_fields_declared_in_ports_list() {
928 let devices = vec![device(serde_json::json!({
929 "mac": "aa:bb:cc:dd:ee:ff", "name": "SwitchA",
930 "port_table": [{
931 "port_idx": 1, "name": "Port 1", "media": "GE", "up": true,
932 "speed": 1000, "full_duplex": true, "poe_enable": true,
933 "poe_power": 4.5, "port_poe": true, "tx_bytes": 100, "rx_bytes": 200
934 }]
935 }))];
936 let rows = collect_rows(&devices);
937 let value = row_json(&rows[0]);
938 let obj = value.as_object().expect("row_json must emit a JSON object");
939
940 let mut emitted: Vec<&str> = obj.keys().map(String::as_str).collect();
941 emitted.sort_unstable();
942 let mut declared: Vec<&str> = crate::fields::names(crate::fields::PORTS_LIST);
943 declared.sort_unstable();
944
945 assert_eq!(
946 emitted, declared,
947 "row_json keys must exactly match fields::PORTS_LIST, so the two cannot drift"
948 );
949 }
950
951 #[test]
952 fn project_retains_only_the_requested_fields() {
953 let mut value = serde_json::json!({"a": 1, "b": 2, "c": 3});
954 project(&mut value, &Some(vec!["a".to_string(), "c".to_string()]));
955
956 let obj = value.as_object().unwrap();
957 assert_eq!(obj.len(), 2);
958 assert!(obj.contains_key("a"));
959 assert!(obj.contains_key("c"));
960 assert!(!obj.contains_key("b"), "unrequested fields must be dropped");
961 }
962
963 #[test]
964 fn project_is_a_noop_when_fields_is_none() {
965 let mut value = serde_json::json!({"a": 1, "b": 2, "c": 3});
966 let before = value.clone();
967
968 project(&mut value, &None);
969
970 assert_eq!(
971 value, before,
972 "a None projection must leave the value untouched"
973 );
974 }
975
976 #[test]
984 fn pad_visible_pads_by_plain_width_not_escaped_byte_length() {
985 let escaped = format!("{}", "up".green());
991 assert!(
992 escaped.len() > "up".len(),
993 "fixture must actually carry escape bytes, or this test proves nothing: {escaped:?}"
994 );
995
996 let padded = pad_visible(&escaped, "up", 6);
997
998 assert!(
999 padded.starts_with(&escaped),
1000 "the coloured text itself must be emitted untouched: {padded:?}"
1001 );
1002 let visible_padding = &padded[escaped.len()..];
1003 assert_eq!(
1004 visible_padding, " ",
1005 "padding must be derived from \"up\".len() (2), not the escaped \
1006 string's byte length: {padded:?}"
1007 );
1008 }
1009
1010 #[test]
1011 fn pad_visible_matches_the_uncoloured_output_it_replaces() {
1012 assert_eq!(pad_visible("up", "up", 6), format!("{:<6}", "up"));
1016 assert_eq!(pad_visible("down", "down", 6), format!("{:<6}", "down"));
1017 assert_eq!(pad_visible("yes", "yes", 9), format!("{:<9}", "yes"));
1018 assert_eq!(pad_visible("-", "-", 9), format!("{:<9}", "-"));
1019 }
1020
1021 #[test]
1022 fn pad_visible_pads_nothing_when_plain_already_fills_the_width() {
1023 assert_eq!(pad_visible("down", "down", 4), "down");
1024 }
1025
1026 #[test]
1027 fn port_noun_is_singular_for_exactly_one_row() {
1028 assert_eq!(port_noun(1), "port");
1029 }
1030
1031 #[test]
1032 fn port_noun_is_plural_for_zero_or_many_rows() {
1033 assert_eq!(port_noun(0), "ports");
1034 assert_eq!(port_noun(2), "ports");
1035 assert_eq!(port_noun(100), "ports");
1036 }
1037
1038 fn device_with(ports: serde_json::Value) -> DeviceWithPorts {
1039 serde_json::from_value(serde_json::json!({
1040 "mac": "aa:bb:cc:dd:ee:ff",
1041 "name": "SwitchA",
1042 "port_table": ports
1043 }))
1044 .expect("fixture must parse")
1045 }
1046
1047 #[test]
1048 fn find_port_returns_the_matching_entry() {
1049 let d = device_with(serde_json::json!([
1050 {"port_idx": 1, "port_poe": true},
1051 {"port_idx": 5, "port_poe": true, "poe_mode": "auto"}
1052 ]));
1053 let p = find_port(&d, 5).expect("port 5 exists");
1054 assert_eq!(p.port_idx, Some(5));
1055 assert_eq!(p.poe_mode.as_deref(), Some("auto"));
1056 }
1057
1058 #[test]
1059 fn find_port_missing_is_not_found() {
1060 let d = device_with(serde_json::json!([{"port_idx": 1}]));
1061 let err = find_port(&d, 99).expect_err("port 99 does not exist");
1062 assert!(matches!(err, crate::api::ApiError::NotFound(_)));
1063 }
1064
1065 #[test]
1066 fn check_cyclable_rejects_non_poe_port() {
1067 let d = device_with(serde_json::json!([{"port_idx": 9, "port_poe": false}]));
1068 let p = find_port(&d, 9).unwrap();
1069 let err = check_cyclable(p, "aa:bb:cc:dd:ee:ff").expect_err("SFP+ has no PoE");
1070 match err {
1071 crate::api::ApiError::Conflict(msg) => {
1072 assert!(msg.contains("does not support PoE"), "got: {msg}")
1073 }
1074 other => panic!("expected Conflict, got {other:?}"),
1075 }
1076 }
1077
1078 #[test]
1079 fn check_cyclable_rejects_poe_mode_off() {
1080 let d = device_with(serde_json::json!([
1081 {"port_idx": 4, "port_poe": true, "poe_mode": "off"}
1082 ]));
1083 let p = find_port(&d, 4).unwrap();
1084 let err = check_cyclable(p, "aa:bb:cc:dd:ee:ff").expect_err("PoE is off");
1085 match err {
1086 crate::api::ApiError::Conflict(msg) => {
1087 assert!(msg.contains("poe_mode=off"), "got: {msg}")
1088 }
1089 other => panic!("expected Conflict, got {other:?}"),
1090 }
1091 }
1092
1093 #[test]
1094 fn check_cyclable_allows_absent_poe_mode() {
1095 let d = device_with(serde_json::json!([
1100 {"port_idx": 4, "port_poe": true, "poe_enable": true}
1101 ]));
1102 let p = find_port(&d, 4).unwrap();
1103 assert!(check_cyclable(p, "aa:bb:cc:dd:ee:ff").is_ok());
1104 }
1105
1106 #[test]
1107 fn check_cyclable_allows_a_port_actually_delivering_power() {
1108 let d = device_with(serde_json::json!([
1111 {"port_idx": 4, "port_poe": true, "poe_mode": "auto", "poe_enable": true}
1112 ]));
1113 let p = find_port(&d, 4).unwrap();
1114 assert!(check_cyclable(p, "aa:bb:cc:dd:ee:ff").is_ok());
1115 }
1116
1117 #[test]
1118 fn check_cyclable_rejects_poe_enable_false() {
1119 let d = device_with(serde_json::json!([
1127 {"port_idx": 4, "port_poe": true, "poe_mode": "auto", "up": false}
1128 ]));
1129 let p = find_port(&d, 4).unwrap();
1130 let err = check_cyclable(p, "aa:bb:cc:dd:ee:fe").expect_err("poe_enable is false");
1131 match err {
1132 crate::api::ApiError::Conflict(msg) => {
1133 assert!(msg.contains("not currently delivering PoE"), "got: {msg}")
1134 }
1135 other => panic!("expected Conflict, got {other:?}"),
1136 }
1137 }
1138
1139 #[test]
1140 fn check_cyclable_poe_mode_off_message_wins_over_poe_enable_false() {
1141 let d = device_with(serde_json::json!([
1147 {"port_idx": 4, "port_poe": true, "poe_mode": "off", "poe_enable": false}
1148 ]));
1149 let p = find_port(&d, 4).unwrap();
1150 let err = check_cyclable(p, "aa:bb:cc:dd:ee:ff").expect_err("PoE is off");
1151 match err {
1152 crate::api::ApiError::Conflict(msg) => {
1153 assert!(msg.contains("poe_mode=off"), "got: {msg}");
1154 assert!(
1155 !msg.contains("not currently delivering PoE"),
1156 "the administratively-disabled message must win over the \
1157 poe_enable=false message: {msg}"
1158 );
1159 }
1160 other => panic!("expected Conflict, got {other:?}"),
1161 }
1162 }
1163
1164 #[test]
1170 fn cycle_summary_shows_the_attached_mac_when_connected() {
1171 let d = device_with(serde_json::json!([{
1172 "port_idx": 4, "port_poe": true,
1173 "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}
1174 }]));
1175 let p = find_port(&d, 4).unwrap();
1176 let summary = cycle_summary(&d, p);
1177 assert!(
1178 summary.contains("aa:bb:cc:dd:ee:10"),
1179 "a connected last_connection must show the formatted attached MAC: {summary}"
1180 );
1181 }
1182
1183 #[test]
1184 fn cycle_summary_reads_nothing_attached_for_a_stale_record() {
1185 let d = device_with(serde_json::json!([{
1188 "port_idx": 4, "port_poe": true,
1189 "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false}
1190 }]));
1191 let p = find_port(&d, 4).unwrap();
1192 let summary = cycle_summary(&d, p);
1193 assert!(
1194 summary.contains("nothing attached"),
1195 "a stale (disconnected) last_connection must read as unattached: {summary}"
1196 );
1197 assert!(
1198 !summary.contains("aa:bb:cc:dd:ee:10"),
1199 "a stale MAC must not appear as if it were live: {summary}"
1200 );
1201 }
1202
1203 #[test]
1204 fn cycle_summary_reads_nothing_attached_when_no_last_connection() {
1205 let d = device_with(serde_json::json!([{"port_idx": 4, "port_poe": true}]));
1206 let p = find_port(&d, 4).unwrap();
1207 let summary = cycle_summary(&d, p);
1208 assert!(
1209 summary.contains("nothing attached"),
1210 "an absent last_connection must read as unattached: {summary}"
1211 );
1212 }
1213
1214 #[test]
1215 fn cycle_summary_shows_the_wattage_for_a_powered_port() {
1216 let d = device_with(serde_json::json!([{
1217 "port_idx": 4, "port_poe": true, "poe_enable": true,
1218 "poe_power": 5.25, "poe_class": "4"
1219 }]));
1220 let p = find_port(&d, 4).unwrap();
1221 let summary = cycle_summary(&d, p);
1222 assert!(
1223 summary.contains("5.25 W"),
1224 "draw must be formatted to two decimal places: {summary}"
1225 );
1226 }
1227
1228 fn clients_fixture() -> Vec<crate::api::LegacyClient> {
1231 serde_json::from_value(serde_json::json!([
1232 {"_id": "1", "mac": "aa:bb:cc:dd:ee:10", "name": "garage-pi", "ip": "192.0.2.5"},
1233 {"_id": "2", "mac": "aa:bb:cc:dd:ee:20", "name": "office-ap", "ip": "192.0.2.6"},
1234 {"_id": "3", "mac": "aa:bb:cc:dd:ee:21", "name": "Main-Office", "ip": "192.0.2.7"}
1235 ]))
1236 .expect("fixture must parse")
1237 }
1238
1239 #[test]
1240 fn resolve_candidates_accepts_any_mac_format() {
1241 let c = clients_fixture();
1242 assert_eq!(
1244 resolve_candidates("AA-BB-CC-DD-EE-10", &c).unwrap(),
1245 vec!["aabbccddee10"]
1246 );
1247 }
1248
1249 #[test]
1250 fn resolve_candidates_matches_ip_then_name() {
1251 let c = clients_fixture();
1252 assert_eq!(
1253 resolve_candidates("192.0.2.5", &c).unwrap(),
1254 vec!["aabbccddee10"]
1255 );
1256 assert_eq!(
1257 resolve_candidates("GARAGE-PI", &c).unwrap(),
1258 vec!["aabbccddee10"]
1259 );
1260 }
1261
1262 #[test]
1266 fn resolve_candidates_returns_every_name_match_without_erroring() {
1267 let c = clients_fixture();
1268 let macs = resolve_candidates("office", &c).expect("both are valid candidates");
1269 assert_eq!(
1270 macs,
1271 vec!["aabbccddee20".to_string(), "aabbccddee21".to_string()],
1272 "both office-ap and Main-Office must come back as candidates"
1273 );
1274 }
1275
1276 #[test]
1277 fn resolve_candidates_unknown_is_not_found() {
1278 let c = clients_fixture();
1279 let err = resolve_candidates("nothing-here", &c).expect_err("unknown");
1280 assert!(matches!(err, crate::api::ApiError::NotFound(_)));
1281 }
1282
1283 #[test]
1284 fn matching_rows_sort_connected_first() {
1285 let devices: Vec<DeviceWithPorts> = serde_json::from_value(serde_json::json!([{
1286 "mac": "aa:bb:cc:dd:ee:ff",
1287 "name": "SwitchA",
1288 "port_table": [
1289 {"port_idx": 2, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false}},
1290 {"port_idx": 7, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}},
1291 {"port_idx": 9, "last_connection": {"mac": "11:22:33:44:55:66", "connected": true}}
1292 ]
1293 }]))
1294 .expect("fixture must parse");
1295 let rows = collect_rows(&devices);
1296 let hits = matching_rows(&rows, "aabbccddee10");
1297 assert_eq!(hits.len(), 2, "device appears on two ports");
1298 assert_eq!(
1299 hits[0].0.port.port_idx,
1300 Some(7),
1301 "connected port sorts first"
1302 );
1303 assert!(hits[0].1, "first hit is connected");
1304 assert!(!hits[1].1, "second hit is the stale record");
1305 }
1306
1307 #[test]
1308 fn find_json_row_matches_exactly_the_fields_declared_in_ports_find() {
1309 let devices = vec![device(serde_json::json!({
1313 "mac": "aa:bb:cc:dd:ee:ff", "name": "SwitchA",
1314 "port_table": [{
1315 "port_idx": 7,
1316 "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}
1317 }]
1318 }))];
1319 let rows = collect_rows(&devices);
1320 let hits = matching_rows(&rows, "aabbccddee10");
1321 let (row, connected) = hits[0];
1322 let mut value = row_json(row);
1323 value["connected"] = connected.into();
1324
1325 let obj = value.as_object().expect("must emit a JSON object");
1326 let mut emitted: Vec<&str> = obj.keys().map(String::as_str).collect();
1327 emitted.sort_unstable();
1328 let mut declared: Vec<&str> = crate::fields::names(crate::fields::PORTS_FIND);
1329 declared.sort_unstable();
1330
1331 assert_eq!(
1332 emitted, declared,
1333 "find's emitted keys must exactly match fields::PORTS_FIND"
1334 );
1335 }
1336}