Skip to main content

unifly_api/controller/
ports.rs

1//! Switch port profile queries and updates.
2//!
3//! Port VLAN configuration is a Session-API-only surface (the Integration
4//! API does not expose `port_table` / `port_overrides`). This module layers
5//! a normalized `PortProfile` view over the raw `stat/device` payload and
6//! provides a helper for merging a single port's overrides while preserving
7//! every other override on the device.
8
9use std::collections::HashMap;
10
11use serde_json::{Map, Value, json};
12use tracing::debug;
13
14use super::Controller;
15use super::support::require_session;
16use crate::command::requests::{ApplyPortEntry, ApplyPortsRequest};
17use crate::core_error::CoreError;
18use crate::model::{
19    MacAddress, PoeMode, PortMode, PortProfile, PortSpeedSetting, PortState, StpState,
20};
21
22/// Desired update to a single port's profile, as supplied by the CLI or a
23/// future TUI editor. Every field is optional -- unset fields leave the
24/// existing override value untouched.
25#[derive(Debug, Default, Clone)]
26pub struct PortProfileUpdate {
27    /// User-facing port label.
28    pub name: Option<String>,
29    /// Operational mode (access / trunk / mirror).
30    pub mode: Option<PortMode>,
31    /// Session `_id` of the native (untagged) network.
32    pub native_network_id: Option<String>,
33    /// Session `_id`s of explicitly tagged networks. `Some(vec![])` clears
34    /// the tagged list; `None` leaves it untouched.
35    pub tagged_network_ids: Option<Vec<String>>,
36    /// PoE configuration.
37    pub poe_mode: Option<PoeMode>,
38    /// Configured link speed.
39    pub speed_setting: Option<PortSpeedSetting>,
40}
41
42impl Controller {
43    /// List normalized port profiles for an adopted switch or gateway with
44    /// ports.
45    ///
46    /// Requires Session API access. Returns ports sorted by port index.
47    pub async fn list_device_ports(
48        &self,
49        device_mac: &MacAddress,
50    ) -> Result<Vec<PortProfile>, CoreError> {
51        let guard = self.inner.session_client.lock().await;
52        let session = require_session(guard.as_ref())?;
53
54        let device = session
55            .get_device(device_mac.as_str())
56            .await?
57            .ok_or_else(|| CoreError::DeviceNotFound {
58                identifier: device_mac.to_string(),
59            })?;
60
61        let network_lookup = build_network_lookup(&session.list_network_conf().await?);
62
63        let overrides = device
64            .extra
65            .get("port_overrides")
66            .and_then(Value::as_array)
67            .cloned()
68            .unwrap_or_default();
69        let port_table = device
70            .extra
71            .get("port_table")
72            .and_then(Value::as_array)
73            .cloned()
74            .unwrap_or_default();
75
76        // Owned map avoids coupling the lookup's lifetime to the `overrides`
77        // Vec — the fallback branch below consumes `overrides` directly.
78        let override_map: HashMap<u32, Value> = overrides
79            .iter()
80            .filter_map(|o| port_idx(o).map(|idx| (idx, o.clone())))
81            .collect();
82
83        // If the switch reports no port_table, fall back to overrides as the
84        // source of truth (rare for adopted switches but happens mid-provision).
85        let mut profiles: Vec<PortProfile> = if port_table.is_empty() {
86            overrides
87                .iter()
88                .filter_map(|o| {
89                    let idx = port_idx(o)?;
90                    Some(build_profile(idx, None, Some(o), &network_lookup))
91                })
92                .collect()
93        } else {
94            port_table
95                .iter()
96                .filter_map(|row| {
97                    let idx = port_idx(row)?;
98                    Some(build_profile(
99                        idx,
100                        Some(row),
101                        override_map.get(&idx),
102                        &network_lookup,
103                    ))
104                })
105                .collect()
106        };
107
108        profiles.sort_by_key(|p| p.index);
109        Ok(profiles)
110    }
111
112    /// Resolve a network by name or session `_id` to its session identifier
113    /// and (optional) VLAN id.
114    ///
115    /// Used to turn user-friendly CLI inputs (`--native-vlan office`) into
116    /// the `networkconf` `_id` that `port_overrides` requires.
117    pub async fn resolve_network_session_id(
118        &self,
119        identifier: &str,
120    ) -> Result<(String, Option<u16>), CoreError> {
121        let guard = self.inner.session_client.lock().await;
122        let session = require_session(guard.as_ref())?;
123
124        let records = session.list_network_conf().await?;
125
126        // Exact `_id` match first so ambiguous names never shadow an ID.
127        if let Some(hit) = records.iter().find(|rec| {
128            rec.get("_id")
129                .and_then(Value::as_str)
130                .is_some_and(|id| id == identifier)
131        }) {
132            return Ok((identifier.to_owned(), parse_vlan_id(hit)));
133        }
134
135        let matches: Vec<&Value> = records
136            .iter()
137            .filter(|rec| {
138                rec.get("name")
139                    .and_then(Value::as_str)
140                    .is_some_and(|name| name.eq_ignore_ascii_case(identifier))
141            })
142            .collect();
143
144        match matches.len() {
145            0 => Err(CoreError::NetworkNotFound {
146                identifier: identifier.to_owned(),
147            }),
148            1 => {
149                let rec = matches[0];
150                let id = rec
151                    .get("_id")
152                    .and_then(Value::as_str)
153                    .ok_or_else(|| CoreError::NetworkNotFound {
154                        identifier: identifier.to_owned(),
155                    })?
156                    .to_owned();
157                Ok((id, parse_vlan_id(rec)))
158            }
159            _ => Err(CoreError::ValidationFailed {
160                message: format!(
161                    "network name {identifier:?} is ambiguous ({} matches); specify the session _id instead",
162                    matches.len()
163                ),
164            }),
165        }
166    }
167
168    /// Apply `update` to the override for `port_idx` on the device identified
169    /// by MAC, preserving every other port's overrides.
170    pub async fn update_device_port(
171        &self,
172        device_mac: &MacAddress,
173        port_idx_target: u32,
174        update: &PortProfileUpdate,
175    ) -> Result<(), CoreError> {
176        if port_idx_target == 0 {
177            return Err(CoreError::ValidationFailed {
178                message: "port index must be 1-based (UniFi switches number ports starting at 1)"
179                    .to_owned(),
180            });
181        }
182        let guard = self.inner.session_client.lock().await;
183        let session = require_session(guard.as_ref())?;
184
185        let device = session
186            .get_device(device_mac.as_str())
187            .await?
188            .ok_or_else(|| CoreError::DeviceNotFound {
189                identifier: device_mac.to_string(),
190            })?;
191
192        // Refuse to write a phantom override for a port the device doesn't
193        // physically have. The controller silently accepts entries for any
194        // index, so without this check `port-set <8-port-switch> 99 ...`
195        // reports success and persists a dangling override forever.
196        let known_ports: Vec<u32> = device
197            .extra
198            .get("port_table")
199            .and_then(Value::as_array)
200            .map(|table| table.iter().filter_map(port_idx).collect())
201            .unwrap_or_default();
202        if !known_ports.is_empty() && !known_ports.contains(&port_idx_target) {
203            let max = known_ports.iter().max().copied().unwrap_or(0);
204            return Err(CoreError::ValidationFailed {
205                message: format!(
206                    "port {port_idx_target} does not exist on this device (valid range 1..={max})"
207                ),
208            });
209        }
210
211        let mut overrides: Vec<Value> = device
212            .extra
213            .get("port_overrides")
214            .and_then(Value::as_array)
215            .cloned()
216            .unwrap_or_default();
217
218        let slot = overrides
219            .iter_mut()
220            .find(|entry| port_idx(entry) == Some(port_idx_target));
221
222        let existing = slot.as_ref().map(|value| match value {
223            Value::Object(map) => map.clone(),
224            _ => Map::new(),
225        });
226        let mut next = existing.unwrap_or_default();
227        next.insert("port_idx".into(), json!(port_idx_target));
228        apply_update(&mut next, update);
229
230        match slot {
231            Some(entry) => *entry = Value::Object(next),
232            None => overrides.push(Value::Object(next)),
233        }
234
235        debug!(port_idx_target, "updating port_overrides");
236        session
237            .update_device_port_overrides(device.id.as_str(), overrides)
238            .await?;
239        Ok(())
240    }
241
242    /// Apply a batch of port overrides to a device in a single round-trip.
243    ///
244    /// Splice semantics: ports not listed in `request.ports` keep their
245    /// existing override entry untouched. Per-port `reset: true` removes
246    /// that port's entry from `port_overrides` entirely (back to controller
247    /// defaults). Network names in the request are resolved to Session
248    /// `_id`s up-front so the device PUT only happens after every entry
249    /// validates.
250    ///
251    /// Requires Session API access.
252    pub async fn apply_device_ports(
253        &self,
254        device_mac: &MacAddress,
255        request: &ApplyPortsRequest,
256    ) -> Result<ApplyPortsSummary, CoreError> {
257        let guard = self.inner.session_client.lock().await;
258        let session = require_session(guard.as_ref())?;
259
260        // Fetch device + network list once. resolve_network_session_id
261        // would re-fetch the network list per call.
262        let device = session
263            .get_device(device_mac.as_str())
264            .await?
265            .ok_or_else(|| CoreError::DeviceNotFound {
266                identifier: device_mac.to_string(),
267            })?;
268        let networks = session.list_network_conf().await?;
269
270        // Validate every entry and convert to (port_idx, op) before any
271        // mutation — bail out cleanly on the first invalid entry. A bare
272        // `{"index": N}` entry is the empty JSON Merge Patch — no
273        // fields, no `reset` — and is documented as a no-op. Skip it
274        // here so it doesn't materialize as a `{"port_idx": N}`
275        // override, which the controller would then fill with defaults
276        // (creating exactly the partial-override mess `--reset` exists
277        // to clean up).
278        let mut ops: Vec<(u32, EntryOp)> = Vec::with_capacity(request.ports.len());
279        for entry in &request.ports {
280            let op = if entry.reset {
281                EntryOp::Reset
282            } else if entry_is_empty_patch(entry) {
283                continue;
284            } else {
285                EntryOp::Update(entry_to_update(entry, &networks)?)
286            };
287            ops.push((entry.index, op));
288        }
289
290        let mut overrides: Vec<Value> = device
291            .extra
292            .get("port_overrides")
293            .and_then(Value::as_array)
294            .cloned()
295            .unwrap_or_default();
296
297        let mut summary = ApplyPortsSummary::default();
298        for (port_idx_target, op) in ops {
299            match op {
300                EntryOp::Reset => {
301                    let before = overrides.len();
302                    overrides.retain(|entry| port_idx(entry) != Some(port_idx_target));
303                    if overrides.len() != before {
304                        summary.reset += 1;
305                    }
306                }
307                EntryOp::Update(update) => {
308                    let slot = overrides
309                        .iter_mut()
310                        .find(|entry| port_idx(entry) == Some(port_idx_target));
311                    let existing = slot.as_ref().map(|value| match value {
312                        Value::Object(map) => map.clone(),
313                        _ => Map::new(),
314                    });
315                    let mut next = existing.unwrap_or_default();
316                    next.insert("port_idx".into(), json!(port_idx_target));
317                    apply_update(&mut next, &update);
318                    match slot {
319                        Some(entry) => *entry = Value::Object(next),
320                        None => overrides.push(Value::Object(next)),
321                    }
322                    summary.applied += 1;
323                }
324            }
325        }
326
327        debug!(
328            device = %device_mac,
329            applied = summary.applied,
330            reset = summary.reset,
331            "applying batch port_overrides",
332        );
333        session
334            .update_device_port_overrides(device.id.as_str(), overrides)
335            .await?;
336        Ok(summary)
337    }
338}
339
340/// Counts of operations performed by [`Controller::apply_device_ports`].
341#[derive(Debug, Default, Clone, Copy)]
342pub struct ApplyPortsSummary {
343    /// Ports that had an override applied or refreshed.
344    pub applied: usize,
345    /// Ports whose override entry was removed (`reset: true`).
346    pub reset: usize,
347}
348
349impl Controller {
350    /// Build an [`ApplyPortsRequest`] reflecting the device's current
351    /// `port_overrides`. Suitable for piping into
352    /// [`Controller::apply_device_ports`] to round-trip a switch's port
353    /// configuration through a JSONC file.
354    ///
355    /// When `include_all` is `false` (the default), only ports with an
356    /// active override entry are emitted (sparse — best for diffable
357    /// config-as-code files). When `true`, ports without an override are
358    /// emitted as placeholder entries with `index` and `name` only, so a
359    /// user can bootstrap an apply file covering every port.
360    pub async fn export_device_ports(
361        &self,
362        device_mac: &MacAddress,
363        include_all: bool,
364    ) -> Result<ApplyPortsRequest, CoreError> {
365        let guard = self.inner.session_client.lock().await;
366        let session = require_session(guard.as_ref())?;
367
368        let device = session
369            .get_device(device_mac.as_str())
370            .await?
371            .ok_or_else(|| CoreError::DeviceNotFound {
372                identifier: device_mac.to_string(),
373            })?;
374
375        let overrides: Vec<Value> = device
376            .extra
377            .get("port_overrides")
378            .and_then(Value::as_array)
379            .cloned()
380            .unwrap_or_default();
381
382        let mut entries: Vec<ApplyPortEntry> = overrides
383            .iter()
384            .filter_map(|raw| port_idx(raw).map(|idx| override_to_entry(idx, raw)))
385            .collect();
386
387        if include_all {
388            let covered: std::collections::HashSet<u32> = entries.iter().map(|e| e.index).collect();
389            let port_table: Vec<Value> = device
390                .extra
391                .get("port_table")
392                .and_then(Value::as_array)
393                .cloned()
394                .unwrap_or_default();
395            for row in &port_table {
396                if let Some(idx) = port_idx(row)
397                    && !covered.contains(&idx)
398                {
399                    entries.push(ApplyPortEntry {
400                        index: idx,
401                        name: row.get("name").and_then(Value::as_str).map(str::to_owned),
402                        ..ApplyPortEntry::default()
403                    });
404                }
405            }
406        }
407
408        entries.sort_by_key(|e| e.index);
409        Ok(ApplyPortsRequest { ports: entries })
410    }
411}
412
413fn override_to_entry(index: u32, raw: &Value) -> ApplyPortEntry {
414    let name = raw
415        .get("name")
416        .and_then(Value::as_str)
417        .filter(|s| !s.is_empty())
418        .map(str::to_owned);
419
420    let op_mode = raw.get("op_mode").and_then(Value::as_str);
421    let tagged_mgmt = raw.get("tagged_vlan_mgmt").and_then(Value::as_str);
422    let mode = match (op_mode, tagged_mgmt) {
423        (Some("mirror"), _) => Some("mirror".to_owned()),
424        (Some("switch") | None, Some("block_all")) => Some("access".to_owned()),
425        (Some("switch") | None, Some("auto" | "custom")) => Some("trunk".to_owned()),
426        _ => None,
427    };
428
429    let native_network_id = raw
430        .get("native_networkconf_id")
431        .and_then(Value::as_str)
432        .filter(|s| !s.is_empty())
433        .map(str::to_owned);
434
435    let tagged_list: Option<Vec<String>> = raw
436        .get("tagged_networkconf_ids")
437        .and_then(Value::as_array)
438        .map(|arr| {
439            arr.iter()
440                .filter_map(|v| v.as_str().map(str::to_owned))
441                .collect()
442        });
443    // For custom trunks, preserve the explicit list — including
444    // `Some(vec![])`, which is how a "trunk that carries only its
445    // native VLAN" round-trips. Dropping it to None makes the file
446    // re-apply as `mode: trunk` with no list, which `apply_update`
447    // treats as `tagged_vlan_mgmt=auto` — silently broadening the
448    // port to all VLANs. For non-custom modes (auto/block_all) the
449    // list is irrelevant, so drop empties to keep the file compact.
450    let tagged_network_ids = if tagged_mgmt == Some("custom") {
451        tagged_list
452    } else {
453        tagged_list.filter(|list| !list.is_empty())
454    };
455
456    let tagged_all = if mode.as_deref() == Some("trunk") && tagged_mgmt == Some("auto") {
457        Some(true)
458    } else {
459        None
460    };
461
462    let poe = raw
463        .get("poe_mode")
464        .and_then(Value::as_str)
465        .filter(|s| !s.is_empty())
466        .map(str::to_owned);
467
468    // autoneg=true is the canonical form for "auto" on the wire — no
469    // `speed` field. Skip emitting `speed` in that case so the round-trip
470    // doesn't trip the controller's pinned-speed validation pattern.
471    let speed = match (
472        raw.get("autoneg").and_then(Value::as_bool),
473        raw.get("speed").and_then(Value::as_str),
474    ) {
475        (Some(false), Some(s)) if !s.is_empty() => Some(s.to_owned()),
476        _ => None,
477    };
478
479    ApplyPortEntry {
480        index,
481        name,
482        mode,
483        native_network_id,
484        tagged_network_ids,
485        tagged_all,
486        poe,
487        speed,
488        reset: false,
489    }
490}
491
492enum EntryOp {
493    Reset,
494    Update(PortProfileUpdate),
495}
496
497/// True iff the entry has no mergeable fields and no `reset` flag —
498/// a bare `{"index": N}` entry is the empty JSON Merge Patch.
499fn entry_is_empty_patch(entry: &ApplyPortEntry) -> bool {
500    entry.name.is_none()
501        && entry.mode.is_none()
502        && entry.native_network_id.is_none()
503        && entry.tagged_network_ids.is_none()
504        && entry.tagged_all.is_none()
505        && entry.poe.is_none()
506        && entry.speed.is_none()
507        && !entry.reset
508}
509
510fn entry_to_update(
511    entry: &ApplyPortEntry,
512    networks: &[Value],
513) -> Result<PortProfileUpdate, CoreError> {
514    if let (Some(true), Some(list)) = (entry.tagged_all, entry.tagged_network_ids.as_deref())
515        && !list.is_empty()
516    {
517        return Err(CoreError::ValidationFailed {
518            message: format!(
519                "port {}: tagged_all=true conflicts with a non-empty tagged_network_ids list",
520                entry.index
521            ),
522        });
523    }
524
525    let mode = entry
526        .mode
527        .as_deref()
528        .map(parse_apply_mode)
529        .transpose()
530        .map_err(|e| context_err(entry.index, e))?;
531
532    // tagged_all=true forces mode=Trunk and clears tagged_network_ids
533    // (apply_update will then write tagged_vlan_mgmt=auto).
534    let mode = if entry.tagged_all == Some(true) {
535        Some(PortMode::Trunk)
536    } else {
537        mode
538    };
539
540    let native_network_id = entry
541        .native_network_id
542        .as_deref()
543        .map(|id| resolve_network_to_id(id, networks))
544        .transpose()
545        .map_err(|e| context_err(entry.index, e))?;
546
547    let tagged_network_ids = if entry.tagged_all == Some(true) {
548        None
549    } else if let Some(list) = entry.tagged_network_ids.as_deref() {
550        let resolved: Result<Vec<String>, _> = list
551            .iter()
552            .map(|id| resolve_network_to_id(id, networks))
553            .collect();
554        Some(resolved.map_err(|e| context_err(entry.index, e))?)
555    } else {
556        None
557    };
558
559    let poe_mode = entry
560        .poe
561        .as_deref()
562        .map(parse_apply_poe)
563        .transpose()
564        .map_err(|e| context_err(entry.index, e))?;
565
566    let speed_setting = entry
567        .speed
568        .as_deref()
569        .map(parse_apply_speed)
570        .transpose()
571        .map_err(|e| context_err(entry.index, e))?;
572
573    Ok(PortProfileUpdate {
574        name: entry.name.clone(),
575        mode,
576        native_network_id,
577        tagged_network_ids,
578        poe_mode,
579        speed_setting,
580    })
581}
582
583fn context_err(port_index: u32, err: CoreError) -> CoreError {
584    if let CoreError::ValidationFailed { message } = &err {
585        CoreError::ValidationFailed {
586            message: format!("port {port_index}: {message}"),
587        }
588    } else {
589        err
590    }
591}
592
593fn resolve_network_to_id(identifier: &str, networks: &[Value]) -> Result<String, CoreError> {
594    if networks
595        .iter()
596        .any(|r| r.get("_id").and_then(Value::as_str) == Some(identifier))
597    {
598        return Ok(identifier.to_owned());
599    }
600    let matches: Vec<&Value> = networks
601        .iter()
602        .filter(|r| {
603            r.get("name")
604                .and_then(Value::as_str)
605                .is_some_and(|n| n.eq_ignore_ascii_case(identifier))
606        })
607        .collect();
608    match matches.len() {
609        0 => Err(CoreError::NetworkNotFound {
610            identifier: identifier.to_owned(),
611        }),
612        1 => matches[0]
613            .get("_id")
614            .and_then(Value::as_str)
615            .map(str::to_owned)
616            .ok_or_else(|| CoreError::NetworkNotFound {
617                identifier: identifier.to_owned(),
618            }),
619        _ => Err(CoreError::ValidationFailed {
620            message: format!(
621                "network name {identifier:?} is ambiguous ({} matches); specify the session _id instead",
622                matches.len()
623            ),
624        }),
625    }
626}
627
628fn parse_apply_mode(raw: &str) -> Result<PortMode, CoreError> {
629    match raw {
630        "access" => Ok(PortMode::Access),
631        "trunk" => Ok(PortMode::Trunk),
632        "mirror" => Ok(PortMode::Mirror),
633        _ => Err(CoreError::ValidationFailed {
634            message: format!("invalid mode {raw:?}, expected access | trunk | mirror"),
635        }),
636    }
637}
638
639fn parse_apply_poe(raw: &str) -> Result<PoeMode, CoreError> {
640    match raw {
641        "on" | "auto" => Ok(PoeMode::Auto),
642        "off" => Ok(PoeMode::Off),
643        "pasv24" => Ok(PoeMode::Passive24V),
644        "passthrough" => Ok(PoeMode::Passthrough),
645        _ => Err(CoreError::ValidationFailed {
646            message: format!(
647                "invalid poe {raw:?}, expected on | off | auto | pasv24 | passthrough"
648            ),
649        }),
650    }
651}
652
653fn parse_apply_speed(raw: &str) -> Result<PortSpeedSetting, CoreError> {
654    match raw {
655        "auto" => Ok(PortSpeedSetting::Auto),
656        "10" => Ok(PortSpeedSetting::Mbps10),
657        "100" => Ok(PortSpeedSetting::Mbps100),
658        "1000" => Ok(PortSpeedSetting::Mbps1000),
659        "2500" => Ok(PortSpeedSetting::Mbps2500),
660        "5000" => Ok(PortSpeedSetting::Mbps5000),
661        "10000" => Ok(PortSpeedSetting::Mbps10000),
662        _ => Err(CoreError::ValidationFailed {
663            message: format!(
664                "invalid speed {raw:?}, expected auto | 10 | 100 | 1000 | 2500 | 5000 | 10000"
665            ),
666        }),
667    }
668}
669
670// ── helpers ──────────────────────────────────────────────────────────────
671
672fn port_idx(value: &Value) -> Option<u32> {
673    #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
674    value
675        .get("port_idx")
676        .and_then(Value::as_u64)
677        .map(|v| v as u32)
678}
679
680fn parse_vlan_id(rec: &Value) -> Option<u16> {
681    #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
682    rec.get("vlan")
683        .and_then(|v| {
684            v.as_u64()
685                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
686        })
687        .map(|v| v as u16)
688}
689
690struct NetworkLookup {
691    by_id: HashMap<String, NetworkInfo>,
692}
693
694struct NetworkInfo {
695    name: Option<String>,
696    vlan_id: Option<u16>,
697}
698
699fn build_network_lookup(records: &[Value]) -> NetworkLookup {
700    let mut by_id = HashMap::new();
701    for rec in records {
702        let Some(id) = rec.get("_id").and_then(Value::as_str) else {
703            continue;
704        };
705        by_id.insert(
706            id.to_owned(),
707            NetworkInfo {
708                name: rec.get("name").and_then(Value::as_str).map(str::to_owned),
709                vlan_id: parse_vlan_id(rec),
710            },
711        );
712    }
713    NetworkLookup { by_id }
714}
715
716impl NetworkLookup {
717    fn name(&self, id: &str) -> Option<String> {
718        self.by_id.get(id).and_then(|n| n.name.clone())
719    }
720    fn vlan(&self, id: &str) -> Option<u16> {
721        self.by_id.get(id).and_then(|n| n.vlan_id)
722    }
723}
724
725fn build_profile(
726    index: u32,
727    row: Option<&Value>,
728    override_: Option<&Value>,
729    networks: &NetworkLookup,
730) -> PortProfile {
731    let link_state = row
732        .and_then(|r| r.get("up"))
733        .and_then(Value::as_bool)
734        .map_or(PortState::Unknown, |up| {
735            if up { PortState::Up } else { PortState::Down }
736        });
737
738    let name = first_string(&[override_, row], "name");
739
740    let native_network_id =
741        first_string(&[override_, row], "native_networkconf_id").filter(|s| !s.is_empty());
742    let tagged_network_ids = first_array(&[override_, row], "tagged_networkconf_ids")
743        .cloned()
744        .unwrap_or_default()
745        .iter()
746        .filter_map(|v| v.as_str().map(str::to_owned))
747        .collect::<Vec<_>>();
748
749    let tagged_vlan_mgmt = first_string(&[override_, row], "tagged_vlan_mgmt");
750    let op_mode = first_string(&[override_, row], "op_mode");
751    let tagged_all = tagged_vlan_mgmt.as_deref() == Some("auto");
752
753    let mode = classify_mode(
754        op_mode.as_deref(),
755        tagged_vlan_mgmt.as_deref(),
756        &tagged_network_ids,
757    );
758
759    let native_vlan_id = native_network_id
760        .as_deref()
761        .and_then(|id| networks.vlan(id));
762    let native_network_name = native_network_id
763        .as_deref()
764        .and_then(|id| networks.name(id));
765    let tagged_vlan_ids = tagged_network_ids
766        .iter()
767        .filter_map(|id| networks.vlan(id))
768        .collect();
769    let tagged_network_names = tagged_network_ids
770        .iter()
771        .filter_map(|id| networks.name(id))
772        .collect();
773
774    let poe_mode = first_string(&[override_, row], "poe_mode")
775        .as_deref()
776        .map(parse_poe_mode);
777    let speed_setting = parse_speed(
778        first_string(&[override_, row], "speed").as_deref(),
779        first_bool(&[override_, row], "autoneg"),
780    );
781    let link_speed_mbps = row
782        .and_then(|r| r.get("speed"))
783        .and_then(Value::as_u64)
784        .map(|v| {
785            #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
786            {
787                v as u32
788            }
789        });
790
791    let stp_state = first_string(&[override_, row], "stp_state")
792        .as_deref()
793        .map_or(StpState::Unknown, parse_stp_state);
794
795    let port_profile_id = first_string(&[override_, row], "portconf_id").filter(|s| !s.is_empty());
796
797    PortProfile {
798        index,
799        name,
800        link_state,
801        mode,
802        native_network_id,
803        native_vlan_id,
804        native_network_name,
805        tagged_network_ids,
806        tagged_vlan_ids,
807        tagged_network_names,
808        tagged_all,
809        poe_mode,
810        speed_setting,
811        link_speed_mbps,
812        stp_state,
813        port_profile_id,
814    }
815}
816
817fn first_string(sources: &[Option<&Value>], key: &str) -> Option<String> {
818    sources
819        .iter()
820        .flatten()
821        .find_map(|v| v.get(key).and_then(Value::as_str).map(str::to_owned))
822}
823
824fn first_bool(sources: &[Option<&Value>], key: &str) -> Option<bool> {
825    sources
826        .iter()
827        .flatten()
828        .find_map(|v| v.get(key).and_then(Value::as_bool))
829}
830
831fn first_array<'a>(sources: &[Option<&'a Value>], key: &str) -> Option<&'a Vec<Value>> {
832    sources
833        .iter()
834        .flatten()
835        .find_map(|v| v.get(key).and_then(Value::as_array))
836}
837
838fn classify_mode(
839    op_mode: Option<&str>,
840    tagged_vlan_mgmt: Option<&str>,
841    tagged_ids: &[String],
842) -> PortMode {
843    if op_mode == Some("mirror") {
844        return PortMode::Mirror;
845    }
846    match tagged_vlan_mgmt {
847        Some("block_all") => PortMode::Access,
848        Some("auto" | "custom") => PortMode::Trunk,
849        _ => {
850            if tagged_ids.is_empty() {
851                PortMode::Unknown
852            } else {
853                PortMode::Trunk
854            }
855        }
856    }
857}
858
859fn parse_poe_mode(raw: &str) -> PoeMode {
860    match raw {
861        "auto" => PoeMode::Auto,
862        "off" => PoeMode::Off,
863        "pasv24" => PoeMode::Passive24V,
864        "passthrough" => PoeMode::Passthrough,
865        _ => PoeMode::Other,
866    }
867}
868
869fn parse_speed(raw: Option<&str>, autoneg: Option<bool>) -> Option<PortSpeedSetting> {
870    if autoneg == Some(true) {
871        return Some(PortSpeedSetting::Auto);
872    }
873    match raw {
874        Some("auto") => Some(PortSpeedSetting::Auto),
875        Some("10") => Some(PortSpeedSetting::Mbps10),
876        Some("100") => Some(PortSpeedSetting::Mbps100),
877        Some("1000") => Some(PortSpeedSetting::Mbps1000),
878        Some("2500") => Some(PortSpeedSetting::Mbps2500),
879        Some("5000") => Some(PortSpeedSetting::Mbps5000),
880        Some("10000") => Some(PortSpeedSetting::Mbps10000),
881        None | Some(_) => None,
882    }
883}
884
885fn parse_stp_state(raw: &str) -> StpState {
886    match raw {
887        "disabled" => StpState::Disabled,
888        "blocking" => StpState::Blocking,
889        "listening" => StpState::Listening,
890        "learning" => StpState::Learning,
891        "forwarding" => StpState::Forwarding,
892        "broken" => StpState::Broken,
893        _ => StpState::Unknown,
894    }
895}
896
897fn apply_update(target: &mut Map<String, Value>, update: &PortProfileUpdate) {
898    if let Some(name) = &update.name {
899        target.insert("name".into(), json!(name));
900    }
901
902    if let Some(mode) = update.mode {
903        match mode {
904            PortMode::Access => {
905                target.insert("op_mode".into(), json!("switch"));
906                target.insert("tagged_vlan_mgmt".into(), json!("block_all"));
907                target.insert("tagged_networkconf_ids".into(), json!([]));
908            }
909            PortMode::Trunk => {
910                target.insert("op_mode".into(), json!("switch"));
911                // If caller provides a tagged list we default to "custom";
912                // otherwise "auto" means "all VLANs" which is the UniFi trunk
913                // default.
914                if update.tagged_network_ids.is_some() {
915                    target.insert("tagged_vlan_mgmt".into(), json!("custom"));
916                } else {
917                    target.insert("tagged_vlan_mgmt".into(), json!("auto"));
918                }
919            }
920            PortMode::Mirror => {
921                target.insert("op_mode".into(), json!("mirror"));
922            }
923            PortMode::Unknown => {}
924        }
925    }
926
927    if let Some(id) = &update.native_network_id {
928        target.insert("native_networkconf_id".into(), json!(id));
929    }
930
931    if let Some(tagged) = &update.tagged_network_ids {
932        target.insert("tagged_networkconf_ids".into(), json!(tagged));
933        // Supplying a tagged list always implies custom-trunk semantics
934        // — `auto` (all VLANs) and `block_all` (access) both ignore the
935        // list, so the user's request would silently no-op without this.
936        // Skip when the same update explicitly switches to Access or
937        // Mirror, since those branches above already wrote the
938        // appropriate `tagged_vlan_mgmt` value.
939        if !matches!(update.mode, Some(PortMode::Access | PortMode::Mirror)) {
940            target.insert("tagged_vlan_mgmt".into(), json!("custom"));
941        }
942    }
943
944    if let Some(poe) = update.poe_mode {
945        target.insert(
946            "poe_mode".into(),
947            json!(match poe {
948                PoeMode::Off => "off",
949                PoeMode::Passive24V => "pasv24",
950                PoeMode::Passthrough => "passthrough",
951                PoeMode::Auto | PoeMode::Other => "auto",
952            }),
953        );
954    }
955
956    if let Some(speed) = update.speed_setting {
957        match speed {
958            PortSpeedSetting::Auto => {
959                // The controller validates `speed` against `10|100|...|100000`.
960                // For autoneg ports the wire stores `autoneg: true` with no
961                // `speed` field — so we drop any pinned speed rather than
962                // sending `"speed": "auto"` (which fails validation).
963                target.insert("autoneg".into(), json!(true));
964                target.remove("speed");
965            }
966            other => {
967                target.insert("autoneg".into(), json!(false));
968                target.insert(
969                    "speed".into(),
970                    json!(match other {
971                        PortSpeedSetting::Mbps10 => "10",
972                        PortSpeedSetting::Mbps100 => "100",
973                        PortSpeedSetting::Mbps1000 => "1000",
974                        PortSpeedSetting::Mbps2500 => "2500",
975                        PortSpeedSetting::Mbps5000 => "5000",
976                        PortSpeedSetting::Mbps10000 => "10000",
977                        PortSpeedSetting::Auto => "auto",
978                    }),
979                );
980            }
981        }
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    fn sample_networks() -> NetworkLookup {
990        build_network_lookup(&[
991            json!({ "_id": "n1", "name": "infra", "vlan": 10 }),
992            json!({ "_id": "n2", "name": "personal", "vlan": 20 }),
993        ])
994    }
995
996    #[test]
997    fn classify_mode_detects_mirror() {
998        assert_eq!(classify_mode(Some("mirror"), None, &[]), PortMode::Mirror);
999    }
1000
1001    #[test]
1002    fn classify_mode_detects_access_and_trunk() {
1003        assert_eq!(
1004            classify_mode(Some("switch"), Some("block_all"), &[]),
1005            PortMode::Access
1006        );
1007        assert_eq!(
1008            classify_mode(Some("switch"), Some("auto"), &[]),
1009            PortMode::Trunk
1010        );
1011        assert_eq!(
1012            classify_mode(Some("switch"), Some("custom"), &["n2".into()]),
1013            PortMode::Trunk
1014        );
1015    }
1016
1017    #[test]
1018    fn parse_speed_autoneg_beats_explicit() {
1019        assert_eq!(
1020            parse_speed(Some("1000"), Some(true)),
1021            Some(PortSpeedSetting::Auto)
1022        );
1023        assert_eq!(
1024            parse_speed(Some("1000"), Some(false)),
1025            Some(PortSpeedSetting::Mbps1000)
1026        );
1027    }
1028
1029    #[test]
1030    fn build_profile_uses_overrides_before_live_state() {
1031        let row = json!({
1032            "port_idx": 10,
1033            "up": true,
1034            "speed": 1000,
1035            "name": "auto-name",
1036            "tagged_vlan_mgmt": "auto",
1037            "native_networkconf_id": "n1",
1038            "poe_mode": "auto",
1039            "stp_state": "forwarding",
1040        });
1041        let override_ = json!({
1042            "port_idx": 10,
1043            "name": "mac-mini",
1044            "tagged_vlan_mgmt": "custom",
1045            "tagged_networkconf_ids": ["n2"],
1046            "native_networkconf_id": "n1",
1047            "poe_mode": "off",
1048            "autoneg": false,
1049            "speed": "1000",
1050        });
1051        let profile = build_profile(10, Some(&row), Some(&override_), &sample_networks());
1052        assert_eq!(profile.name.as_deref(), Some("mac-mini"));
1053        assert_eq!(profile.mode, PortMode::Trunk);
1054        assert_eq!(profile.native_vlan_id, Some(10));
1055        assert_eq!(profile.native_network_name.as_deref(), Some("infra"));
1056        assert_eq!(profile.tagged_vlan_ids, vec![20]);
1057        assert_eq!(profile.tagged_network_names, vec!["personal"]);
1058        assert_eq!(profile.poe_mode, Some(PoeMode::Off));
1059        assert_eq!(profile.speed_setting, Some(PortSpeedSetting::Mbps1000));
1060        assert_eq!(profile.link_speed_mbps, Some(1000));
1061        assert_eq!(profile.stp_state, StpState::Forwarding);
1062        assert_eq!(profile.link_state, PortState::Up);
1063    }
1064
1065    #[test]
1066    fn apply_update_access_mode_clears_tagged_list() {
1067        let mut target = Map::new();
1068        target.insert("port_idx".into(), json!(10));
1069        target.insert("tagged_networkconf_ids".into(), json!(["old"]));
1070        apply_update(
1071            &mut target,
1072            &PortProfileUpdate {
1073                mode: Some(PortMode::Access),
1074                native_network_id: Some("n1".into()),
1075                ..PortProfileUpdate::default()
1076            },
1077        );
1078        assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("block_all")));
1079        assert_eq!(target.get("tagged_networkconf_ids"), Some(&json!([])));
1080        assert_eq!(target.get("native_networkconf_id"), Some(&json!("n1")));
1081        assert_eq!(target.get("op_mode"), Some(&json!("switch")));
1082    }
1083
1084    #[test]
1085    fn apply_update_trunk_with_tagged_list_marks_custom() {
1086        let mut target = Map::new();
1087        target.insert("port_idx".into(), json!(10));
1088        apply_update(
1089            &mut target,
1090            &PortProfileUpdate {
1091                mode: Some(PortMode::Trunk),
1092                native_network_id: Some("n1".into()),
1093                tagged_network_ids: Some(vec!["n2".into()]),
1094                ..PortProfileUpdate::default()
1095            },
1096        );
1097        assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("custom")));
1098        assert_eq!(target.get("tagged_networkconf_ids"), Some(&json!(["n2"])));
1099    }
1100
1101    #[test]
1102    fn apply_update_tagged_list_alone_marks_custom() {
1103        // Without this fix, `--tagged-vlans foo` (no `--mode`) on an
1104        // existing auto-trunk left tagged_vlan_mgmt=auto, so the
1105        // restriction was silently a no-op.
1106        let mut target = Map::new();
1107        target.insert("port_idx".into(), json!(10));
1108        target.insert("tagged_vlan_mgmt".into(), json!("auto"));
1109        apply_update(
1110            &mut target,
1111            &PortProfileUpdate {
1112                tagged_network_ids: Some(vec!["n2".into()]),
1113                ..PortProfileUpdate::default()
1114            },
1115        );
1116        assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("custom")));
1117        assert_eq!(target.get("tagged_networkconf_ids"), Some(&json!(["n2"])));
1118    }
1119
1120    #[test]
1121    fn apply_update_access_mode_keeps_block_all_even_with_tagged_list() {
1122        // Contradictory inputs (--mode access + --tagged-vlans): the
1123        // explicit mode wins. We do NOT silently flip to custom trunk.
1124        let mut target = Map::new();
1125        apply_update(
1126            &mut target,
1127            &PortProfileUpdate {
1128                mode: Some(PortMode::Access),
1129                tagged_network_ids: Some(vec!["n2".into()]),
1130                ..PortProfileUpdate::default()
1131            },
1132        );
1133        assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("block_all")));
1134    }
1135
1136    #[test]
1137    fn apply_update_speed_fixed_disables_autoneg() {
1138        let mut target = Map::new();
1139        apply_update(
1140            &mut target,
1141            &PortProfileUpdate {
1142                speed_setting: Some(PortSpeedSetting::Mbps2500),
1143                ..PortProfileUpdate::default()
1144            },
1145        );
1146        assert_eq!(target.get("autoneg"), Some(&json!(false)));
1147        assert_eq!(target.get("speed"), Some(&json!("2500")));
1148    }
1149
1150    /// The controller's pinned-speed validation pattern is
1151    /// `10|100|...|100000` — `"auto"` is not in it. apply_update must
1152    /// represent Auto as `autoneg: true` only and remove any stale
1153    /// `speed` field rather than emitting `"speed": "auto"`.
1154    #[test]
1155    fn apply_update_speed_auto_omits_speed_field() {
1156        let mut target = Map::new();
1157        target.insert("speed".into(), json!("1000"));
1158        apply_update(
1159            &mut target,
1160            &PortProfileUpdate {
1161                speed_setting: Some(PortSpeedSetting::Auto),
1162                ..PortProfileUpdate::default()
1163            },
1164        );
1165        assert_eq!(target.get("autoneg"), Some(&json!(true)));
1166        assert_eq!(target.get("speed"), None);
1167    }
1168
1169    #[test]
1170    fn override_to_entry_round_trips_basic_fields() {
1171        let raw = json!({
1172            "port_idx": 1,
1173            "name": "uplink",
1174            "op_mode": "switch",
1175            "tagged_vlan_mgmt": "auto",
1176            "native_networkconf_id": "n1",
1177            "poe_mode": "auto",
1178            "autoneg": true,
1179        });
1180        let entry = override_to_entry(1, &raw);
1181        assert_eq!(entry.index, 1);
1182        assert_eq!(entry.name.as_deref(), Some("uplink"));
1183        assert_eq!(entry.mode.as_deref(), Some("trunk"));
1184        assert_eq!(entry.native_network_id.as_deref(), Some("n1"));
1185        assert_eq!(entry.tagged_all, Some(true));
1186        assert_eq!(entry.poe.as_deref(), Some("auto"));
1187        // autoneg=true → speed is None (avoids round-trip validation error)
1188        assert!(entry.speed.is_none());
1189    }
1190
1191    #[test]
1192    fn override_to_entry_pinned_speed_emits_value() {
1193        let raw = json!({
1194            "port_idx": 4,
1195            "autoneg": false,
1196            "speed": "1000",
1197        });
1198        let entry = override_to_entry(4, &raw);
1199        assert_eq!(entry.speed.as_deref(), Some("1000"));
1200    }
1201
1202    #[test]
1203    fn override_to_entry_access_mode_from_block_all() {
1204        let raw = json!({
1205            "port_idx": 2,
1206            "op_mode": "switch",
1207            "tagged_vlan_mgmt": "block_all",
1208        });
1209        let entry = override_to_entry(2, &raw);
1210        assert_eq!(entry.mode.as_deref(), Some("access"));
1211        assert!(entry.tagged_all.is_none());
1212    }
1213
1214    #[test]
1215    fn override_to_entry_preserves_empty_custom_trunk_tagged_list() {
1216        // A custom trunk with no extra tags (only native VLAN) MUST
1217        // round-trip as `Some(vec![])`. Dropping it to None re-applies
1218        // as `tagged_vlan_mgmt=auto`, silently broadening the port.
1219        let raw = json!({
1220            "port_idx": 5,
1221            "op_mode": "switch",
1222            "tagged_vlan_mgmt": "custom",
1223            "native_networkconf_id": "n1",
1224            "tagged_networkconf_ids": []
1225        });
1226        let entry = override_to_entry(5, &raw);
1227        assert_eq!(entry.mode.as_deref(), Some("trunk"));
1228        assert_eq!(entry.tagged_network_ids.as_deref(), Some(&[][..]));
1229        assert!(entry.tagged_all.is_none());
1230    }
1231
1232    #[test]
1233    fn override_to_entry_drops_empty_list_for_auto_trunks() {
1234        // For auto-mode trunks (carries all VLANs), the list isn't
1235        // semantically meaningful, so empty is dropped to keep the
1236        // exported file compact.
1237        let raw = json!({
1238            "port_idx": 6,
1239            "op_mode": "switch",
1240            "tagged_vlan_mgmt": "auto",
1241            "tagged_networkconf_ids": []
1242        });
1243        let entry = override_to_entry(6, &raw);
1244        assert!(entry.tagged_network_ids.is_none());
1245        assert_eq!(entry.tagged_all, Some(true));
1246    }
1247
1248    #[test]
1249    fn entry_to_update_rejects_invalid_strings() {
1250        let networks: Vec<Value> = vec![];
1251        let entry = ApplyPortEntry {
1252            index: 1,
1253            mode: Some("bogus".into()),
1254            ..ApplyPortEntry::default()
1255        };
1256        let err = entry_to_update(&entry, &networks).expect_err("invalid mode should error");
1257        assert!(matches!(err, CoreError::ValidationFailed { .. }));
1258    }
1259
1260    #[test]
1261    fn entry_is_empty_patch_skips_bare_index_entries() {
1262        let bare = ApplyPortEntry {
1263            index: 4,
1264            ..ApplyPortEntry::default()
1265        };
1266        assert!(entry_is_empty_patch(&bare));
1267
1268        let with_name = ApplyPortEntry {
1269            index: 4,
1270            name: Some("foo".into()),
1271            ..ApplyPortEntry::default()
1272        };
1273        assert!(!entry_is_empty_patch(&with_name));
1274
1275        let reset = ApplyPortEntry {
1276            index: 4,
1277            reset: true,
1278            ..ApplyPortEntry::default()
1279        };
1280        assert!(
1281            !entry_is_empty_patch(&reset),
1282            "reset is a real instruction, not an empty patch"
1283        );
1284
1285        let cleared_tagged = ApplyPortEntry {
1286            index: 4,
1287            tagged_network_ids: Some(vec![]),
1288            ..ApplyPortEntry::default()
1289        };
1290        assert!(
1291            !entry_is_empty_patch(&cleared_tagged),
1292            "Some(vec![]) is the explicit clear instruction"
1293        );
1294    }
1295
1296    #[test]
1297    fn entry_to_update_rejects_tagged_all_with_non_empty_list() {
1298        let networks: Vec<Value> = vec![json!({ "_id": "n1", "name": "infra" })];
1299        let entry = ApplyPortEntry {
1300            index: 1,
1301            tagged_all: Some(true),
1302            tagged_network_ids: Some(vec!["infra".into()]),
1303            ..ApplyPortEntry::default()
1304        };
1305        let err = entry_to_update(&entry, &networks).expect_err("conflict should error");
1306        assert!(matches!(err, CoreError::ValidationFailed { .. }));
1307    }
1308}