Skip to main content

disk_cleaner/
lib.rs

1use rayon::prelude::*;
2use serde::Serialize;
3use std::error::Error;
4use std::ffi::OsStr;
5use std::fs;
6use std::path::Path;
7use std::sync::Arc;
8
9mod ffi;
10pub mod projects;
11pub mod tui;
12
13pub use projects::{
14    analyze, clean, dir_size, scan, Project, ProjectAnalysis, ProjectType, ScanError, ScanOptions,
15};
16
17#[derive(Serialize, Clone)]
18pub struct DiskItem {
19    pub name: String,
20    pub disk_size: u64,
21    pub children: Option<Vec<DiskItem>>,
22}
23
24impl DiskItem {
25    pub fn from_analyze(
26        path: &Path,
27        apparent: bool,
28        root_dev: u64,
29    ) -> Result<Self, Box<dyn Error>> {
30        // One shared hardlink-dedup set spans the whole tree, so a file linked
31        // under multiple paths (Cargo's target/ does this for every build
32        // script and binary) is counted once — matching `du`/`df`, not once per
33        // link. Without this the tree's per-directory and total sizes overshoot
34        // what is actually on disk.
35        let counter = Arc::new(projects::FileSizeCounter::new(apparent));
36        Self::from_analyze_inner(path, root_dev, &counter)
37    }
38
39    fn from_analyze_inner(
40        path: &Path,
41        root_dev: u64,
42        counter: &Arc<projects::FileSizeCounter>,
43    ) -> Result<Self, Box<dyn Error>> {
44        let name = path
45            .file_name()
46            .unwrap_or(OsStr::new("."))
47            .to_string_lossy()
48            .to_string();
49
50        let md = path.symlink_metadata()?;
51        if md.is_dir() {
52            let volume_id = volume_id_of(&md, path)?;
53            if volume_id != root_dev {
54                return Err("Filesystem boundary crossed".into());
55            }
56
57            let sub_entries = fs::read_dir(path)?
58                .filter_map(Result::ok)
59                .collect::<Vec<_>>();
60
61            let mut sub_items = sub_entries
62                .par_iter()
63                .filter_map(|entry| {
64                    DiskItem::from_analyze_inner(&entry.path(), root_dev, counter).ok()
65                })
66                .collect::<Vec<_>>();
67
68            sub_items.sort_unstable_by(|a, b| a.disk_size.cmp(&b.disk_size).reverse());
69
70            Ok(DiskItem {
71                name,
72                disk_size: sub_items.iter().map(|di| di.disk_size).sum(),
73                children: Some(sub_items),
74            })
75        } else {
76            // Files: count through the shared counter so hardlinks are deduped.
77            let size = counter.add(&md, path);
78            Ok(DiskItem {
79                name,
80                disk_size: size,
81                children: None,
82            })
83        }
84    }
85
86    /// Build a fully-recursive `DiskItem` tree for `path`.
87    ///
88    /// Despite the historical name, this is *not* a shallow scan: it performs a
89    /// complete recursive analysis of the entire subtree, identical to
90    /// [`DiskItem::from_analyze`]. The separate entry point is kept only so the
91    /// TUI's `do_shallow_scan` call site reads naturally. New code should call
92    /// [`DiskItem::from_analyze`] directly.
93    pub fn from_shallow_scan(
94        path: &Path,
95        apparent: bool,
96        root_dev: u64,
97    ) -> Result<Self, Box<dyn Error>> {
98        Self::from_analyze(path, apparent, root_dev)
99    }
100}
101
102/// The device/volume id of `md`'s filesystem, used to detect filesystem
103/// boundaries during a recursive walk. On Unix this is `st_dev` from the same
104/// `symlink_metadata` call [`DiskItem::from_analyze`] already made; on Windows
105/// the volume serial number requires reopening the path, so it is fetched only
106/// for directories (files don't need a boundary check).
107fn volume_id_of(md: &fs::Metadata, path: &Path) -> Result<u64, Box<dyn Error>> {
108    #[cfg(unix)]
109    {
110        use std::os::unix::fs::MetadataExt;
111        let _ = path;
112        Ok(md.dev())
113    }
114    #[cfg(windows)]
115    {
116        use winapi_util::{file, Handle};
117        let _ = md;
118        let info = file::information(Handle::from_path_any(path)?)?;
119        Ok(info.volume_serial_number())
120    }
121    #[cfg(not(any(unix, windows)))]
122    {
123        let _ = (md, path);
124        Ok(0)
125    }
126}
127
128pub enum FileInfo {
129    File { size: u64, volume_id: u64 },
130    Directory { volume_id: u64 },
131}
132
133impl FileInfo {
134    #[cfg(unix)]
135    pub fn from_path(path: &Path, apparent: bool) -> Result<Self, Box<dyn Error>> {
136        use std::os::unix::fs::MetadataExt;
137
138        let md = path.symlink_metadata()?;
139        if md.is_dir() {
140            Ok(FileInfo::Directory {
141                volume_id: md.dev(),
142            })
143        } else {
144            let size = if apparent {
145                md.len()
146            } else {
147                md.blocks() * 512
148            };
149            Ok(FileInfo::File {
150                size,
151                volume_id: md.dev(),
152            })
153        }
154    }
155
156    #[cfg(windows)]
157    pub fn from_path(path: &Path, apparent: bool) -> Result<Self, Box<dyn Error>> {
158        use winapi_util::{file, Handle};
159        const FILE_ATTRIBUTE_DIRECTORY: u64 = 0x10;
160
161        let h = Handle::from_path_any(path)?;
162        let md = file::information(h)?;
163
164        if md.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
165            Ok(FileInfo::Directory {
166                volume_id: md.volume_serial_number(),
167            })
168        } else {
169            let size = if apparent {
170                md.file_size()
171            } else {
172                ffi::compressed_size(path)?
173            };
174            Ok(FileInfo::File {
175                size,
176                volume_id: md.volume_serial_number(),
177            })
178        }
179    }
180}
181
182#[cfg(all(test, unix))]
183mod tests {
184    use super::{FileInfo, DiskItem};
185    use std::error::Error;
186    use std::fs::{self, File};
187    use std::os::unix::fs::MetadataExt;
188    use std::time::{SystemTime, UNIX_EPOCH};
189
190    #[test]
191    fn apparent_size_uses_logical_file_length() -> Result<(), Box<dyn Error>> {
192        let dir = std::env::temp_dir().join(format!(
193            "disk-cleaner-{}",
194            SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
195        ));
196        fs::create_dir(&dir)?;
197        let path = dir.join("sparse.bin");
198        let file = File::create(&path)?;
199        let sparse_len = 1024 * 1024;
200        file.set_len(sparse_len)?;
201        drop(file);
202
203        let apparent_size = match FileInfo::from_path(&path, true)? {
204            FileInfo::File { size, .. } => size,
205            FileInfo::Directory { .. } => panic!("test path should be a file"),
206        };
207        let disk_size = match FileInfo::from_path(&path, false)? {
208            FileInfo::File { size, .. } => size,
209            FileInfo::Directory { .. } => panic!("test path should be a file"),
210        };
211
212        fs::remove_file(&path)?;
213        fs::remove_dir(&dir)?;
214
215        assert_eq!(apparent_size, sparse_len);
216        assert!(disk_size <= apparent_size);
217        Ok(())
218    }
219
220    /// The `du`-style tree must count a hardlinked file once per inode, not once
221    /// per directory entry. Without this the tree (and the TUI disk view built
222    /// from it) reports inflated sizes for Cargo `target/` directories, where
223    /// every build script and binary is hardlinked under two paths.
224    #[test]
225    fn disk_item_dedups_hardlinked_files() -> Result<(), Box<dyn Error>> {
226        let dir = std::env::temp_dir().join(format!(
227            "disk-cleaner-{}",
228            SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
229        ));
230        fs::create_dir(&dir)?;
231
232        // A 1 MiB file, then a second name (hardlink) pointing at the same inode.
233        let primary = dir.join("a.bin");
234        let linked = dir.join("b.bin");
235        let f = File::create(&primary)?;
236        f.set_len(1024 * 1024)?;
237        drop(f);
238        fs::hard_link(&primary, &linked)?;
239
240        // Sanity: one inode, two links.
241        assert_eq!(fs::metadata(&primary)?.nlink(), 2);
242
243        let dev = fs::metadata(&dir)?.dev();
244        let item = DiskItem::from_analyze(&dir, true, dev)?;
245
246        // Counted once: 1 MiB apparent, not 2 MiB.
247        assert_eq!(item.disk_size, 1024 * 1024);
248
249        fs::remove_dir_all(&dir)?;
250        Ok(())
251    }
252}