Skip to main content

unifi_cli/commands/
ports.rs

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
9/// One port, flattened with the device that owns it. Every `ports` subcommand
10/// renders these, so the filtered and unfiltered listings cannot drift apart.
11pub 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    /// Field names already validated against `fields::PORTS_LIST`.
21    pub fields: Option<Vec<String>>,
22}
23
24/// Derive a port row's formatted device MAC and display name: `name` ->
25/// `model` -> `name_fallback`. Shared by `show` and `collect_rows_with_fallback`
26/// so this three-tier fallback can never drift between the two call sites.
27fn 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
42/// Flatten devices into port rows, skipping devices with no port table.
43/// `ports list` / `ports find` fall back to `"-"` for a device with neither
44/// `name` nor `model`.
45pub fn collect_rows(devices: &[DeviceWithPorts]) -> Vec<PortRow<'_>> {
46    collect_rows_with_fallback(devices, "-")
47}
48
49/// Same flattening as `collect_rows`, but with a caller-chosen fallback for a
50/// device that has neither `name` nor `model`. `devices ports` keeps its
51/// historical `"Device"` label here rather than duplicating the whole
52/// flattening loop just to change one fallback string.
53pub 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
74/// The `PORTS_LIST` field set for one row.
75pub 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
94/// Apply a validated `--fields` projection in place.
95pub 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
103/// Human-readable PoE cell: draw in watts, or on/off/- .
104fn 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
127/// What a port's `last_connection` record says, split into what is attached
128/// *now* and what the record names regardless of state.
129struct Attachment {
130    /// The MAC only when the controller affirms the record is live.
131    attached_mac: Option<String>,
132    /// The MAC whenever the record names one, live or not.
133    last_seen_mac: Option<String>,
134    /// The controller's own `connected` flag, kept tri-state: `None` means the
135    /// firmware did not report it, which is not the same fact as `false`.
136    connected: Option<bool>,
137}
138
139/// Read a port's `last_connection` record.
140///
141/// The controller keeps the record after the device is unplugged, marking it
142/// `connected: false`. A stale record therefore names a device that may have
143/// been gone for months, and reporting it as the attachment would tell an
144/// operator a port is in use moments before they cut its power. So
145/// `attached_mac` is set only when the controller affirms `connected: true`;
146/// a firmware that omits the flag says nothing about the present, which is
147/// not grounds to claim an attachment either. Nothing is lost: the MAC is
148/// always available as `last_seen_mac` and the raw flag as `connected`, so a
149/// caller can tell "gone" from "not reported". `cycle_summary` applies the
150/// same rule to the confirmation prompt.
151fn 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
173/// Right-pad a table cell to `width` visible columns, deriving the padding
174/// from `plain`'s length rather than `rendered`'s.
175///
176/// This exists because `format!("{:<width$}", rendered)` counts *bytes*: a
177/// coloured cell like `"up".green()` renders as `"\x1b[32mup\x1b[39m"`, 12
178/// bytes for 2 visible characters, which `{:<6}` sees as already over width
179/// and pads with nothing. Every coloured cell in this module's tables must
180/// go through this instead of a `{:<N}` specifier.
181///
182/// `rendered` is `plain` itself on the uncoloured path (see call sites), so
183/// that path pads identically to the `{:<width$}` it replaces, and this must
184/// stay true, since existing tests assert on uncoloured output byte-for-byte.
185fn pad_visible(rendered: &str, plain: &str, width: usize) -> String {
186    let pad = width.saturating_sub(plain.len());
187    format!("{rendered}{}", " ".repeat(pad))
188}
189
190/// "port" for exactly one row, "ports" otherwise: the row-count trailer's
191/// singular/plural noun.
192fn port_noun(count: usize) -> &'static str {
193    if count == 1 { "port" } else { "ports" }
194}
195
196/// Device column width, in characters. Callers that paginate must compute
197/// this from the full result set, not just the page handed to `render_text`.
198/// Otherwise two `--offset` pages of the same query can render the column at
199/// different widths.
200pub 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
209/// Render rows as a table. `show_device_col` is true only for the unfiltered
210/// listing; the filtered table stays byte-identical to what `devices ports`
211/// has always printed. `dev_w` is the Device column width; pass
212/// `device_col_width` of the *full* result set, not just `rows`, so a
213/// paginated caller renders a stable width across pages.
214pub 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
218/// Same table as `render_text`, with an extra `Connected` column (`yes`/`-`)
219/// appended after RX, aligned by index with `rows`. Only `ports find` calls
220/// this: `find`'s entire purpose is telling the operator which port a device
221/// is on *now*, and previously only the connected-first sort order
222/// distinguished that from stale history. `list` and `devices ports` keep
223/// calling `render_text` above, unaffected by this column's existence.
224pub 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
233/// Shared implementation behind `render_text` and `render_text_with_connected`.
234/// `connected` is `None` for `render_text`'s two callers, so their output is
235/// untouched; `Some` only from `render_text_with_connected`.
236fn 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    // Must match the `{:<N}` widths the header below uses for these two
245    // columns, so `pad_visible` reproduces them exactly.
246    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        // Computed from the full `rows`, not the paginated `page`, so two
362        // `--offset` pages of the same query render the Device column at the
363        // same width.
364        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
371/// Locate a port by index within a device's port table.
372pub 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
387/// Normalize `identifier` and return it only if it already has MAC shape (12
388/// hex digits once separators are stripped). Shared by `resolve_identifier`
389/// and `find` so a MAC identifier is recognized identically in both places
390/// without duplicating the predicate.
391fn 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
397/// Resolve a MAC, IP, or client name to every candidate MAC it could refer
398/// to. Deliberately does *not* decide ambiguity here: two client records can
399/// share a name because they are two interfaces (wired and wireless, say) of
400/// one physical device, and only one of them may ever appear in a switch's
401/// port table. `find` decides ambiguity from port occupancy instead, after
402/// looking up every candidate this returns.
403///
404/// Ordered, stopping at the first tier that matches: normalized MAC equality,
405/// then exact IP, then case-insensitive name/hostname substring (all matches
406/// in that last tier are returned together). Follows the
407/// `protect cameras show <id-or-name>` precedent rather than the MAC-only
408/// convention of `clients show`, because the whole point of `find` is not
409/// having to look the MAC up first.
410pub 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
455/// Describe one `find` candidate for a conflict message: its name/hostname,
456/// formatted MAC, and the switch port it was found on (the connected-first
457/// row, i.e. `hits[0]`). Only called once a candidate is already known to
458/// have at least one port match.
459fn 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
477/// Rows whose `last_connection.mac` matches, connected first so a stale record
478/// reads as history rather than as the device's current location.
479pub 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
495/// Find which switch port a device is attached to, by MAC, IP, or client
496/// name.
497///
498/// Resolution and port lookup interleave rather than picking a single client
499/// up front: a name can match more than one client record while only one of
500/// them is ever attached to a switch port (a device's wired and wireless
501/// interfaces commonly share a name and report separately). Ambiguity is
502/// judged by port occupancy, computed after fetching the port tables, not by
503/// how many client records the name matched.
504pub 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    // A MAC identifier resolves locally, so the common scripted path stays a
511    // single round trip: no client lookup is needed to know which MAC to
512    // look for on the port tables.
513    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    // Port matches for every candidate, keeping only the ones actually on a
525    // port. A candidate that matched the name/IP but never appears in any
526    // port table (e.g. a client's WiFi interface, when only its wired
527    // interface is on a switch) is not noise worth surfacing here.
528    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        // `find` never paginates, so `refs` is already the full result set.
571        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    // Only an affirmed connection prints as a bare MAC, so the line can never
683    // be read as "this device is plugged in right now" unless it is. A stale
684    // record and an unreported one are both qualified, and differently: the
685    // first knows the device is gone, the second knows nothing.
686    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
696/// Reject a power-cycle that cannot succeed, before any HTTP call.
697pub 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    // `port_poe` is `#[serde(default)] bool` (see src/api/types.rs), so
705    // firmware that simply omits the key also lands here, indistinguishable
706    // from a genuinely non-PoE port. That is deliberate: for a command that
707    // cuts power, failing closed is the right direction. It does mean the
708    // message/hint below can fire for PoE-capable hardware whose firmware
709    // didn't report the field, not only for true non-PoE ports.
710    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    // Only an explicit "off" blocks. An absent or unrecognised poe_mode
717    // proceeds: the field is not guaranteed across firmware revisions.
718    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    // `poe_enable` is the controller's own precondition, confirmed in both
724    // directions against a live UCG-Max: a port with port_poe: true,
725    // poe_mode: "auto" and poe_enable: false rejects `power-cycle` with HTTP
726    // 400 api.err.InvalidTargetPort, while the same command against a port
727    // with poe_enable: true succeeds and reboots the attached device.
728    // Checking it here turns that 400 into a local `conflict` naming the
729    // reason, instead of an opaque status from the controller.
730    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/// Whether the cycle actually happened. `Declined` is not an error at this
740/// layer; the caller decides how to report a refused confirmation.
741#[derive(Debug, PartialEq, Eq)]
742pub enum CycleOutcome {
743    Cycled,
744    Declined,
745}
746
747/// Power-cycle one PoE port.
748///
749/// `confirm` receives a human-readable summary of what is about to lose power
750/// and returns whether to proceed. Taking it as a callback keeps the device
751/// fetch and the guard rails to exactly one pass: the prompt needs the same
752/// port data the checks do, so resolving it twice would mean two round trips
753/// to the controller and two chances for the answers to disagree.
754pub 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
789/// One-line description of what is about to lose power, shown at the prompt.
790pub 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    /// Build a `DeviceWithPorts` fixture from a JSON literal, exercising the
827    /// same `Deserialize` impl the API layer uses.
828    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        // `devices ports` restores the historical "Device" label for a
909        // device with neither `name` nor `model`; `collect_rows` (used by
910        // `ports list` / `ports find`) must keep falling back to "-".
911        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    // `pad_visible` is what makes coloured Link/Connected cells line up with
977    // the header at a TTY. `use_color()` reads `stdout().is_terminal()`
978    // directly with no override, so a unit test cannot force colour on for
979    // `render_rows` itself. Testing the padding helper directly, with a
980    // hand-built ANSI-escaped string standing in for what `owo_colors` would
981    // emit, exercises the defect it guards against (padding computed from
982    // byte length instead of visible width) without needing a real TTY.
983    #[test]
984    fn pad_visible_pads_by_plain_width_not_escaped_byte_length() {
985        // The real thing `render_rows` hands `pad_visible` on the coloured
986        // path: `"up".green()` rendered to a `String`, several bytes of ANSI
987        // escapes wrapped around 2 visible characters. A `{:<6}` specifier
988        // sees this as already over width 6 (that was the bug) and pads with
989        // nothing at all.
990        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        // On the uncoloured path every call site passes `rendered == plain`,
1013        // so this must reproduce exactly what the old `{:<width$}` specifier
1014        // produced: the uncoloured path must not change at all.
1015        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        // poe_mode is not guaranteed across firmware. A missing value must not
1096        // block a port that already passed the port_poe check. poe_enable is
1097        // set explicitly here so this test stays about poe_mode alone, not
1098        // about the separate poe_enable guard below.
1099        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        // The genuinely cyclable case: PoE-capable, auto, and delivering
1109        // power right now.
1110        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        // This fixture is the live UCG-Max finding that prompted this guard:
1120        // PoE-capable, mode "auto", passing both prior checks, but not
1121        // currently delivering power (poe_enable defaults to false here,
1122        // matching what the controller reported). Firing power-cycle at it
1123        // was rejected with HTTP 400 api.err.InvalidTargetPort instead of
1124        // succeeding, which is why this must be rejected locally too rather
1125        // than treated as the earlier "happy path" this test used to assert.
1126        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        // poe_mode: "off" implies poe_enable: false (confirmed explicitly
1142        // here rather than relying on the default), so both guards would
1143        // fire. The administratively-disabled message must win: it is the
1144        // more specific and more useful of the two, and the poe_mode check
1145        // runs first in `check_cyclable`.
1146        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    // `cycle_summary` is the text a human reads before authorising a power
1165    // cut. Untested, it carries real logic that would be easy to invert or
1166    // drop silently: the `connected` filter on `last_connection`, and the
1167    // watt formatting.
1168
1169    #[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        // connected: false is history, not the device's current location; the
1186        // summary must not read as if a live device would lose power.
1187        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    // `_id` is required by `LegacyClient`, so every record here supplies one
1229    // or the fixture will not deserialize.
1230    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        // A MAC resolves without consulting the client list at all.
1243        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    // A name matching multiple client records is not an error here.
1263    // `resolve_candidates` returns every candidate; `find` calls it a conflict
1264    // only once it also knows more than one of them sits on a switch port.
1265    #[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        // Exercises the same construction `find` uses (`row_json` plus the
1310        // manually-inserted `connected` key) without needing an HTTP mock, so
1311        // a drift between the two can never sneak past this test.
1312        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}