Skip to main content

iso_code/
util.rs

1//! Shared filesystem helpers: directory-size walk and filesystem-capacity probe.
2//!
3//! The .git-skipping, hardlink-deduped walk lives here so guards, Manager, and
4//! downstream crates (iso-code-mcp) all agree on what counts as "worktree bytes."
5
6use std::path::Path;
7
8/// Sum the real on-disk size of every file under `roots`, skipping any `.git/`
9/// subtree. Hardlinks are deduplicated per-walk by `(dev, ino)` on Unix.
10/// Silently ignores missing roots so callers don't have to pre-filter.
11pub fn dir_size_skipping_git<'a, I: IntoIterator<Item = &'a Path>>(roots: I) -> u64 {
12    let mut total: u64 = 0;
13    #[cfg(unix)]
14    let mut seen_inodes: std::collections::HashSet<(u64, u64)> =
15        std::collections::HashSet::new();
16
17    for root in roots {
18        if !root.exists() {
19            continue;
20        }
21        for entry in jwalk::WalkDir::new(root)
22            .process_read_dir(|_, _, _, children| {
23                children.retain(|child| {
24                    child
25                        .as_ref()
26                        .map(|e| e.file_name().to_string_lossy() != ".git")
27                        .unwrap_or(true)
28                });
29            })
30            .into_iter()
31            .flatten()
32        {
33            if let Ok(meta) = std::fs::metadata(entry.path()) {
34                if meta.is_file() {
35                    #[cfg(unix)]
36                    {
37                        use std::os::unix::fs::MetadataExt;
38                        if meta.nlink() > 1 && !seen_inodes.insert((meta.dev(), meta.ino())) {
39                            continue;
40                        }
41                    }
42                    total += filesize::file_real_size_fast(entry.path(), &meta)
43                        .unwrap_or(meta.len());
44                }
45            }
46        }
47    }
48    total
49}
50
51/// Return the total capacity in bytes of the filesystem hosting `path`.
52/// Picks the disk with the longest matching mount-point prefix. Returns
53/// `None` when the capacity cannot be determined (e.g. unsupported platform
54/// or path outside any known mount), so callers can skip the check.
55pub fn filesystem_capacity_bytes(path: &Path) -> Option<u64> {
56    use sysinfo::Disks;
57
58    let probe = if path.exists() {
59        path.to_path_buf()
60    } else {
61        path.parent().unwrap_or(Path::new("/")).to_path_buf()
62    };
63
64    let disks = Disks::new_with_refreshed_list();
65    let mut best: Option<(&sysinfo::Disk, usize)> = None;
66    for disk in disks.list() {
67        let mount = disk.mount_point();
68        if probe.starts_with(mount) {
69            let len = mount.as_os_str().len();
70            if best.map_or(true, |(_, cur)| len > cur) {
71                best = Some((disk, len));
72            }
73        }
74    }
75    best.map(|(d, _)| d.total_space())
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use tempfile::TempDir;
82
83    #[test]
84    fn dir_size_skips_git_dir() {
85        // Scope the check at the .git exclusion, not absolute byte counts —
86        // filesystems like APFS/ext4 round small-file block sizes up (a 5-byte
87        // file reports as 4KB on-disk), so we assert ordering rather than
88        // equality.
89        let dir = TempDir::new().unwrap();
90        let git = dir.path().join(".git");
91        std::fs::create_dir_all(&git).unwrap();
92        // A large blob under .git that would dominate the total if not skipped.
93        std::fs::write(git.join("HEAD"), vec![0u8; 100_000]).unwrap();
94        std::fs::write(dir.path().join("a.txt"), b"hello").unwrap();
95
96        let size = dir_size_skipping_git([dir.path()].iter().copied());
97        assert!(
98            size < 100_000,
99            "size {size} must exclude the 100KB blob under .git"
100        );
101        assert!(size > 0, "size must count a.txt");
102    }
103
104    #[test]
105    fn dir_size_missing_root_is_zero() {
106        let p = std::path::Path::new("/tmp/definitely-not-here-xyz-1234567890");
107        let size = dir_size_skipping_git([p].iter().copied());
108        assert_eq!(size, 0);
109    }
110
111    #[test]
112    fn filesystem_capacity_is_some_for_tmp() {
113        let cap = filesystem_capacity_bytes(std::env::temp_dir().as_path());
114        assert!(cap.is_some_and(|c| c > 0), "expected positive capacity");
115    }
116}