1use anyhow::{anyhow, Context, Result};
4use std::path::Path;
5use std::process::Command;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct DiskUsage {
10 pub total_bytes: u64,
11 pub free_bytes: u64,
12}
13
14impl DiskUsage {
15 pub fn percent_free(&self) -> u8 {
17 if self.total_bytes == 0 {
18 return 0;
19 }
20 ((self.free_bytes as u128 * 100) / self.total_bytes as u128) as u8
21 }
22}
23
24pub fn usage(path: &Path) -> Result<DiskUsage> {
27 let out = Command::new("df")
28 .arg("-Pk")
29 .arg(path)
30 .output()
31 .with_context(|| format!("running df on {}", path.display()))?;
32 if !out.status.success() {
33 return Err(anyhow!("df failed on {}", path.display()));
34 }
35 parse_df_pk(&String::from_utf8_lossy(&out.stdout))
36 .ok_or_else(|| anyhow!("could not parse df output for {}", path.display()))
37}
38
39fn parse_df_pk(text: &str) -> Option<DiskUsage> {
46 let line = text.lines().nth(1)?;
47 let cols: Vec<&str> = line.split_whitespace().collect();
48 let cap = cols.iter().position(|c| c.ends_with('%'))?;
50 if cap < 3 {
51 return None;
52 }
53 let blocks: u64 = cols[cap - 3].parse().ok()?;
54 let avail: u64 = cols[cap - 1].parse().ok()?;
55 Some(DiskUsage {
56 total_bytes: blocks.checked_mul(1024)?,
57 free_bytes: avail.checked_mul(1024)?,
58 })
59}
60
61pub fn is_low(u: DiskUsage, warn_percent: u8, warn_min_free_bytes: u64) -> bool {
65 (u.percent_free() as u32) < warn_percent as u32 && u.free_bytes < warn_min_free_bytes
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 const DF: &str = "Filesystem 1024-blocks Used Available Capacity Mounted on\n\
75 /dev/disk1s1 976490568 810000000 160000000 84% /\n";
76
77 #[test]
78 fn parses_df_pk_anchored_on_capacity() {
79 let u = parse_df_pk(DF).unwrap();
80 assert_eq!(u.total_bytes, 976_490_568 * 1024);
81 assert_eq!(u.free_bytes, 160_000_000 * 1024);
82 }
83
84 #[test]
85 fn parses_df_pk_with_spaces_in_mount_path() {
86 let df = "Filesystem 1024-blocks Used Available Capacity Mounted on\n\
89 /dev/disk3s1 976490568 810000000 160000000 84% /Volumes/My Big Disk\n";
90 let u = parse_df_pk(df).unwrap();
91 assert_eq!(u.total_bytes, 976_490_568 * 1024);
92 assert_eq!(u.free_bytes, 160_000_000 * 1024);
93 }
94
95 #[test]
96 fn percent_free_is_rounded_down() {
97 let u = DiskUsage {
98 total_bytes: 1000,
99 free_bytes: 165,
100 };
101 assert_eq!(u.percent_free(), 16);
102 }
103
104 #[test]
105 fn low_requires_both_proportional_and_absolute() {
106 let huge = DiskUsage {
108 total_bytes: 56_000_000_000_000,
109 free_bytes: 5_600_000_000_000,
110 };
111 assert!(!is_low(huge, 15, 10 * 1024 * 1024 * 1024));
112 let small = DiskUsage {
114 total_bytes: 20_000_000_000,
115 free_bytes: 1_000_000_000,
116 };
117 assert!(is_low(small, 15, 10 * 1024 * 1024 * 1024));
118 let tiny = DiskUsage {
120 total_bytes: 4_000_000_000,
121 free_bytes: 2_000_000_000,
122 };
123 assert!(!is_low(tiny, 15, 10 * 1024 * 1024 * 1024));
124 }
125
126 #[test]
127 fn garbage_df_output_is_none() {
128 assert!(parse_df_pk("nonsense\n").is_none());
129 assert!(parse_df_pk("").is_none());
130 }
131}