retch_sysinfo/io.rs
1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Disk and network I/O throughput sampling.
5//!
6//! Both fields report a **rate**, which a one-shot process cannot read directly: the
7//! kernel exposes cumulative counters, so a rate needs two samples and a known interval.
8//! fastfetch solves this with a dedicated ~1 s sleep (measured: `fastfetch -s NetIO`
9//! takes 1.00 s against 0.00 s for a counter-only module). retch cannot afford that —
10//! `--long` targets ~500 ms end to end, and being slower than fastfetch is treated as a
11//! blocking regression (NOTES.md §3, "Performance Regression Vigilance").
12//!
13//! So this module follows the v0.3.49 `cpu-usage` pattern instead: [`fetch`] samples the
14//! counters *before* the concurrent probe scope and diffs them *after*, making the
15//! existing collection window the sampling interval. A floor is applied only when the
16//! window came out too short to measure anything (an isolated `--fields disk-io`), which
17//! is the sole case where these fields add any wall-clock at all.
18//!
19//! The consequence, stated plainly because it is the honest reading of the number: the
20//! window varies by mode — roughly 0.4 s in `--long`, seconds in `--full` — so the value
21//! is the *average* rate over the run, not an instantaneous one. That is the right
22//! trade for a fetcher; a stable window would cost a sleep on every invocation.
23//!
24//! [`fetch`]: crate::fetch
25
26/// Cumulative byte counters for one device at a point in time.
27///
28/// `read`/`write` are disk semantics; for network interfaces they carry RX/TX
29/// respectively, since the rate arithmetic is identical and only the labels differ.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct IoCounters {
32 /// Kernel device name (`nvme0n1`, `wlp0s20f3`).
33 pub device: String,
34 /// Bytes read (disk) or received (network) since boot.
35 pub read: u64,
36 /// Bytes written (disk) or transmitted (network) since boot.
37 pub write: u64,
38}
39
40/// A device's throughput over the sampling window, in bytes per second.
41#[derive(Debug, Clone, PartialEq)]
42pub struct IoRate {
43 /// Kernel device name.
44 pub device: String,
45 /// Read/RX rate in bytes per second.
46 pub read: f64,
47 /// Write/TX rate in bytes per second.
48 pub write: f64,
49}
50
51/// Bytes per sector in `/proc/diskstats`.
52///
53/// **This is a fixed kernel convention, not the device's sector size.** diskstats reports
54/// in 512-byte bio sectors regardless of what `/sys/block/<dev>/queue/hw_sector_size`
55/// says, so keying this off the hardware value inflates every figure 8× on a 4 KiB-sector
56/// drive. `disk.rs` already relies on the same convention for `/sys/block/<dev>/size`.
57///
58/// Confirmed here by writing a known 64 MiB of incompressible data and reading the delta:
59/// 146808 sectors, i.e. 71 MiB at 512 B/sector (the excess is btrfs metadata and CoW)
60/// against an impossible 573 MiB at 4096. **Verification limit, recorded rather than
61/// papered over:** the host used for that check has `hw_sector_size` 512 itself, so the
62/// result confirms the value without discriminating the two rules. A device with a 4 KiB
63/// logical sector would separate them.
64const DISKSTATS_SECTOR_BYTES: u64 = 512;
65
66/// Field index of "sectors read" in a `/proc/diskstats` line (0-based, after splitting on
67/// whitespace): major, minor, name, reads completed, reads merged, **sectors read**.
68const DISKSTATS_SECTORS_READ: usize = 5;
69
70/// Field index of "sectors written": … ms reading, writes completed, writes merged,
71/// **sectors written**.
72const DISKSTATS_SECTORS_WRITTEN: usize = 9;
73
74/// Parses `/proc/diskstats` into per-device byte counters.
75///
76/// `keep` decides which device names survive; it is injected rather than hardcoded so the
77/// tests can assert against a verbatim fixture without depending on the block devices of
78/// whatever machine runs them — the #155/v0.6.2 lesson, where a "parse this fixture" test
79/// silently consulted live hardware and failed on one developer's box.
80///
81/// Lines with too few fields or unparsable counters are skipped rather than defaulted to
82/// zero: a zero would render as a confident `0 B/s` for a device that was never read.
83pub fn parse_diskstats<F>(content: &str, keep: F) -> Vec<IoCounters>
84where
85 F: Fn(&str) -> bool,
86{
87 let mut out = Vec::new();
88 for line in content.lines() {
89 let fields: Vec<&str> = line.split_whitespace().collect();
90 if fields.len() <= DISKSTATS_SECTORS_WRITTEN {
91 continue;
92 }
93 let name = fields[2];
94 if !keep(name) {
95 continue;
96 }
97 let (Ok(read_sectors), Ok(written_sectors)) = (
98 fields[DISKSTATS_SECTORS_READ].parse::<u64>(),
99 fields[DISKSTATS_SECTORS_WRITTEN].parse::<u64>(),
100 ) else {
101 continue;
102 };
103 out.push(IoCounters {
104 device: name.to_string(),
105 read: read_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
106 write: written_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
107 });
108 }
109 out
110}
111
112/// Computes per-device rates between two samples taken `elapsed_secs` apart.
113///
114/// A device present in only one sample is **dropped**, not reported: an interface that
115/// appeared mid-run (a VPN link coming up) has no baseline, and treating its lifetime
116/// counter as a delta would render a spectacular fictional rate.
117///
118/// Counter decreases are clamped to zero via `saturating_sub`. Counters do reset in
119/// practice — an interface going down and up, a module reload — and a wrapped subtraction
120/// would produce an exabyte-scale rate from a perfectly ordinary event.
121pub fn compute_rates(
122 before: &[IoCounters],
123 after: &[IoCounters],
124 elapsed_secs: f64,
125) -> Vec<IoRate> {
126 // Written as an explicit finite check rather than `<= 0.0` because a NaN window must
127 // also yield nothing: `NaN <= 0.0` is false, so the terse form would divide by it.
128 if !elapsed_secs.is_finite() || elapsed_secs <= 0.0 {
129 return Vec::new();
130 }
131 after
132 .iter()
133 .filter_map(|now| {
134 let prev = before.iter().find(|p| p.device == now.device)?;
135 Some(IoRate {
136 device: now.device.clone(),
137 read: now.read.saturating_sub(prev.read) as f64 / elapsed_secs,
138 write: now.write.saturating_sub(prev.write) as f64 / elapsed_secs,
139 })
140 })
141 .collect()
142}
143
144/// Formats a byte-per-second rate for display (`"1.2 MB/s"`).
145///
146/// Delegates to [`crate::network::format_bytes`] so the unit vocabulary matches the `Net`
147/// field's existing `RX:`/`TX:` totals rather than introducing a second scheme alongside
148/// it. Non-finite and negative inputs render as `0 B/s`; they cannot arise from
149/// [`compute_rates`], but the formatter is public and should not print `NaN B/s`.
150pub fn format_rate(bytes_per_sec: f64) -> String {
151 let clamped = if bytes_per_sec.is_finite() && bytes_per_sec > 0.0 {
152 bytes_per_sec.round() as u64
153 } else {
154 0
155 };
156 format!("{}/s", crate::network::format_bytes(clamped))
157}
158
159/// Renders one device's rates as a display line, e.g.
160/// `"nvme0n1 R: 1.2 MB/s W: 0 B/s"`.
161pub fn format_io_line(rate: &IoRate, read_label: &str, write_label: &str) -> String {
162 format!(
163 "{} {}: {} {}: {}",
164 rate.device,
165 read_label,
166 format_rate(rate.read),
167 write_label,
168 format_rate(rate.write)
169 )
170}
171
172/// Chooses which interfaces the `net-io` field reports.
173///
174/// The default-route interface when it is known — matching both fastfetch and the way the
175/// `Net` field already singles that interface out. Otherwise (offline, or the active
176/// interface could not be resolved) every interface that actually moved bytes during the
177/// window, so an unusual routing setup still reports something rather than nothing.
178///
179/// Returning an empty list when the active interface is known but idle is deliberate: a
180/// `0 B/s` line for the interface you are using is a real, informative reading.
181pub fn select_net_rates(rates: Vec<IoRate>, active: Option<&str>) -> Vec<IoRate> {
182 if let Some(active) = active {
183 let selected: Vec<IoRate> = rates
184 .iter()
185 .filter(|r| r.device == active)
186 .cloned()
187 .collect();
188 if !selected.is_empty() {
189 return selected;
190 }
191 }
192 rates
193 .into_iter()
194 .filter(|r| r.read > 0.0 || r.write > 0.0)
195 .collect()
196}
197
198/// Samples cumulative disk byte counters for physical whole disks.
199///
200/// Linux reads `/proc/diskstats`; Windows queries `IOCTL_DISK_PERFORMANCE` per
201/// `\\.\PhysicalDriveN`. Elsewhere this returns an empty vector, so the field is simply
202/// absent rather than wrong (same shape as `brightness`, `keyboard`, `tpm`).
203///
204/// Partitions are excluded on both platforms because their traffic is already counted
205/// against the parent device — reporting both would double every disk's apparent
206/// throughput. On Windows that falls out of addressing whole drives directly; on Linux it
207/// takes an explicit filter.
208pub fn sample_disk_io() -> Vec<IoCounters> {
209 #[cfg(target_os = "linux")]
210 {
211 let Ok(content) = std::fs::read_to_string("/proc/diskstats") else {
212 return Vec::new();
213 };
214 parse_diskstats(&content, is_physical_disk)
215 }
216
217 #[cfg(target_os = "windows")]
218 {
219 win_ffi::sample_physical_drives()
220 }
221
222 #[cfg(not(any(target_os = "linux", target_os = "windows")))]
223 {
224 Vec::new()
225 }
226}
227
228/// True when `name` is a physical whole disk rather than a partition or virtual device.
229///
230/// Shares [`crate::disk::is_virtual_block_name`] with the `phys-disk` field so the two
231/// cannot drift into disagreeing about what counts as a disk, then applies the same two
232/// sysfs tests `disk::detect_linux` uses: partitions carry a `partition` file, and a real
233/// block device has a `queue` directory.
234#[cfg(target_os = "linux")]
235fn is_physical_disk(name: &str) -> bool {
236 if crate::disk::is_virtual_block_name(name) {
237 return false;
238 }
239 let dev = std::path::Path::new("/sys/class/block").join(name);
240 !dev.join("partition").exists() && dev.join("queue").exists()
241}
242
243/// Samples cumulative network byte counters per interface.
244///
245/// Linux reads `/sys/class/net/<iface>/statistics/{rx,tx}_bytes` directly rather than
246/// going through sysinfo, so the two samples are guaranteed to come from the same source
247/// and the same units as each other. Windows reads `InOctets`/`OutOctets` from
248/// `GetIfTable2`, which is the same source `Get-NetAdapterStatistics` reports and needs no
249/// subprocess. Empty on other platforms.
250///
251/// Loopback is excluded on both — its traffic is the machine talking to itself and says
252/// nothing about network throughput.
253///
254/// **The interface names must stay in the same vocabulary as `active_interface`**, or
255/// [`select_net_rates`] silently stops matching and falls through to its "everything that
256/// moved" branch. On Windows both are the adapter's friendly name (`Wi-Fi`): sysinfo
257/// reports it, and it is `MIB_IF_ROW2.Alias`.
258pub fn sample_net_io() -> Vec<IoCounters> {
259 #[cfg(target_os = "linux")]
260 {
261 let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
262 return Vec::new();
263 };
264 let mut out = Vec::new();
265 for entry in entries.flatten() {
266 let name = entry.file_name().to_string_lossy().to_string();
267 if name == "lo" || name.starts_with("lo:") {
268 continue;
269 }
270 let stats = entry.path().join("statistics");
271 let read = read_counter(&stats.join("rx_bytes"));
272 let write = read_counter(&stats.join("tx_bytes"));
273 if let (Some(read), Some(write)) = (read, write) {
274 out.push(IoCounters {
275 device: name,
276 read,
277 write,
278 });
279 }
280 }
281 out.sort_by(|a, b| a.device.cmp(&b.device));
282 out
283 }
284
285 #[cfg(target_os = "windows")]
286 {
287 win_ffi::sample_interfaces()
288 }
289
290 #[cfg(not(any(target_os = "linux", target_os = "windows")))]
291 {
292 Vec::new()
293 }
294}
295
296/// Reads a single unsigned counter from a sysfs file.
297#[cfg(target_os = "linux")]
298fn read_counter(path: &std::path::Path) -> Option<u64> {
299 std::fs::read_to_string(path)
300 .ok()?
301 .trim()
302 .parse::<u64>()
303 .ok()
304}
305
306/// Names a physical drive after its `\\.\PhysicalDriveN` index.
307///
308/// The Linux arm reports kernel device names (`nvme0n1`), so Windows reports the closest
309/// equivalent rather than the model string `phys-disk` shows — the two fields answer
310/// different questions, and a drive index is what identifies the device here.
311#[cfg(any(target_os = "windows", test))]
312fn physical_drive_name(index: u32) -> String {
313 format!("PhysicalDrive{index}")
314}
315
316/// Native Win32 bindings for the two counter sources.
317///
318/// Hand-written `extern "system"` declarations, matching the crate's Windows FFI house
319/// style (`win_reg.rs`, `disk.rs`) rather than pulling in a binding crate. The
320/// `CreateFileW`/`DeviceIoControl`/`CloseHandle` declarations duplicate `disk.rs`'s: they
321/// are declarations of the same OS entry points, carrying no logic that could drift, and
322/// sharing them would mean passing raw `HANDLE`s across module boundaries. The scan range
323/// they are used over *is* shared — see [`crate::disk::MAX_PHYSICAL_DRIVES`].
324#[cfg(target_os = "windows")]
325mod win_ffi {
326 use super::{physical_drive_name, IoCounters};
327 use std::ffi::{c_void, OsStr};
328 use std::mem::size_of;
329 use std::os::windows::ffi::OsStrExt;
330 use std::ptr;
331
332 #[allow(clippy::upper_case_acronyms)]
333 type HANDLE = *mut c_void;
334 const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE;
335 const FILE_SHARE_READ: u32 = 0x0000_0001;
336 const FILE_SHARE_WRITE: u32 = 0x0000_0002;
337 const OPEN_EXISTING: u32 = 3;
338
339 /// `IOCTL_DISK_PERFORMANCE`, `CTL_CODE(IOCTL_DISK_BASE, 0x0008, METHOD_BUFFERED,
340 /// FILE_ANY_ACCESS)`.
341 ///
342 /// The access bits are zero, so — like the two IOCTLs `disk.rs` uses — it can be
343 /// issued on a handle opened with no access rights and needs no elevation. Confirmed
344 /// on Windows 11 from an unelevated shell before this code was written.
345 const IOCTL_DISK_PERFORMANCE: u32 = 0x0007_0020;
346
347 /// `DISK_PERFORMANCE`. Only the two byte counters are read; the rest of the struct is
348 /// declared so the layout — and therefore those two offsets — is right.
349 #[repr(C)]
350 #[derive(Default)]
351 struct DiskPerformance {
352 bytes_read: i64,
353 bytes_written: i64,
354 read_time: i64,
355 write_time: i64,
356 idle_time: i64,
357 read_count: u32,
358 write_count: u32,
359 queue_depth: u32,
360 split_count: u32,
361 query_time: i64,
362 storage_device_number: u32,
363 storage_manager_name: [u16; 8],
364 }
365
366 extern "system" {
367 fn CreateFileW(
368 lp_file_name: *const u16,
369 dw_desired_access: u32,
370 dw_share_mode: u32,
371 lp_security_attributes: *mut c_void,
372 dw_creation_disposition: u32,
373 dw_flags_and_attributes: u32,
374 h_template_file: HANDLE,
375 ) -> HANDLE;
376
377 fn DeviceIoControl(
378 h_device: HANDLE,
379 dw_io_control_code: u32,
380 lp_in_buffer: *const c_void,
381 n_in_buffer_size: u32,
382 lp_out_buffer: *mut c_void,
383 n_out_buffer_size: u32,
384 lp_bytes_returned: *mut u32,
385 lp_overlapped: *mut c_void,
386 ) -> i32;
387
388 fn CloseHandle(h_object: HANDLE) -> i32;
389 }
390
391 /// Reads cumulative byte counters for every physical drive that answers.
392 ///
393 /// A drive that will not open, or whose IOCTL fails, is **skipped rather than
394 /// reported as zero**: `DISK_PERFORMANCE` counters can be turned off, and a confident
395 /// `0 B/s` for a disk that was never measured is worse than no line at all — the
396 /// `Users: 0` call (v0.6.1).
397 pub fn sample_physical_drives() -> Vec<IoCounters> {
398 (0..crate::disk::MAX_PHYSICAL_DRIVES)
399 .filter_map(query_drive_counters)
400 .collect()
401 }
402
403 /// Opens `\\.\PhysicalDrive{index}` with no access rights and queries its counters.
404 fn query_drive_counters(index: u32) -> Option<IoCounters> {
405 let path = format!(r"\\.\PhysicalDrive{index}");
406 let path_w: Vec<u16> = OsStr::new(&path).encode_wide().chain(Some(0)).collect();
407
408 // SAFETY: path_w is a valid null-terminated wide string. Zero desired access is
409 // sufficient for IOCTL_DISK_PERFORMANCE, which is FILE_ANY_ACCESS.
410 let handle = unsafe {
411 CreateFileW(
412 path_w.as_ptr(),
413 0,
414 FILE_SHARE_READ | FILE_SHARE_WRITE,
415 ptr::null_mut(),
416 OPEN_EXISTING,
417 0,
418 ptr::null_mut(),
419 )
420 };
421 if handle == INVALID_HANDLE_VALUE || handle.is_null() {
422 return None;
423 }
424
425 let mut perf = DiskPerformance::default();
426 let mut returned: u32 = 0;
427 // SAFETY: perf is a writable DiskPerformance passed with its own size; the IOCTL
428 // takes no input buffer.
429 let ok = unsafe {
430 DeviceIoControl(
431 handle,
432 IOCTL_DISK_PERFORMANCE,
433 ptr::null(),
434 0,
435 &mut perf as *mut _ as *mut c_void,
436 size_of::<DiskPerformance>() as u32,
437 &mut returned,
438 ptr::null_mut(),
439 )
440 };
441 // SAFETY: handle came from a successful CreateFileW and is closed exactly once.
442 unsafe {
443 CloseHandle(handle);
444 }
445
446 if ok == 0 || (returned as usize) < size_of::<DiskPerformance>() {
447 return None;
448 }
449 // Negative counters are not reachable from a working driver, but the field is a
450 // signed LARGE_INTEGER; clamp rather than wrap into an enormous u64.
451 Some(IoCounters {
452 device: physical_drive_name(index),
453 read: perf.bytes_read.max(0) as u64,
454 write: perf.bytes_written.max(0) as u64,
455 })
456 }
457
458 /// Reads cumulative per-interface byte counters via the shared `GetIfTable2`
459 /// enumeration, which has already excluded NDIS filter instances and loopback.
460 pub fn sample_interfaces() -> Vec<IoCounters> {
461 crate::win_iftable::interfaces()
462 .into_iter()
463 .map(|row| IoCounters {
464 device: row.name,
465 read: row.in_octets,
466 write: row.out_octets,
467 })
468 .collect()
469 }
470
471 #[cfg(test)]
472 mod layout {
473 use std::mem::{offset_of, size_of};
474
475 // These structs are read by fixed offset — MIB_IF_ROW2's counters sit past 1.2 KB
476 // of preceding fields, so a reorder or a padding change would silently read some
477 // other field's bytes as a byte count. The expected values were confirmed against
478 // live data before being pinned here: reading in/out_octets at these offsets
479 // reproduced `Get-NetAdapterStatistics`' per-adapter totals.
480 #[test]
481 fn ffi_struct_layout() {
482 assert_eq!(size_of::<super::DiskPerformance>(), 88);
483 assert_eq!(offset_of!(super::DiskPerformance, bytes_read), 0);
484 assert_eq!(offset_of!(super::DiskPerformance, bytes_written), 8);
485 }
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 /// Verbatim `/proc/diskstats` excerpt from a Fedora 44 host (corrino): one NVMe whole
494 /// disk, three of its partitions, and a zram device.
495 const DISKSTATS: &str = "\
496 259 0 nvme0n1 881658 6545 23259904 398678 110634 278 3327562 320368 0 53695 721779 6070 0 2002840 1798 2314 934
497 259 1 nvme0n1p1 338 1067 10262 173 2 0 2 0 0 24 173 0 0 0 0 0 0
498 259 2 nvme0n1p2 289 12 7954 45 22 17 288 6 0 42 51 0 0 0 0 0 0
499 259 3 nvme0n1p3 880938 5466 23238992 398446 110607 261 3327272 320361 0 65541 720606 6070 0 2002840 1798 0 0
500 251 0 zram0 46534 0 381008 231 252558 0 2769832 3263 0 5270 3494 0 0 0 0 0 0
501";
502
503 #[test]
504 fn test_parse_diskstats_reads_the_sector_columns() {
505 let parsed = parse_diskstats(DISKSTATS, |n| n == "nvme0n1");
506 assert_eq!(parsed.len(), 1);
507 // Columns 6 and 10 of the line, in 512-byte sectors.
508 assert_eq!(parsed[0].read, 23_259_904 * 512);
509 assert_eq!(parsed[0].write, 3_327_562 * 512);
510 }
511
512 #[test]
513 fn test_parse_diskstats_honors_the_injected_filter() {
514 // The filter is what keeps partitions out; without it their traffic is counted a
515 // second time against the same physical device.
516 let all = parse_diskstats(DISKSTATS, |_| true);
517 assert_eq!(all.len(), 5);
518 let whole = parse_diskstats(DISKSTATS, |n| !n.starts_with("zram") && !n.contains('p'));
519 assert_eq!(
520 whole.iter().map(|c| c.device.as_str()).collect::<Vec<_>>(),
521 vec!["nvme0n1"]
522 );
523 }
524
525 #[test]
526 fn test_parse_diskstats_skips_malformed_lines() {
527 let content = "259 0 nvme0n1 1 2\n259 0 sda 1 2 x 4 5 6 notanumber 8 9 10\n";
528 assert!(parse_diskstats(content, |_| true).is_empty());
529 }
530
531 #[test]
532 fn test_compute_rates_divides_the_delta_by_the_window() {
533 let before = vec![IoCounters {
534 device: "nvme0n1".into(),
535 read: 1_000,
536 write: 2_000,
537 }];
538 let after = vec![IoCounters {
539 device: "nvme0n1".into(),
540 read: 3_000,
541 write: 2_000,
542 }];
543 let rates = compute_rates(&before, &after, 0.5);
544 assert_eq!(rates.len(), 1);
545 assert_eq!(rates[0].read, 4_000.0);
546 assert_eq!(rates[0].write, 0.0);
547 }
548
549 #[test]
550 fn test_compute_rates_drops_devices_missing_from_either_sample() {
551 let before = vec![IoCounters {
552 device: "eth0".into(),
553 read: 10,
554 write: 10,
555 }];
556 let after = vec![
557 IoCounters {
558 device: "eth0".into(),
559 read: 20,
560 write: 10,
561 },
562 // Appeared mid-run: its lifetime counter is not a delta.
563 IoCounters {
564 device: "wt0".into(),
565 read: 9_999_999,
566 write: 9_999_999,
567 },
568 ];
569 let rates = compute_rates(&before, &after, 1.0);
570 assert_eq!(rates.len(), 1);
571 assert_eq!(rates[0].device, "eth0");
572 }
573
574 #[test]
575 fn test_compute_rates_clamps_a_counter_reset_to_zero() {
576 // An interface going down and up resets its counters; a wrapping subtraction here
577 // renders an exabyte-per-second reading from an ordinary event.
578 //
579 // The clamp reports 0, NOT the post-reset counter. A decrease says the baseline is
580 // void, not how many bytes flowed after it — and 1024 bytes into a window is a
581 // guess that is wrong whenever the decrease had some other cause. Under-reporting
582 // beats asserting something false, the same call as `Users: 0` (v0.6.1) and the
583 // ambiguous input devices (v0.7.0).
584 let before = vec![
585 IoCounters {
586 device: "wlan0".into(),
587 read: 5_000_000,
588 write: 5_000_000,
589 },
590 IoCounters {
591 device: "eth0".into(),
592 read: 1_000,
593 write: 1_000,
594 },
595 ];
596 let after = vec![
597 IoCounters {
598 device: "wlan0".into(),
599 read: 1_024,
600 write: 0,
601 },
602 IoCounters {
603 device: "eth0".into(),
604 read: 3_000,
605 write: 1_000,
606 },
607 ];
608 let rates = compute_rates(&before, &after, 1.0);
609 assert_eq!(rates[0].device, "wlan0");
610 assert_eq!(rates[0].read, 0.0);
611 assert_eq!(rates[0].write, 0.0);
612 // The unaffected device in the same pair must still report, so this test cannot
613 // pass by every rate happening to be zero.
614 assert_eq!(rates[1].device, "eth0");
615 assert_eq!(rates[1].read, 2_000.0);
616 }
617
618 #[test]
619 fn test_compute_rates_refuses_a_zero_or_negative_window() {
620 let sample = vec![IoCounters {
621 device: "nvme0n1".into(),
622 read: 1,
623 write: 1,
624 }];
625 assert!(compute_rates(&sample, &sample, 0.0).is_empty());
626 assert!(compute_rates(&sample, &sample, -1.0).is_empty());
627 assert!(compute_rates(&sample, &sample, f64::NAN).is_empty());
628 }
629
630 #[test]
631 fn test_format_rate_matches_the_net_field_units() {
632 assert_eq!(format_rate(0.0), "0 B/s");
633 assert_eq!(format_rate(512.0), "512 B/s");
634 assert_eq!(format_rate(1024.0), "1.0 KB/s");
635 assert_eq!(format_rate(1024.0 * 1024.0 * 1.5), "1.5 MB/s");
636 // Not reachable from compute_rates, but the formatter is public.
637 assert_eq!(format_rate(f64::NAN), "0 B/s");
638 assert_eq!(format_rate(-1.0), "0 B/s");
639 }
640
641 #[test]
642 fn test_format_io_line() {
643 let rate = IoRate {
644 device: "nvme0n1".into(),
645 read: 0.0,
646 write: 1024.0 * 308.0,
647 };
648 assert_eq!(
649 format_io_line(&rate, "R", "W"),
650 "nvme0n1 R: 0 B/s W: 308.0 KB/s"
651 );
652 }
653
654 #[test]
655 fn test_select_net_rates_prefers_the_active_interface() {
656 let rates = vec![
657 IoRate {
658 device: "wlp0s20f3".into(),
659 read: 100.0,
660 write: 50.0,
661 },
662 IoRate {
663 device: "wt0".into(),
664 read: 10.0,
665 write: 10.0,
666 },
667 ];
668 let selected = select_net_rates(rates, Some("wlp0s20f3"));
669 assert_eq!(selected.len(), 1);
670 assert_eq!(selected[0].device, "wlp0s20f3");
671 }
672
673 #[test]
674 fn test_select_net_rates_keeps_an_idle_active_interface() {
675 // 0 B/s on the interface you are actually using is a reading, not a miss.
676 let rates = vec![IoRate {
677 device: "eth0".into(),
678 read: 0.0,
679 write: 0.0,
680 }];
681 let selected = select_net_rates(rates, Some("eth0"));
682 assert_eq!(selected.len(), 1);
683 assert_eq!(selected[0].device, "eth0");
684 }
685
686 #[test]
687 fn test_physical_drive_name_matches_the_device_path() {
688 assert_eq!(physical_drive_name(0), "PhysicalDrive0");
689 assert_eq!(physical_drive_name(31), "PhysicalDrive31");
690 }
691
692 #[test]
693 fn test_select_net_rates_falls_back_to_busy_interfaces() {
694 let rates = vec![
695 IoRate {
696 device: "eth0".into(),
697 read: 0.0,
698 write: 0.0,
699 },
700 IoRate {
701 device: "wt0".into(),
702 read: 1.0,
703 write: 0.0,
704 },
705 ];
706 // Unknown active interface: report what moved, not everything.
707 let selected = select_net_rates(rates.clone(), None);
708 assert_eq!(selected.len(), 1);
709 assert_eq!(selected[0].device, "wt0");
710 // An active interface that is not in the list at all falls back the same way.
711 let selected = select_net_rates(rates, Some("ppp0"));
712 assert_eq!(selected.len(), 1);
713 assert_eq!(selected[0].device, "wt0");
714 }
715}