Skip to main content

retch_sysinfo/
network.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Network interface detection, IP resolution, Wi-Fi, and related helpers.
5
6use owo_colors::OwoColorize;
7use sysinfo::Networks;
8
9/// Detects the local IP address and active network interface name.
10pub fn detect_active_interface_and_local_ip() -> (Option<String>, Option<String>) {
11    let local_ip = std::net::UdpSocket::bind("0.0.0.0:0")
12        .ok()
13        .and_then(|socket| {
14            socket.connect("8.8.8.8:53").ok()?;
15            socket.local_addr().ok().map(|addr| addr.ip().to_string())
16        });
17
18    let active_interface = {
19        #[cfg(target_os = "linux")]
20        {
21            let native_iface = std::fs::read_to_string("/proc/net/route")
22                .ok()
23                .and_then(|content| parse_proc_net_route(&content));
24
25            native_iface.or_else(|| {
26                std::process::Command::new("ip")
27                    .args(["route", "show", "default"])
28                    .output()
29                    .ok()
30                    .and_then(|o| String::from_utf8(o.stdout).ok())
31                    .and_then(|s| {
32                        s.split_whitespace()
33                            .position(|w| w == "dev")
34                            .and_then(|i| s.split_whitespace().nth(i + 1))
35                            .map(|s| s.to_string())
36                    })
37            })
38        }
39        #[cfg(target_os = "macos")]
40        {
41            std::process::Command::new("route")
42                .args(["-n", "get", "default"])
43                .output()
44                .ok()
45                .and_then(|o| String::from_utf8(o.stdout).ok())
46                .and_then(|s| {
47                    s.lines()
48                        .find(|l| l.contains("interface:"))
49                        .and_then(|l| l.split_whitespace().last())
50                        .map(|s| s.to_string())
51                })
52        }
53        #[cfg(target_os = "windows")]
54        {
55            std::process::Command::new("powershell")
56                .args(["-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -First 1 -ExpandProperty InterfaceAlias"])
57                .output()
58                .ok()
59                .and_then(|o| String::from_utf8(o.stdout).ok())
60                .map(|s| s.trim().to_string())
61                .filter(|s| !s.is_empty())
62        }
63        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
64        {
65            None
66        }
67    };
68
69    (local_ip, active_interface)
70}
71
72/// Fetches the public IP address via an external service (best-effort, 2s timeout).
73pub fn detect_public_ip() -> Option<String> {
74    std::process::Command::new("curl")
75        .args(["-s", "--max-time", "2", "https://api.ipify.org"])
76        .output()
77        .ok()
78        .and_then(|o| String::from_utf8(o.stdout).ok())
79        .map(|s| s.trim().to_string())
80        .filter(|s| !s.is_empty())
81}
82
83/// Builds the formatted list of network interfaces with IP addresses and RX/TX stats.
84pub fn detect_networks(active_interface: Option<&str>, local_ip: Option<&str>) -> Vec<String> {
85    Networks::new_with_refreshed_list()
86        .iter()
87        .map(|(name, data)| {
88            let rx = format_bytes(data.total_received());
89            let tx = format_bytes(data.total_transmitted());
90            let is_up = data.operational_state() == sysinfo::InterfaceOperationalState::Up
91                || data.total_received() > 0
92                || data.total_transmitted() > 0;
93            let status = if is_up {
94                "Up".green().to_string()
95            } else {
96                "Down".red().to_string()
97            };
98
99            let mut ipv4_addresses = Vec::new();
100            let mut ipv6_addresses = Vec::new();
101
102            if is_up {
103                for ip_net in data.ip_networks() {
104                    let ip = ip_net.addr;
105                    let name_lower = name.to_lowercase();
106                    let is_loopback_iface =
107                        name_lower.starts_with("lo") || name_lower.contains("loopback");
108                    if ip.is_loopback() && !is_loopback_iface {
109                        continue;
110                    }
111                    match ip {
112                        std::net::IpAddr::V4(v4) => {
113                            ipv4_addresses.push(v4.to_string());
114                        }
115                        std::net::IpAddr::V6(v6) => {
116                            if !v6.is_unicast_link_local() {
117                                ipv6_addresses.push(v6.to_string());
118                            }
119                        }
120                    }
121                }
122
123                // Fallback to active interface UDP-resolved local IP if no IPs detected by sysinfo
124                if ipv4_addresses.is_empty() && ipv6_addresses.is_empty() {
125                    if let (Some(active), Some(ip)) = (active_interface, local_ip) {
126                        if name == active {
127                            ipv4_addresses.push(ip.to_string());
128                        }
129                    }
130                }
131            }
132
133            let ip_str = if !ipv4_addresses.is_empty() || !ipv6_addresses.is_empty() {
134                let mut combined = Vec::new();
135                if !ipv4_addresses.is_empty() {
136                    combined.push(ipv4_addresses.join(", "));
137                }
138                if !ipv6_addresses.is_empty() {
139                    combined.push(ipv6_addresses.join(", "));
140                }
141                format!(" ({})", combined.join(", "))
142            } else {
143                String::new()
144            };
145
146            format!("{}{} [{}] RX: {} TX: {}", name, ip_str, status, rx, tx)
147        })
148        .collect()
149}
150
151/// Formats a byte count into human-readable form (KB, MB, GB, etc.)
152pub fn format_bytes(bytes: u64) -> String {
153    const KB: u64 = 1024;
154    const MB: u64 = KB * 1024;
155    const GB: u64 = MB * 1024;
156
157    if bytes >= GB {
158        format!("{:.1} GB", bytes as f64 / GB as f64)
159    } else if bytes >= MB {
160        format!("{:.1} MB", bytes as f64 / MB as f64)
161    } else if bytes >= KB {
162        format!("{:.1} KB", bytes as f64 / KB as f64)
163    } else {
164        format!("{} B", bytes)
165    }
166}
167
168/// Looks up a PCI vendor name from `/usr/share/hwdata/pci.ids` (or fallback paths).
169///
170/// `vendor_id` should be a lowercase hex string without the `0x` prefix.
171pub fn lookup_pci_vendor(vendor_id: &str) -> Option<String> {
172    let vendor_id = vendor_id.trim_start_matches("0x").to_lowercase();
173    let paths = ["/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids"];
174    for path in &paths {
175        if let Ok(content) = std::fs::read_to_string(path) {
176            for line in content.lines() {
177                if line.starts_with('#') || line.is_empty() {
178                    continue;
179                }
180                if !line.starts_with('\t') {
181                    let parts: Vec<&str> = line.split_whitespace().collect();
182                    if parts.len() >= 2 && parts[0].to_lowercase() == vendor_id {
183                        let name = line.strip_prefix(parts[0]).unwrap().trim();
184                        return Some(name.to_string());
185                    }
186                }
187            }
188        }
189    }
190    None
191}
192
193/// Detects the connected Wi-Fi network and link parameters.
194pub fn detect_wifi() -> Option<String> {
195    #[cfg(target_os = "linux")]
196    {
197        let mut wifi_interface = None;
198        if let Ok(entries) = std::fs::read_dir("/sys/class/net") {
199            for entry in entries.filter_map(|e| e.ok()) {
200                let path = entry.path();
201                if path.join("wireless").exists() || path.join("phy80211").exists() {
202                    wifi_interface = Some(entry.file_name().to_string_lossy().to_string());
203                    break;
204                }
205            }
206        }
207
208        if let Some(ref iface) = wifi_interface {
209            if let Ok(output) = std::process::Command::new("iw")
210                .args(["dev", iface, "link"])
211                .output()
212            {
213                if let Ok(stdout) = String::from_utf8(output.stdout) {
214                    let (ssid, links) = parse_iw_link_output(&stdout);
215                    if let Some(s) = ssid {
216                        let card_model = get_wifi_card_model(iface);
217                        let prefix = if let Some(m) = card_model {
218                            format!("{} [{}] - ", m, iface)
219                        } else {
220                            format!("[{}] - ", iface)
221                        };
222
223                        if !links.is_empty() {
224                            let mut link_strs = Vec::new();
225                            for link in links {
226                                let freq_str = link.freq.map(|f| {
227                                    let ghz_mhz = if f >= 1000.0 {
228                                        format!("{:.1} GHz", f / 1000.0)
229                                    } else {
230                                        format!("{} MHz", f)
231                                    };
232                                    if let Some(ch) = freq_to_channel(f) {
233                                        format!("{} ch{}", ghz_mhz, ch)
234                                    } else {
235                                        ghz_mhz
236                                    }
237                                });
238
239                                let mut rx_tx = Vec::new();
240                                if let Some(rx) = link.rx_rate {
241                                    if rx != "0"
242                                        && !rx.starts_with("0 ")
243                                        && rx != "0 Mbps"
244                                        && rx != "0 MBit/s"
245                                    {
246                                        rx_tx.push(format!("↓{}", clean_rate(&rx)));
247                                    }
248                                }
249                                if let Some(tx) = link.tx_rate {
250                                    if tx != "0"
251                                        && !tx.starts_with("0 ")
252                                        && tx != "0 Mbps"
253                                        && tx != "0 MBit/s"
254                                    {
255                                        rx_tx.push(format!("↑{}", clean_rate(&tx)));
256                                    }
257                                }
258
259                                match (freq_str, rx_tx.is_empty()) {
260                                    (Some(f), false) => {
261                                        link_strs.push(format!("{} [{}]", f, rx_tx.join(" ")))
262                                    }
263                                    (Some(f), true) => link_strs.push(f),
264                                    (None, false) => link_strs.push(rx_tx.join(" ")),
265                                    _ => {}
266                                }
267                            }
268                            if !link_strs.is_empty() {
269                                return Some(format!(
270                                    "{}{}{} ({})",
271                                    prefix,
272                                    s,
273                                    "",
274                                    link_strs.join(", ")
275                                ));
276                            } else {
277                                return Some(format!("{}{}", prefix, s));
278                            }
279                        }
280                        return Some(format!("{}{}", prefix, s));
281                    }
282                }
283            }
284        }
285
286        // Fallback to nmcli (using --rescan no to avoid slow hardware channel scans)
287        if let Ok(output) = std::process::Command::new("nmcli")
288            .args([
289                "-t",
290                "-f",
291                "active,ssid,rate",
292                "device",
293                "wifi",
294                "list",
295                "--rescan",
296                "no",
297            ])
298            .output()
299        {
300            if let Ok(stdout) = String::from_utf8(output.stdout) {
301                for line in stdout.lines() {
302                    let line = line.trim();
303                    if let Some(rest) = line.strip_prefix("yes:") {
304                        if let Some(colon_idx) = rest.rfind(':') {
305                            let ssid = &rest[..colon_idx];
306                            let rate = rest[colon_idx + 1..].trim();
307                            if !ssid.is_empty() {
308                                if !rate.is_empty()
309                                    && rate != "0"
310                                    && !rate.starts_with("0 ")
311                                    && rate != "0 Mbit/s"
312                                    && rate != "0 Mbps"
313                                {
314                                    return Some(format!("{} ({})", ssid, clean_rate(rate)));
315                                } else {
316                                    return Some(ssid.to_string());
317                                }
318                            }
319                        } else if !rest.is_empty() {
320                            return Some(rest.to_string());
321                        }
322                    }
323                }
324            }
325        }
326
327        // Fallback to iwgetid
328        if let Ok(output) = std::process::Command::new("iwgetid").arg("-r").output() {
329            if let Ok(stdout) = String::from_utf8(output.stdout) {
330                let ssid = stdout.trim();
331                if !ssid.is_empty() {
332                    return Some(ssid.to_string());
333                }
334            }
335        }
336        None
337    }
338
339    #[cfg(target_os = "macos")]
340    {
341        crate::macos_ffi::get_wifi_info().map(|(ssid, rate)| match rate {
342            Some(r) if r > 0 => format!("{} (↑{} Mbps)", ssid, r),
343            _ => ssid,
344        })
345    }
346
347    #[cfg(target_os = "windows")]
348    {
349        if let Ok(output) = std::process::Command::new("netsh")
350            .args(["wlan", "show", "interfaces"])
351            .output()
352        {
353            if let Ok(stdout) = String::from_utf8(output.stdout) {
354                return parse_netsh_output(&stdout);
355            }
356        }
357        None
358    }
359
360    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
361    {
362        None
363    }
364}
365
366#[cfg(any(target_os = "linux", test))]
367pub fn parse_proc_net_route(content: &str) -> Option<String> {
368    for line in content.lines().skip(1) {
369        let parts: Vec<&str> = line.split_whitespace().collect();
370        if parts.len() >= 8 {
371            let dest = parts[1];
372            let mask = parts[7];
373            if dest == "00000000" && mask == "00000000" {
374                return Some(parts[0].to_string());
375            }
376        }
377    }
378    None
379}
380
381#[allow(
382    clippy::manual_is_multiple_of,
383    clippy::manual_range_contains,
384    dead_code
385)]
386fn freq_to_channel(freq_mhz: f64) -> Option<u32> {
387    let freq = freq_mhz.round() as u32;
388    if freq >= 2412 && freq <= 2472 {
389        Some((freq - 2407) / 5)
390    } else if freq == 2484 {
391        Some(14)
392    } else if freq >= 5160 && freq <= 5885 {
393        if (freq - 5000) % 5 == 0 {
394            Some((freq - 5000) / 5)
395        } else {
396            None
397        }
398    } else if freq >= 5955 && freq <= 7115 {
399        if (freq - 5950) % 5 == 0 {
400            Some((freq - 5950) / 5)
401        } else {
402            None
403        }
404    } else {
405        None
406    }
407}
408
409#[allow(dead_code)]
410fn get_wifi_card_model(iface: &str) -> Option<String> {
411    let vendor = std::fs::read_to_string(format!("/sys/class/net/{}/device/vendor", iface)).ok()?;
412    let device = std::fs::read_to_string(format!("/sys/class/net/{}/device/device", iface)).ok()?;
413    let vendor_clean = vendor.trim().trim_start_matches("0x").to_lowercase();
414    let device_clean = device.trim().trim_start_matches("0x").to_lowercase();
415
416    let vendor_name = lookup_pci_vendor(&vendor_clean);
417    let model_name = crate::gpu::lookup_pci_device(&vendor_clean, &device_clean);
418
419    match (vendor_name, model_name) {
420        (Some(v), Some(m)) => {
421            let v_clean = v.replace(", Inc.", "").replace(" Corporation", "");
422            if m.to_lowercase().contains(&v_clean.to_lowercase())
423                || m.to_lowercase().contains(
424                    &v_clean
425                        .split_whitespace()
426                        .next()
427                        .unwrap_or("")
428                        .to_lowercase(),
429                )
430            {
431                Some(m)
432            } else {
433                Some(format!("{} {}", v_clean, m))
434            }
435        }
436        (None, Some(m)) => Some(m),
437        _ => None,
438    }
439}
440
441#[allow(dead_code)]
442fn clean_rate(rate: &str) -> String {
443    rate.replace("MBit/s", "Mbps")
444        .replace("GBit/s", "Gbps")
445        .replace("Bit/s", "bps")
446}
447
448#[derive(Debug, Clone)]
449pub struct WifiLink {
450    pub freq: Option<f64>,
451    pub rx_rate: Option<String>,
452    pub tx_rate: Option<String>,
453}
454
455#[allow(dead_code)]
456pub fn parse_iw_link_output(stdout: &str) -> (Option<String>, Vec<WifiLink>) {
457    let mut ssid = None;
458    let mut links = Vec::new();
459    let mut current_link = None;
460
461    for line in stdout.lines() {
462        let trimmed = line.trim();
463        if trimmed.starts_with("Connected to") || trimmed.starts_with("link") {
464            if let Some(link) = current_link.take() {
465                links.push(link);
466            }
467            current_link = Some(WifiLink {
468                freq: None,
469                rx_rate: None,
470                tx_rate: None,
471            });
472        } else if trimmed.starts_with("SSID:") {
473            ssid = Some(trimmed.strip_prefix("SSID:").unwrap().trim().to_string());
474        } else if trimmed.starts_with("freq:") {
475            if let Some(ref mut link) = current_link {
476                let freq_str = trimmed.strip_prefix("freq:").unwrap().trim();
477                link.freq = freq_str.parse::<f64>().ok();
478            }
479        } else if trimmed.starts_with("rx bitrate:") {
480            if let Some(ref mut link) = current_link {
481                let rx_str = trimmed.strip_prefix("rx bitrate:").unwrap().trim();
482                let rate = rx_str
483                    .split_whitespace()
484                    .take(2)
485                    .collect::<Vec<&str>>()
486                    .join(" ");
487                link.rx_rate = Some(rate);
488            }
489        } else if trimmed.starts_with("tx bitrate:") {
490            if let Some(ref mut link) = current_link {
491                let tx_str = trimmed.strip_prefix("tx bitrate:").unwrap().trim();
492                let rate = tx_str
493                    .split_whitespace()
494                    .take(2)
495                    .collect::<Vec<&str>>()
496                    .join(" ");
497                link.tx_rate = Some(rate);
498            }
499        }
500    }
501    if let Some(link) = current_link {
502        links.push(link);
503    }
504    (ssid, links)
505}
506
507#[allow(dead_code)]
508pub fn parse_netsh_output(stdout: &str) -> Option<String> {
509    let mut ssid = None;
510    let mut rx = None;
511    let mut tx = None;
512    let mut band = None;
513    for line in stdout.lines() {
514        let trimmed = line.trim();
515        if trimmed.starts_with("SSID") {
516            if let Some(idx) = trimmed.find(':') {
517                let val = trimmed[idx + 1..].trim().to_string();
518                if !val.is_empty() {
519                    ssid = Some(val);
520                }
521            }
522        } else if trimmed.starts_with("Receive rate (Mbps)") {
523            if let Some(idx) = trimmed.find(':') {
524                let val = trimmed[idx + 1..].trim().to_string();
525                if !val.is_empty() {
526                    rx = Some(val);
527                }
528            }
529        } else if trimmed.starts_with("Transmit rate (Mbps)") {
530            if let Some(idx) = trimmed.find(':') {
531                let val = trimmed[idx + 1..].trim().to_string();
532                if !val.is_empty() {
533                    tx = Some(val);
534                }
535            }
536        } else if trimmed.starts_with("Band") {
537            if let Some(idx) = trimmed.find(':') {
538                let val = trimmed[idx + 1..].trim().to_string();
539                if !val.is_empty() {
540                    band = Some(val);
541                }
542            }
543        }
544    }
545    if let Some(s) = ssid {
546        let mut rate_strs = Vec::new();
547        if let Some(rx_val) = rx {
548            if rx_val != "0" {
549                rate_strs.push(format!("↓{} Mbps", rx_val));
550            }
551        }
552        if let Some(tx_val) = tx {
553            if tx_val != "0" {
554                rate_strs.push(format!("↑{} Mbps", tx_val));
555            }
556        }
557        let info = match (band, rate_strs.is_empty()) {
558            (Some(b), false) => format!("{} [{}]", b, rate_strs.join(" ")),
559            (Some(b), true) => b,
560            (None, false) => rate_strs.join(" "),
561            _ => String::new(),
562        };
563        if !info.is_empty() {
564            Some(format!("{} ({})", s, info))
565        } else {
566            Some(s)
567        }
568    } else {
569        None
570    }
571}
572
573/// Returns the list of configured DNS nameserver addresses.
574///
575/// Linux/macOS: parses `nameserver` lines from `/etc/resolv.conf`.
576/// Windows: runs PowerShell `Get-DnsClientServerAddress`.
577/// Returns an empty `Vec` if nothing is found.
578pub fn detect_dns() -> Vec<String> {
579    #[cfg(any(target_os = "linux", target_os = "macos"))]
580    {
581        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
582            return parse_resolv_conf(&content);
583        }
584    }
585    #[cfg(target_os = "windows")]
586    {
587        if let Ok(output) = std::process::Command::new("powershell")
588            .args([
589                "-Command",
590                "Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object -ExpandProperty ServerAddresses | Sort-Object -Unique",
591            ])
592            .output()
593        {
594            if let Ok(stdout) = String::from_utf8(output.stdout) {
595                let servers: Vec<String> = stdout
596                    .lines()
597                    .map(|l| l.trim().to_string())
598                    .filter(|l| !l.is_empty())
599                    .collect();
600                if !servers.is_empty() {
601                    return servers;
602                }
603            }
604        }
605    }
606    Vec::new()
607}
608
609#[cfg(any(target_os = "linux", target_os = "macos", test))]
610pub fn parse_resolv_conf(content: &str) -> Vec<String> {
611    content
612        .lines()
613        .filter_map(|line| {
614            let line = line.trim();
615            if line.starts_with('#') || line.starts_with(';') {
616                return None;
617            }
618            let mut parts = line.split_whitespace();
619            if parts.next()? == "nameserver" {
620                parts.next().map(|s| s.to_string())
621            } else {
622                None
623            }
624        })
625        .collect()
626}
627
628/// Returns the configured DNS search domain name.
629///
630/// Reads the `domain` directive from `/etc/resolv.conf` (takes precedence),
631/// falling back to the first entry of the `search` directive. Returns `None`
632/// when neither is present or the file is unreadable.
633pub fn detect_domain() -> Option<String> {
634    #[cfg(any(target_os = "linux", target_os = "macos"))]
635    {
636        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
637            return parse_domain_from_resolv_conf(&content);
638        }
639    }
640    None
641}
642
643/// Returns per-interface DNS search domain lists.
644///
645/// On Linux, tries `resolvectl status --no-pager` first and parses per-link
646/// DNS Domain / DNS Search Domains entries. Falls back to the global `search`
647/// list from `/etc/resolv.conf` when resolvectl is unavailable.
648/// On macOS, reads the global `search` list from `/etc/resolv.conf`.
649pub fn detect_domain_search() -> Vec<String> {
650    #[cfg(target_os = "linux")]
651    {
652        if let Ok(output) = std::process::Command::new("resolvectl")
653            .args(["status", "--no-pager"])
654            .output()
655        {
656            if output.status.success() {
657                let text = String::from_utf8_lossy(&output.stdout);
658                let result = parse_resolvectl_search(&text);
659                if !result.is_empty() {
660                    return result;
661                }
662            }
663        }
664        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
665            return parse_search_from_resolv_conf(&content);
666        }
667    }
668    #[cfg(target_os = "macos")]
669    {
670        if let Ok(content) = std::fs::read_to_string("/etc/resolv.conf") {
671            return parse_search_from_resolv_conf(&content);
672        }
673    }
674    Vec::new()
675}
676
677/// Parses the `domain` directive (or first `search` entry as fallback) from
678/// `/etc/resolv.conf` content.
679#[cfg(any(target_os = "linux", target_os = "macos", test))]
680pub fn parse_domain_from_resolv_conf(content: &str) -> Option<String> {
681    let mut first_search: Option<String> = None;
682    for line in content.lines() {
683        let line = line.trim();
684        if line.starts_with('#') || line.starts_with(';') {
685            continue;
686        }
687        let mut parts = line.split_whitespace();
688        match parts.next() {
689            Some("domain") => {
690                if let Some(d) = parts.next() {
691                    return Some(d.to_string());
692                }
693            }
694            Some("search") if first_search.is_none() => {
695                if let Some(d) = parts.next() {
696                    first_search = Some(d.to_string());
697                }
698            }
699            _ => {}
700        }
701    }
702    first_search
703}
704
705/// Parses all entries from the `search` directive in `/etc/resolv.conf` content.
706#[cfg(any(target_os = "linux", target_os = "macos", test))]
707pub fn parse_search_from_resolv_conf(content: &str) -> Vec<String> {
708    for line in content.lines() {
709        let line = line.trim();
710        if line.starts_with('#') || line.starts_with(';') {
711            continue;
712        }
713        let mut parts = line.split_whitespace();
714        if parts.next() == Some("search") {
715            let domains: Vec<String> = parts.map(|s| s.to_string()).collect();
716            if !domains.is_empty() {
717                return domains;
718            }
719        }
720    }
721    Vec::new()
722}
723
724/// Parses `resolvectl status --no-pager` output into per-interface search domain strings.
725///
726/// Skips routing-only domains (`~.`). Returns entries like `"wlan0: home.local"`.
727#[cfg(any(target_os = "linux", test))]
728pub fn parse_resolvectl_search(content: &str) -> Vec<String> {
729    let mut results = Vec::new();
730    let mut current_iface: Option<String> = None;
731
732    for line in content.lines() {
733        let trimmed = line.trim();
734        // "Link N (ifname)" starts a new interface section
735        if trimmed.starts_with("Link ") {
736            if let (Some(start), Some(end)) = (trimmed.find('('), trimmed.find(')')) {
737                current_iface = Some(trimmed[start + 1..end].to_string());
738            }
739            continue;
740        }
741        if let Some(ref iface) = current_iface {
742            let domains_str = trimmed
743                .strip_prefix("DNS Domain:")
744                .or_else(|| trimmed.strip_prefix("DNS Search Domains:"))
745                .map(|v| v.trim());
746            if let Some(domains) = domains_str {
747                // Skip routing-only catch-all domain
748                let filtered: Vec<&str> =
749                    domains.split_whitespace().filter(|d| *d != "~.").collect();
750                if !filtered.is_empty() {
751                    results.push(format!("{}: {}", iface, filtered.join(", ")));
752                }
753            }
754        }
755    }
756    results
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    #[test]
764    fn test_format_bytes() {
765        assert_eq!(format_bytes(500), "500 B");
766        assert_eq!(format_bytes(1024), "1.0 KB");
767        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
768        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
769        assert_eq!(format_bytes(1536), "1.5 KB");
770    }
771
772    #[test]
773    fn test_parse_proc_net_route() {
774        let sample =
775            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
776                      wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n\
777                      wlan0\t00000000\t0100A8C0\t0003\t0\t0\t600\t00000000\t0\t0\t0\n";
778        assert_eq!(parse_proc_net_route(sample), Some("wlan0".to_string()));
779
780        let sample_no_default =
781            "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
782                                 wlan0\t0000A8C0\t00000000\t0001\t0\t0\t600\t0000FFFF\t0\t0\t0\n";
783        assert_eq!(parse_proc_net_route(sample_no_default), None);
784    }
785
786    #[test]
787    fn test_parse_netsh_output() {
788        let sample = "    Name                   : Wi-Fi\n    State                  : connected\n    SSID                   : Office_Wi-Fi\n    Receive rate (Mbps)    : 433\n    Transmit rate (Mbps)   : 866\n    Band                   : 5 GHz\n";
789        assert_eq!(
790            parse_netsh_output(sample),
791            Some("Office_Wi-Fi (5 GHz [↓433 Mbps ↑866 Mbps])".to_string())
792        );
793    }
794
795    #[test]
796    fn test_parse_iw_link_output() {
797        let sample = "Connected to 84:78:48:dc:97:23 (on wlp2s0)\n        SSID: OfficeNet\n        freq: 6135.0\n        rx bitrate: 6.0 MBit/s\n        tx bitrate: 864.6 MBit/s 160MHz HE-MCS 4\n";
798        let (ssid, links) = parse_iw_link_output(sample);
799        assert_eq!(ssid, Some("OfficeNet".to_string()));
800        assert_eq!(links.len(), 1);
801        assert_eq!(links[0].freq, Some(6135.0));
802        assert_eq!(links[0].rx_rate, Some("6.0 MBit/s".to_string()));
803        assert_eq!(links[0].tx_rate, Some("864.6 MBit/s".to_string()));
804
805        // MLO multi-link mock output
806        let sample_mlo = "Connected to aa:bb:cc:dd:ee:ff (on wlan0)\n        SSID: HomeWiFi\n        freq: 5180.0\n        rx bitrate: 866.0 MBit/s\n        tx bitrate: 866.0 MBit/s\nConnected to aa:bb:cc:dd:ee:01 (on wlan0)\n        freq: 6135.0\n        rx bitrate: 1200.0 MBit/s\n        tx bitrate: 1200.0 MBit/s\n";
807        let (ssid_mlo, links_mlo) = parse_iw_link_output(sample_mlo);
808        assert_eq!(ssid_mlo, Some("HomeWiFi".to_string()));
809        assert_eq!(links_mlo.len(), 2);
810        assert_eq!(links_mlo[0].freq, Some(5180.0));
811        assert_eq!(links_mlo[1].freq, Some(6135.0));
812    }
813
814    #[test]
815    fn test_parse_resolv_conf() {
816        let sample = "# Generated by NetworkManager\ndomain home\nsearch home\nnameserver 192.168.1.1\nnameserver 8.8.8.8\n; comment\nnameserver 2001:db8::1\n";
817        assert_eq!(
818            parse_resolv_conf(sample),
819            vec!["192.168.1.1", "8.8.8.8", "2001:db8::1"]
820        );
821
822        let empty = "# no nameservers\nsearch local\n";
823        assert_eq!(parse_resolv_conf(empty), Vec::<String>::new());
824    }
825
826    #[test]
827    fn test_parse_domain_from_resolv_conf_domain_directive() {
828        let s = "# test\ndomain example.com\nsearch fallback.com\nnameserver 1.1.1.1\n";
829        assert_eq!(
830            parse_domain_from_resolv_conf(s),
831            Some("example.com".to_string())
832        );
833    }
834
835    #[test]
836    fn test_parse_domain_from_resolv_conf_search_fallback() {
837        let s = "# no domain directive\nsearch local.lan other.lan\nnameserver 1.1.1.1\n";
838        assert_eq!(
839            parse_domain_from_resolv_conf(s),
840            Some("local.lan".to_string())
841        );
842    }
843
844    #[test]
845    fn test_parse_domain_from_resolv_conf_none() {
846        let s = "# no domain or search\nnameserver 1.1.1.1\n";
847        assert_eq!(parse_domain_from_resolv_conf(s), None);
848    }
849
850    #[test]
851    fn test_parse_search_from_resolv_conf() {
852        let s = "search home.local corp.example.com\nnameserver 1.1.1.1\n";
853        assert_eq!(
854            parse_search_from_resolv_conf(s),
855            vec!["home.local", "corp.example.com"]
856        );
857    }
858
859    #[test]
860    fn test_parse_resolvectl_search_basic() {
861        let sample = "Global\n\
862            Link 2 (lo)\n\
863              Current Scopes: none\n\
864            Link 3 (wlan0)\n\
865              Current Scopes: DNS\n\
866              DNS Domain: home.local\n\
867            Link 4 (eth0)\n\
868              Current Scopes: DNS\n\
869              DNS Search Domains: corp.example.com internal.net\n";
870        let result = parse_resolvectl_search(sample);
871        assert_eq!(
872            result,
873            vec!["wlan0: home.local", "eth0: corp.example.com, internal.net"]
874        );
875    }
876
877    #[test]
878    fn test_parse_resolvectl_search_skips_routing_domain() {
879        let sample = "Link 2 (wlan0)\n  DNS Domain: ~.\nLink 3 (eth0)\n  DNS Domain: corp.net\n";
880        let result = parse_resolvectl_search(sample);
881        assert_eq!(result, vec!["eth0: corp.net"]);
882    }
883
884    #[test]
885    fn test_parse_resolvectl_search_empty() {
886        let sample = "Global\n  DNS Servers: 1.1.1.1\n";
887        assert!(parse_resolvectl_search(sample).is_empty());
888    }
889}