Skip to main content

git_ark/
disk.rs

1//! Filesystem free-space, via `df` — informational only (never blocks a backup).
2
3use anyhow::{anyhow, Context, Result};
4use std::path::Path;
5use std::process::Command;
6
7/// Free-space snapshot for the filesystem holding a path.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct DiskUsage {
10    pub total_bytes: u64,
11    pub free_bytes: u64,
12}
13
14impl DiskUsage {
15    /// Percent of the filesystem that is free, rounded down (0–100).
16    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
24/// Free space for the filesystem containing `path`, via `df -Pk` (POSIX,
25/// 1024-byte blocks). No new dependency — same shell-out pattern as `git`.
26pub 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
39/// Parse `df -Pk` output. `-P` guarantees one physical line per filesystem:
40/// `Filesystem 1024-blocks Used Available Capacity Mounted-on`. We anchor on the
41/// Capacity column — the single `NN%` token — because the Mounted-on column that
42/// follows it can contain spaces (e.g. `/Volumes/My Disk`), which a right-edge
43/// anchor would miscount. Everything after `%` is ignored; the numeric columns
44/// sit at fixed offsets before it. Non-numeric parses fall through to `None`.
45fn parse_df_pk(text: &str) -> Option<DiskUsage> {
46    let line = text.lines().nth(1)?;
47    let cols: Vec<&str> = line.split_whitespace().collect();
48    // Capacity is `NN%`; Available is the token before it, 1024-blocks three before.
49    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
61/// Is the filesystem low? Low = proportionally AND absolutely low, so a huge
62/// disk stays quiet until it is genuinely almost empty while a small one still
63/// trips. See the plan's Global Constraints.
64pub 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    // Real `df -Pk` output: header line, then one line per filesystem. Parsing
73    // anchors on the Capacity (`NN%`) column.
74    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        // A "Mounted on" column with spaces must not shift the numeric columns —
87        // the reproduced should-fix. Anchoring on the `%` token survives it.
88        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        // Huge disk, proportionally low but absolutely fine → NOT low.
107        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        // Small disk, both low → low.
113        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        // Absolutely low but proportionally fine (tiny disk, half free) → NOT low.
119        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}