Skip to main content

prt_core/core/
bandwidth.rs

1//! System-wide bandwidth estimation.
2//!
3//! Reads network interface byte counters and computes RX/TX rates.
4//! - **Linux:** parses `/proc/net/dev`
5//! - **macOS:** parses `netstat -ib`
6//!
7//! The first sample has no delta, so the rate is `None`.
8
9use std::time::Instant;
10
11/// Bandwidth measurement: bytes per second for RX and TX.
12#[derive(Debug, Clone, Copy)]
13pub struct BandwidthRate {
14    pub rx_bytes_per_sec: f64,
15    pub tx_bytes_per_sec: f64,
16}
17
18/// Tracks bandwidth by sampling byte counters.
19#[derive(Debug)]
20pub struct BandwidthTracker {
21    prev_sample: Option<(u64, u64, Instant)>, // (rx_bytes, tx_bytes, when)
22    pub current_rate: Option<BandwidthRate>,
23}
24
25impl Default for BandwidthTracker {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl BandwidthTracker {
32    pub fn new() -> Self {
33        Self {
34            prev_sample: None,
35            current_rate: None,
36        }
37    }
38
39    /// Take a new sample and compute the rate delta.
40    pub fn sample(&mut self) {
41        let (rx, tx) = match read_counters() {
42            Some(v) => v,
43            None => return,
44        };
45        let now = Instant::now();
46
47        if let Some((prev_rx, prev_tx, prev_time)) = self.prev_sample {
48            let elapsed = now.duration_since(prev_time).as_secs_f64();
49            if elapsed > 0.0 {
50                let rx_delta = rx.saturating_sub(prev_rx) as f64;
51                let tx_delta = tx.saturating_sub(prev_tx) as f64;
52                self.current_rate = Some(BandwidthRate {
53                    rx_bytes_per_sec: rx_delta / elapsed,
54                    tx_bytes_per_sec: tx_delta / elapsed,
55                });
56            }
57        }
58
59        self.prev_sample = Some((rx, tx, now));
60    }
61}
62
63/// Format bytes/sec as human-readable string (e.g. "1.2 MB/s").
64pub fn format_rate(bytes_per_sec: f64) -> String {
65    if bytes_per_sec < 1024.0 {
66        format!("{:.0} B/s", bytes_per_sec)
67    } else if bytes_per_sec < 1024.0 * 1024.0 {
68        format!("{:.1} KB/s", bytes_per_sec / 1024.0)
69    } else if bytes_per_sec < 1024.0 * 1024.0 * 1024.0 {
70        format!("{:.1} MB/s", bytes_per_sec / (1024.0 * 1024.0))
71    } else {
72        format!("{:.1} GB/s", bytes_per_sec / (1024.0 * 1024.0 * 1024.0))
73    }
74}
75
76/// Read total RX and TX byte counters from the OS.
77fn read_counters() -> Option<(u64, u64)> {
78    #[cfg(target_os = "linux")]
79    {
80        read_counters_linux()
81    }
82    #[cfg(target_os = "macos")]
83    {
84        read_counters_macos()
85    }
86    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
87    {
88        None
89    }
90}
91
92/// Parse `/proc/net/dev` for total bytes.
93#[cfg(target_os = "linux")]
94fn read_counters_linux() -> Option<(u64, u64)> {
95    let content = std::fs::read_to_string("/proc/net/dev").ok()?;
96    parse_proc_net_dev(&content)
97}
98
99/// Parse `netstat -ib` for total bytes.
100#[cfg(target_os = "macos")]
101fn read_counters_macos() -> Option<(u64, u64)> {
102    let output = std::process::Command::new("netstat")
103        .args(["-ib"])
104        .output()
105        .ok()?;
106    let content = String::from_utf8(output.stdout).ok()?;
107    parse_netstat_ib(&content)
108}
109
110/// Parse `/proc/net/dev` format.
111///
112/// Format:
113/// ```text
114/// Inter-|   Receive                                                |  Transmit
115///  face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs ...
116///    lo: 1234 ...   5678 ...
117///  eth0: 9999 ...   8888 ...
118/// ```
119#[allow(dead_code)]
120fn parse_proc_net_dev(content: &str) -> Option<(u64, u64)> {
121    let mut total_rx = 0u64;
122    let mut total_tx = 0u64;
123    let mut found = false;
124
125    for line in content.lines().skip(2) {
126        // Skip header lines
127        let line = line.trim();
128        if line.is_empty() {
129            continue;
130        }
131        let parts: Vec<&str> = line.split_whitespace().collect();
132        if parts.len() < 10 {
133            continue;
134        }
135        // Skip loopback
136        if parts[0].starts_with("lo:") || parts[0] == "lo:" {
137            continue;
138        }
139        if let (Ok(rx), Ok(tx)) = (parts[1].parse::<u64>(), parts[9].parse::<u64>()) {
140            total_rx += rx;
141            total_tx += tx;
142            found = true;
143        }
144    }
145
146    if found {
147        Some((total_rx, total_tx))
148    } else {
149        None
150    }
151}
152
153/// Parse `netstat -ib` output (macOS).
154///
155/// Format:
156/// ```text
157/// Name  Mtu   Network       Address            Ipkts Ierrs     Ibytes    Opkts Oerrs     Obytes  Coll
158/// lo0   16384 <Link#1>                         12345     0     678901    12345     0     678901     0
159/// en0   1500  <Link#6>    xx:xx:xx:xx:xx:xx   98765     0   12345678    87654     0    9876543     0
160/// ```
161#[allow(dead_code)]
162fn parse_netstat_ib(content: &str) -> Option<(u64, u64)> {
163    let mut total_rx = 0u64;
164    let mut total_tx = 0u64;
165    let mut found = false;
166
167    for line in content.lines().skip(1) {
168        let parts: Vec<&str> = line.split_whitespace().collect();
169        if parts.len() < 11 {
170            continue;
171        }
172        // Skip loopback
173        if parts[0].starts_with("lo") {
174            continue;
175        }
176        // Only count <Link#N> entries (physical interfaces)
177        if !parts[2].starts_with("<Link#") {
178            continue;
179        }
180        if let (Ok(rx), Ok(tx)) = (parts[6].parse::<u64>(), parts[9].parse::<u64>()) {
181            total_rx += rx;
182            total_tx += tx;
183            found = true;
184        }
185    }
186
187    if found {
188        Some((total_rx, total_tx))
189    } else {
190        None
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn format_rate_bytes() {
200        assert_eq!(format_rate(0.0), "0 B/s");
201        assert_eq!(format_rate(500.0), "500 B/s");
202        assert_eq!(format_rate(1023.0), "1023 B/s");
203    }
204
205    #[test]
206    fn format_rate_kilobytes() {
207        assert_eq!(format_rate(1024.0), "1.0 KB/s");
208        assert_eq!(format_rate(1536.0), "1.5 KB/s");
209        assert_eq!(format_rate(500_000.0), "488.3 KB/s");
210    }
211
212    #[test]
213    fn format_rate_megabytes() {
214        assert_eq!(format_rate(1_048_576.0), "1.0 MB/s");
215        assert_eq!(format_rate(10_000_000.0), "9.5 MB/s");
216    }
217
218    #[test]
219    fn format_rate_gigabytes() {
220        assert_eq!(format_rate(1_073_741_824.0), "1.0 GB/s");
221    }
222
223    #[test]
224    fn tracker_first_sample_no_rate() {
225        let mut t = BandwidthTracker::new();
226        assert!(t.current_rate.is_none());
227        t.sample();
228        // After first sample, still no rate (no delta)
229        // (may or may not have rate depending on platform)
230    }
231
232    #[test]
233    fn parse_proc_net_dev_valid() {
234        let content = "\
235Inter-|   Receive                                                |  Transmit
236 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
237    lo: 100 10 0 0 0 0 0 0 100 10 0 0 0 0 0 0
238  eth0: 5000 50 0 0 0 0 0 0 3000 30 0 0 0 0 0 0
239  eth1: 2000 20 0 0 0 0 0 0 1000 10 0 0 0 0 0 0
240";
241        let (rx, tx) = parse_proc_net_dev(content).unwrap();
242        assert_eq!(rx, 7000); // eth0 + eth1, skip lo
243        assert_eq!(tx, 4000);
244    }
245
246    #[test]
247    fn parse_proc_net_dev_empty() {
248        let content = "\
249Inter-|   Receive                                                |  Transmit
250 face |bytes    packets errs drop fifo frame compressed multicast|bytes    packets errs drop fifo colls carrier compressed
251";
252        assert!(parse_proc_net_dev(content).is_none());
253    }
254
255    #[test]
256    fn parse_netstat_ib_valid() {
257        let content = "\
258Name  Mtu   Network       Address            Ipkts Ierrs     Ibytes    Opkts Oerrs     Obytes  Coll
259lo0   16384 <Link#1>                           1000     0     500000     1000     0     500000     0
260en0   1500  <Link#6>    aa:bb:cc:dd:ee:ff      5000     0    3000000     4000     0    2000000     0
261en0   1500  192.168.1     192.168.1.100         5000     0    3000000     4000     0    2000000     0
262";
263        let (rx, tx) = parse_netstat_ib(content).unwrap();
264        assert_eq!(rx, 3_000_000); // only en0 <Link#6>, skip lo0 and non-Link
265        assert_eq!(tx, 2_000_000);
266    }
267
268    #[test]
269    fn parse_netstat_ib_empty() {
270        let content = "Name  Mtu   Network       Address\n";
271        assert!(parse_netstat_ib(content).is_none());
272    }
273}