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 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 let size = counter.add(&md, path);
78 Ok(DiskItem {
79 name,
80 disk_size: size,
81 children: None,
82 })
83 }
84 }
85
86 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
102fn 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 #[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 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 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 assert_eq!(item.disk_size, 1024 * 1024);
248
249 fs::remove_dir_all(&dir)?;
250 Ok(())
251 }
252}