#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IoCounters {
pub device: String,
pub read: u64,
pub write: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IoRate {
pub device: String,
pub read: f64,
pub write: f64,
}
const DISKSTATS_SECTOR_BYTES: u64 = 512;
const DISKSTATS_SECTORS_READ: usize = 5;
const DISKSTATS_SECTORS_WRITTEN: usize = 9;
pub fn parse_diskstats<F>(content: &str, keep: F) -> Vec<IoCounters>
where
F: Fn(&str) -> bool,
{
let mut out = Vec::new();
for line in content.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() <= DISKSTATS_SECTORS_WRITTEN {
continue;
}
let name = fields[2];
if !keep(name) {
continue;
}
let (Ok(read_sectors), Ok(written_sectors)) = (
fields[DISKSTATS_SECTORS_READ].parse::<u64>(),
fields[DISKSTATS_SECTORS_WRITTEN].parse::<u64>(),
) else {
continue;
};
out.push(IoCounters {
device: name.to_string(),
read: read_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
write: written_sectors.saturating_mul(DISKSTATS_SECTOR_BYTES),
});
}
out
}
pub fn compute_rates(
before: &[IoCounters],
after: &[IoCounters],
elapsed_secs: f64,
) -> Vec<IoRate> {
if !elapsed_secs.is_finite() || elapsed_secs <= 0.0 {
return Vec::new();
}
after
.iter()
.filter_map(|now| {
let prev = before.iter().find(|p| p.device == now.device)?;
Some(IoRate {
device: now.device.clone(),
read: now.read.saturating_sub(prev.read) as f64 / elapsed_secs,
write: now.write.saturating_sub(prev.write) as f64 / elapsed_secs,
})
})
.collect()
}
pub fn format_rate(bytes_per_sec: f64) -> String {
let clamped = if bytes_per_sec.is_finite() && bytes_per_sec > 0.0 {
bytes_per_sec.round() as u64
} else {
0
};
format!("{}/s", crate::network::format_bytes(clamped))
}
pub fn format_io_line(rate: &IoRate, read_label: &str, write_label: &str) -> String {
format!(
"{} {}: {} {}: {}",
rate.device,
read_label,
format_rate(rate.read),
write_label,
format_rate(rate.write)
)
}
pub fn select_net_rates(rates: Vec<IoRate>, active: Option<&str>) -> Vec<IoRate> {
if let Some(active) = active {
let selected: Vec<IoRate> = rates
.iter()
.filter(|r| r.device == active)
.cloned()
.collect();
if !selected.is_empty() {
return selected;
}
}
rates
.into_iter()
.filter(|r| r.read > 0.0 || r.write > 0.0)
.collect()
}
pub fn sample_disk_io() -> Vec<IoCounters> {
#[cfg(target_os = "linux")]
{
let Ok(content) = std::fs::read_to_string("/proc/diskstats") else {
return Vec::new();
};
parse_diskstats(&content, is_physical_disk)
}
#[cfg(not(target_os = "linux"))]
{
Vec::new()
}
}
#[cfg(target_os = "linux")]
fn is_physical_disk(name: &str) -> bool {
if crate::disk::is_virtual_block_name(name) {
return false;
}
let dev = std::path::Path::new("/sys/class/block").join(name);
!dev.join("partition").exists() && dev.join("queue").exists()
}
pub fn sample_net_io() -> Vec<IoCounters> {
#[cfg(target_os = "linux")]
{
let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name == "lo" || name.starts_with("lo:") {
continue;
}
let stats = entry.path().join("statistics");
let read = read_counter(&stats.join("rx_bytes"));
let write = read_counter(&stats.join("tx_bytes"));
if let (Some(read), Some(write)) = (read, write) {
out.push(IoCounters {
device: name,
read,
write,
});
}
}
out.sort_by(|a, b| a.device.cmp(&b.device));
out
}
#[cfg(not(target_os = "linux"))]
{
Vec::new()
}
}
#[cfg(target_os = "linux")]
fn read_counter(path: &std::path::Path) -> Option<u64> {
std::fs::read_to_string(path)
.ok()?
.trim()
.parse::<u64>()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
const DISKSTATS: &str = "\
259 0 nvme0n1 881658 6545 23259904 398678 110634 278 3327562 320368 0 53695 721779 6070 0 2002840 1798 2314 934
259 1 nvme0n1p1 338 1067 10262 173 2 0 2 0 0 24 173 0 0 0 0 0 0
259 2 nvme0n1p2 289 12 7954 45 22 17 288 6 0 42 51 0 0 0 0 0 0
259 3 nvme0n1p3 880938 5466 23238992 398446 110607 261 3327272 320361 0 65541 720606 6070 0 2002840 1798 0 0
251 0 zram0 46534 0 381008 231 252558 0 2769832 3263 0 5270 3494 0 0 0 0 0 0
";
#[test]
fn test_parse_diskstats_reads_the_sector_columns() {
let parsed = parse_diskstats(DISKSTATS, |n| n == "nvme0n1");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].read, 23_259_904 * 512);
assert_eq!(parsed[0].write, 3_327_562 * 512);
}
#[test]
fn test_parse_diskstats_honors_the_injected_filter() {
let all = parse_diskstats(DISKSTATS, |_| true);
assert_eq!(all.len(), 5);
let whole = parse_diskstats(DISKSTATS, |n| !n.starts_with("zram") && !n.contains('p'));
assert_eq!(
whole.iter().map(|c| c.device.as_str()).collect::<Vec<_>>(),
vec!["nvme0n1"]
);
}
#[test]
fn test_parse_diskstats_skips_malformed_lines() {
let content = "259 0 nvme0n1 1 2\n259 0 sda 1 2 x 4 5 6 notanumber 8 9 10\n";
assert!(parse_diskstats(content, |_| true).is_empty());
}
#[test]
fn test_compute_rates_divides_the_delta_by_the_window() {
let before = vec![IoCounters {
device: "nvme0n1".into(),
read: 1_000,
write: 2_000,
}];
let after = vec![IoCounters {
device: "nvme0n1".into(),
read: 3_000,
write: 2_000,
}];
let rates = compute_rates(&before, &after, 0.5);
assert_eq!(rates.len(), 1);
assert_eq!(rates[0].read, 4_000.0);
assert_eq!(rates[0].write, 0.0);
}
#[test]
fn test_compute_rates_drops_devices_missing_from_either_sample() {
let before = vec![IoCounters {
device: "eth0".into(),
read: 10,
write: 10,
}];
let after = vec![
IoCounters {
device: "eth0".into(),
read: 20,
write: 10,
},
IoCounters {
device: "wt0".into(),
read: 9_999_999,
write: 9_999_999,
},
];
let rates = compute_rates(&before, &after, 1.0);
assert_eq!(rates.len(), 1);
assert_eq!(rates[0].device, "eth0");
}
#[test]
fn test_compute_rates_clamps_a_counter_reset_to_zero() {
let before = vec![
IoCounters {
device: "wlan0".into(),
read: 5_000_000,
write: 5_000_000,
},
IoCounters {
device: "eth0".into(),
read: 1_000,
write: 1_000,
},
];
let after = vec![
IoCounters {
device: "wlan0".into(),
read: 1_024,
write: 0,
},
IoCounters {
device: "eth0".into(),
read: 3_000,
write: 1_000,
},
];
let rates = compute_rates(&before, &after, 1.0);
assert_eq!(rates[0].device, "wlan0");
assert_eq!(rates[0].read, 0.0);
assert_eq!(rates[0].write, 0.0);
assert_eq!(rates[1].device, "eth0");
assert_eq!(rates[1].read, 2_000.0);
}
#[test]
fn test_compute_rates_refuses_a_zero_or_negative_window() {
let sample = vec![IoCounters {
device: "nvme0n1".into(),
read: 1,
write: 1,
}];
assert!(compute_rates(&sample, &sample, 0.0).is_empty());
assert!(compute_rates(&sample, &sample, -1.0).is_empty());
assert!(compute_rates(&sample, &sample, f64::NAN).is_empty());
}
#[test]
fn test_format_rate_matches_the_net_field_units() {
assert_eq!(format_rate(0.0), "0 B/s");
assert_eq!(format_rate(512.0), "512 B/s");
assert_eq!(format_rate(1024.0), "1.0 KB/s");
assert_eq!(format_rate(1024.0 * 1024.0 * 1.5), "1.5 MB/s");
assert_eq!(format_rate(f64::NAN), "0 B/s");
assert_eq!(format_rate(-1.0), "0 B/s");
}
#[test]
fn test_format_io_line() {
let rate = IoRate {
device: "nvme0n1".into(),
read: 0.0,
write: 1024.0 * 308.0,
};
assert_eq!(
format_io_line(&rate, "R", "W"),
"nvme0n1 R: 0 B/s W: 308.0 KB/s"
);
}
#[test]
fn test_select_net_rates_prefers_the_active_interface() {
let rates = vec![
IoRate {
device: "wlp0s20f3".into(),
read: 100.0,
write: 50.0,
},
IoRate {
device: "wt0".into(),
read: 10.0,
write: 10.0,
},
];
let selected = select_net_rates(rates, Some("wlp0s20f3"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].device, "wlp0s20f3");
}
#[test]
fn test_select_net_rates_keeps_an_idle_active_interface() {
let rates = vec![IoRate {
device: "eth0".into(),
read: 0.0,
write: 0.0,
}];
let selected = select_net_rates(rates, Some("eth0"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].device, "eth0");
}
#[test]
fn test_select_net_rates_falls_back_to_busy_interfaces() {
let rates = vec![
IoRate {
device: "eth0".into(),
read: 0.0,
write: 0.0,
},
IoRate {
device: "wt0".into(),
read: 1.0,
write: 0.0,
},
];
let selected = select_net_rates(rates.clone(), None);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].device, "wt0");
let selected = select_net_rates(rates, Some("ppp0"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].device, "wt0");
}
}