1use rayon::prelude::*;
2use serde::Serialize;
3use std::error::Error;
4use std::ffi::OsStr;
5use std::fs;
6use std::path::Path;
7
8mod ffi;
9pub mod projects;
10pub mod tui;
11
12pub use projects::{
13 analyze, clean, dir_size, scan, Project, ProjectAnalysis, ProjectType, ScanError, ScanOptions,
14};
15
16#[derive(Serialize, Clone)]
17pub struct DiskItem {
18 pub name: String,
19 pub disk_size: u64,
20 pub children: Option<Vec<DiskItem>>,
21}
22
23impl DiskItem {
24 pub fn from_analyze(
25 path: &Path,
26 apparent: bool,
27 root_dev: u64,
28 ) -> Result<Self, Box<dyn Error>> {
29 let name = path
30 .file_name()
31 .unwrap_or(&OsStr::new("."))
32 .to_string_lossy()
33 .to_string();
34
35 let file_info = FileInfo::from_path(path, apparent)?;
36
37 match file_info {
38 FileInfo::Directory { volume_id } => {
39 if volume_id != root_dev {
40 return Err("Filesystem boundary crossed".into());
41 }
42
43 let sub_entries = fs::read_dir(path)?
44 .filter_map(Result::ok)
45 .collect::<Vec<_>>();
46
47 let mut sub_items = sub_entries
48 .par_iter()
49 .filter_map(|entry| {
50 DiskItem::from_analyze(&entry.path(), apparent, root_dev).ok()
51 })
52 .collect::<Vec<_>>();
53
54 sub_items.sort_unstable_by(|a, b| a.disk_size.cmp(&b.disk_size).reverse());
55
56 Ok(DiskItem {
57 name,
58 disk_size: sub_items.iter().map(|di| di.disk_size).sum(),
59 children: Some(sub_items),
60 })
61 }
62 FileInfo::File { size, .. } => Ok(DiskItem {
63 name,
64 disk_size: size,
65 children: None,
66 }),
67 }
68 }
69
70 pub fn from_shallow_scan(
73 path: &Path,
74 apparent: bool,
75 root_dev: u64,
76 ) -> Result<Self, Box<dyn Error>> {
77 let name = path
78 .file_name()
79 .unwrap_or(&OsStr::new("."))
80 .to_string_lossy()
81 .to_string();
82
83 let file_info = FileInfo::from_path(path, apparent)?;
84
85 match file_info {
86 FileInfo::Directory { volume_id } => {
87 if volume_id != root_dev {
88 return Err("Filesystem boundary crossed".into());
89 }
90
91 let sub_entries = fs::read_dir(path)?
92 .filter_map(Result::ok)
93 .collect::<Vec<_>>();
94
95 let mut sub_items: Vec<DiskItem> = sub_entries
96 .par_iter()
97 .filter_map(|entry| {
98 DiskItem::from_analyze(&entry.path(), apparent, root_dev).ok()
99 })
100 .collect();
101
102 sub_items.sort_unstable_by(|a, b| a.disk_size.cmp(&b.disk_size).reverse());
103
104 let total: u64 = sub_items.iter().map(|di| di.disk_size).sum();
105
106 Ok(DiskItem {
107 name,
108 disk_size: total,
109 children: Some(sub_items),
110 })
111 }
112 FileInfo::File { size, .. } => Ok(DiskItem {
113 name,
114 disk_size: size,
115 children: None,
116 }),
117 }
118 }
119}
120
121pub enum FileInfo {
122 File { size: u64, volume_id: u64 },
123 Directory { volume_id: u64 },
124}
125
126impl FileInfo {
127 #[cfg(unix)]
128 pub fn from_path(path: &Path, apparent: bool) -> Result<Self, Box<dyn Error>> {
129 use std::os::unix::fs::MetadataExt;
130
131 let md = path.symlink_metadata()?;
132 if md.is_dir() {
133 Ok(FileInfo::Directory {
134 volume_id: md.dev(),
135 })
136 } else {
137 let size = if apparent {
138 md.len()
139 } else {
140 md.blocks() * 512
141 };
142 Ok(FileInfo::File {
143 size,
144 volume_id: md.dev(),
145 })
146 }
147 }
148
149 #[cfg(windows)]
150 pub fn from_path(path: &Path, apparent: bool) -> Result<Self, Box<dyn Error>> {
151 use winapi_util::{file, Handle};
152 const FILE_ATTRIBUTE_DIRECTORY: u64 = 0x10;
153
154 let h = Handle::from_path_any(path)?;
155 let md = file::information(h)?;
156
157 if md.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
158 Ok(FileInfo::Directory {
159 volume_id: md.volume_serial_number(),
160 })
161 } else {
162 let size = if apparent {
163 md.file_size()
164 } else {
165 ffi::compressed_size(path)?
166 };
167 Ok(FileInfo::File {
168 size,
169 volume_id: md.volume_serial_number(),
170 })
171 }
172 }
173}
174
175#[cfg(all(test, unix))]
176mod tests {
177 use super::FileInfo;
178 use std::error::Error;
179 use std::fs::{self, File};
180 use std::time::{SystemTime, UNIX_EPOCH};
181
182 #[test]
183 fn apparent_size_uses_logical_file_length() -> Result<(), Box<dyn Error>> {
184 let dir = std::env::temp_dir().join(format!(
185 "disk-cleaner-{}",
186 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
187 ));
188 fs::create_dir(&dir)?;
189 let path = dir.join("sparse.bin");
190 let file = File::create(&path)?;
191 let sparse_len = 1024 * 1024;
192 file.set_len(sparse_len)?;
193 drop(file);
194
195 let apparent_size = match FileInfo::from_path(&path, true)? {
196 FileInfo::File { size, .. } => size,
197 FileInfo::Directory { .. } => panic!("test path should be a file"),
198 };
199 let disk_size = match FileInfo::from_path(&path, false)? {
200 FileInfo::File { size, .. } => size,
201 FileInfo::Directory { .. } => panic!("test path should be a file"),
202 };
203
204 fs::remove_file(&path)?;
205 fs::remove_dir(&dir)?;
206
207 assert_eq!(apparent_size, sparse_len);
208 assert!(disk_size <= apparent_size);
209 Ok(())
210 }
211}