use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Default, Clone)]
pub struct IokitDeviceStats {
#[allow(dead_code)]
pub bsd_name: String,
pub bytes_read: u64,
pub bytes_written: u64,
pub ops_read: u64,
pub ops_written: u64,
pub total_time_read_ns: u64,
pub total_time_write_ns: u64,
}
pub fn collect() -> HashMap<String, IokitDeviceStats> {
let Ok(out) = Command::new("ioreg")
.args(["-c", "IOBlockStorageDriver", "-r", "-l", "-w", "0"])
.output()
else {
return HashMap::new();
};
if !out.status.success() {
return HashMap::new();
}
let text = String::from_utf8_lossy(&out.stdout);
parse_ioreg(&text)
}
pub(crate) fn parse_ioreg(text: &str) -> HashMap<String, IokitDeviceStats> {
let mut out = HashMap::new();
let mut pending_stats: Option<Stats> = None;
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
if line_is_node(line, "IOBlockStorageDriver") {
pending_stats = read_property_block(&mut lines).and_then(|props| {
props
.iter()
.find(|(k, _)| k == "Statistics")
.map(|(_, v)| parse_statistics(v))
});
continue;
}
if line_is_node(line, "IOMedia") {
let Some(props) = read_property_block(&mut lines) else {
continue;
};
let bsd = props
.iter()
.find(|(k, _)| k == "BSD Name")
.map(|(_, v)| strip_quotes(v).to_string());
let whole = props
.iter()
.find(|(k, _)| k == "Whole")
.map(|(_, v)| v == "Yes")
.unwrap_or(false);
if let (Some(name), true, Some(s)) = (bsd, whole, pending_stats.take()) {
out.insert(
name.clone(),
IokitDeviceStats {
bsd_name: name,
bytes_read: s.bytes_read,
bytes_written: s.bytes_written,
ops_read: s.ops_read,
ops_written: s.ops_written,
total_time_read_ns: s.total_time_read_ns,
total_time_write_ns: s.total_time_write_ns,
},
);
}
}
}
out
}
#[derive(Default, Clone, Copy)]
struct Stats {
bytes_read: u64,
bytes_written: u64,
ops_read: u64,
ops_written: u64,
total_time_read_ns: u64,
total_time_write_ns: u64,
}
fn line_is_node(line: &str, class_name: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("+-o ") && trimmed.contains(&format!("<class {}", class_name))
}
fn read_property_block<'a>(
lines: &mut std::iter::Peekable<impl Iterator<Item = &'a str>>,
) -> Option<Vec<(String, String)>> {
loop {
let peek = lines.peek()?;
let trimmed = peek.trim();
if trimmed.ends_with('{') || trimmed == "{" {
lines.next();
break;
}
if trimmed.starts_with("+-o ") {
return None;
}
lines.next();
}
let mut out = Vec::new();
for line in lines.by_ref() {
let trimmed = line.trim().trim_start_matches('|').trim();
if trimmed == "}" {
break;
}
if let Some((k, v)) = parse_property(trimmed) {
out.push((k, v));
}
}
Some(out)
}
fn parse_property(line: &str) -> Option<(String, String)> {
let eq = line.find(" = ")?;
let key_raw = line[..eq].trim();
let val_raw = line[eq + 3..].trim();
Some((strip_quotes(key_raw).to_string(), val_raw.to_string()))
}
fn strip_quotes(s: &str) -> &str {
let t = s.trim();
if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
&t[1..t.len() - 1]
} else {
t
}
}
fn parse_statistics(raw: &str) -> Stats {
let mut s = Stats::default();
let inner = raw.trim_start_matches('{').trim_end_matches('}');
for pair in inner.split(',') {
let Some(eq) = pair.find('=') else { continue };
let k = strip_quotes(pair[..eq].trim());
let v: u64 = pair[eq + 1..].trim().parse().unwrap_or(0);
match k {
"Bytes (Read)" => s.bytes_read = v,
"Bytes (Write)" => s.bytes_written = v,
"Operations (Read)" => s.ops_read = v,
"Operations (Write)" => s.ops_written = v,
"Total Time (Read)" => s.total_time_read_ns = v,
"Total Time (Write)" => s.total_time_write_ns = v,
_ => {}
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"+-o IOBlockStorageDriver <class IOBlockStorageDriver, id 0x100000663, registered, matched, active, busy 0 (117 ms), retain 8>
| {
| "IOClass" = "IOBlockStorageDriver"
| "Statistics" = {"Operations (Write)"=13964680,"Latency Time (Write)"=0,"Bytes (Read)"=368261685248,"Errors (Write)"=0,"Total Time (Read)"=3698579261481,"Latency Time (Read)"=0,"Retries (Read)"=0,"Errors (Read)"=0,"Total Time (Write)"=1213255816800,"Bytes (Write)"=222826323968,"Operations (Read)"=12975224,"Retries (Write)"=0}
| }
|
+-o APPLE SSD AP1024Z Media <class IOMedia, id 0x100000791, registered, matched, active, busy 0 (117 ms), retain 12>
| {
| "Content" = "GUID_partition_scheme"
| "Whole" = Yes
| "BSD Name" = "disk0"
| "Size" = 1000555581440
| }
|
+-o IOMediaBSDClient <class IOMediaBSDClient, id 0x1000007ca, registered, matched, active, busy 0 (0 ms), retain 6>
"#;
#[test]
fn parses_one_driver_with_media() {
let m = parse_ioreg(SAMPLE);
let stats = m.get("disk0").expect("disk0 should be present");
assert_eq!(stats.bytes_read, 368_261_685_248);
assert_eq!(stats.bytes_written, 222_826_323_968);
assert_eq!(stats.ops_read, 12_975_224);
assert_eq!(stats.ops_written, 13_964_680);
assert_eq!(stats.total_time_read_ns, 3_698_579_261_481);
assert_eq!(stats.total_time_write_ns, 1_213_255_816_800);
}
#[test]
fn skips_partition_media() {
let text = r#"+-o IOBlockStorageDriver <class IOBlockStorageDriver, id 0x1, ...>
| {
| "Statistics" = {"Bytes (Read)"=10,"Bytes (Write)"=20,"Operations (Read)"=1,"Operations (Write)"=2,"Total Time (Read)"=0,"Total Time (Write)"=0}
| }
+-o foo Media <class IOMedia, id 0x2, ...>
| {
| "Whole" = No
| "BSD Name" = "disk0s1"
| }
"#;
let m = parse_ioreg(text);
assert!(m.is_empty());
}
}