1use std::{
2 borrow::Cow,
3 error::{self, Error},
4 fs,
5 path::{self, Path},
6 time::SystemTime,
7};
8
9const FILE_CARGO_TOML: &str = "Cargo.toml";
10const FILE_PACKAGE_JSON: &str = "package.json";
11const FILE_ASSEMBLY_CSHARP: &str = "Assembly-CSharp.csproj";
12const FILE_STACK_HASKELL: &str = "stack.yaml";
13const FILE_SBT_BUILD: &str = "build.sbt";
14const FILE_MVN_BUILD: &str = "pom.xml";
15const FILE_BUILD_GRADLE: &str = "build.gradle";
16const FILE_BUILD_GRADLE_KTS: &str = "build.gradle.kts";
17const FILE_CMAKE_BUILD: &str = "CMakeLists.txt";
18const FILE_UNREAL_SUFFIX: &str = ".uproject";
19const FILE_JUPYTER_SUFFIX: &str = ".ipynb";
20const FILE_PYTHON_SUFFIX: &str = ".py";
21const FILE_COMPOSER_JSON: &str = "composer.json";
22const FILE_PUBSPEC_YAML: &str = "pubspec.yaml";
23const FILE_ELIXIR_MIX: &str = "mix.exs";
24const FILE_SWIFT_PACKAGE: &str = "Package.swift";
25const FILE_BUILD_ZIG: &str = "build.zig";
26const FILE_GODOT_4_PROJECT: &str = "project.godot";
27const FILE_CSPROJ_SUFFIX: &str = ".csproj";
28const FILE_FSPROJ_SUFFIX: &str = ".fsproj";
29
30const PROJECT_CARGO_DIRS: [&str; 2] = ["target", ".xwin-cache"];
31const PROJECT_NODE_DIRS: [&str; 2] = ["node_modules", ".angular"];
32const PROJECT_UNITY_DIRS: [&str; 7] = [
33 "Library",
34 "Temp",
35 "Obj",
36 "Logs",
37 "MemoryCaptures",
38 "Build",
39 "Builds",
40];
41const PROJECT_STACK_DIRS: [&str; 1] = [".stack-work"];
42const PROJECT_SBT_DIRS: [&str; 2] = ["target", "project/target"];
43const PROJECT_MVN_DIRS: [&str; 1] = ["target"];
44const PROJECT_GRADLE_DIRS: [&str; 2] = ["build", ".gradle"];
45const PROJECT_CMAKE_DIRS: [&str; 3] = ["build", "cmake-build-debug", "cmake-build-release"];
46const PROJECT_UNREAL_DIRS: [&str; 5] = [
47 "Binaries",
48 "Build",
49 "Saved",
50 "DerivedDataCache",
51 "Intermediate",
52];
53const PROJECT_JUPYTER_DIRS: [&str; 1] = [".ipynb_checkpoints"];
54const PROJECT_PYTHON_DIRS: [&str; 8] = [
55 ".mypy_cache",
56 ".nox",
57 ".pytest_cache",
58 ".ruff_cache",
59 ".tox",
60 ".venv",
61 "__pycache__",
62 "__pypackages__",
63];
64const PROJECT_COMPOSER_DIRS: [&str; 1] = ["vendor"];
65const PROJECT_PUB_DIRS: [&str; 4] = [
66 "build",
67 ".dart_tool",
68 "linux/flutter/ephemeral",
69 "windows/flutter/ephemeral",
70];
71const PROJECT_ELIXIR_DIRS: [&str; 4] = ["_build", ".elixir-tools", ".elixir_ls", ".lexical"];
72const PROJECT_SWIFT_DIRS: [&str; 2] = [".build", ".swiftpm"];
73const PROJECT_ZIG_DIRS: [&str; 1] = ["zig-cache"];
74const PROJECT_GODOT_4_DIRS: [&str; 1] = [".godot"];
75const PROJECT_DOTNET_DIRS: [&str; 2] = ["bin", "obj"];
76
77const PROJECT_CARGO_NAME: &str = "Cargo";
78const PROJECT_NODE_NAME: &str = "Node";
79const PROJECT_UNITY_NAME: &str = "Unity";
80const PROJECT_STACK_NAME: &str = "Stack";
81const PROJECT_SBT_NAME: &str = "SBT";
82const PROJECT_MVN_NAME: &str = "Maven";
83const PROJECT_GRADLE_NAME: &str = "Gradle";
84const PROJECT_CMAKE_NAME: &str = "CMake";
85const PROJECT_UNREAL_NAME: &str = "Unreal";
86const PROJECT_JUPYTER_NAME: &str = "Jupyter";
87const PROJECT_PYTHON_NAME: &str = "Python";
88const PROJECT_COMPOSER_NAME: &str = "Composer";
89const PROJECT_PUB_NAME: &str = "Pub";
90const PROJECT_ELIXIR_NAME: &str = "Elixir";
91const PROJECT_SWIFT_NAME: &str = "Swift";
92const PROJECT_ZIG_NAME: &str = "Zig";
93const PROJECT_GODOT_4_NAME: &str = "Godot 4.x";
94const PROJECT_DOTNET_NAME: &str = ".NET";
95
96#[derive(Debug, Clone)]
97pub enum ProjectType {
98 Cargo,
99 Node,
100 Unity,
101 Stack,
102 #[allow(clippy::upper_case_acronyms)]
103 SBT,
104 Maven,
105 Gradle,
106 CMake,
107 Unreal,
108 Jupyter,
109 Python,
110 Composer,
111 Pub,
112 Elixir,
113 Swift,
114 Zig,
115 Godot4,
116 Dotnet,
117}
118
119#[derive(Debug, Clone)]
120pub struct Project {
121 pub project_type: ProjectType,
122 pub path: path::PathBuf,
123}
124
125#[derive(Debug, Clone)]
126pub struct ProjectSize {
127 pub artifact_size: u64,
128 pub non_artifact_size: u64,
129 pub dirs: Vec<(String, u64, bool)>,
130}
131
132impl Project {
133 pub fn artifact_dirs(&self) -> &[&str] {
134 match self.project_type {
135 ProjectType::Cargo => &PROJECT_CARGO_DIRS,
136 ProjectType::Node => &PROJECT_NODE_DIRS,
137 ProjectType::Unity => &PROJECT_UNITY_DIRS,
138 ProjectType::Stack => &PROJECT_STACK_DIRS,
139 ProjectType::SBT => &PROJECT_SBT_DIRS,
140 ProjectType::Maven => &PROJECT_MVN_DIRS,
141 ProjectType::Unreal => &PROJECT_UNREAL_DIRS,
142 ProjectType::Jupyter => &PROJECT_JUPYTER_DIRS,
143 ProjectType::Python => &PROJECT_PYTHON_DIRS,
144 ProjectType::CMake => &PROJECT_CMAKE_DIRS,
145 ProjectType::Composer => &PROJECT_COMPOSER_DIRS,
146 ProjectType::Pub => &PROJECT_PUB_DIRS,
147 ProjectType::Elixir => &PROJECT_ELIXIR_DIRS,
148 ProjectType::Swift => &PROJECT_SWIFT_DIRS,
149 ProjectType::Gradle => &PROJECT_GRADLE_DIRS,
150 ProjectType::Zig => &PROJECT_ZIG_DIRS,
151 ProjectType::Godot4 => &PROJECT_GODOT_4_DIRS,
152 ProjectType::Dotnet => &PROJECT_DOTNET_DIRS,
153 }
154 }
155
156 pub fn name(&self) -> Cow<str> {
157 self.path.to_string_lossy()
158 }
159
160 pub fn size(&self, options: &ScanOptions) -> u64 {
161 self.artifact_dirs()
162 .iter()
163 .copied()
164 .map(|p| dir_size(&self.path.join(p), options))
165 .sum()
166 }
167
168 pub fn last_modified(&self, options: &ScanOptions) -> Result<SystemTime, std::io::Error> {
169 let top_level_modified = fs::metadata(&self.path)?.modified()?;
170 let most_recent_modified = ignore::WalkBuilder::new(&self.path)
171 .follow_links(options.follow_symlinks)
172 .same_file_system(options.same_file_system)
173 .build()
174 .fold(top_level_modified, |acc, e| {
175 if let Ok(e) = e {
176 if let Ok(e) = e.metadata() {
177 if let Ok(modified) = e.modified() {
178 if modified > acc {
179 return modified;
180 }
181 }
182 }
183 }
184 acc
185 });
186 Ok(most_recent_modified)
187 }
188
189 pub fn size_dirs(&self, options: &ScanOptions) -> ProjectSize {
190 let mut artifact_size = 0;
191 let mut non_artifact_size = 0;
192 let mut dirs = Vec::new();
193
194 let project_root = match fs::read_dir(&self.path) {
195 Err(_) => {
196 return ProjectSize {
197 artifact_size,
198 non_artifact_size,
199 dirs,
200 }
201 }
202 Ok(rd) => rd,
203 };
204
205 for entry in project_root.filter_map(|rd| rd.ok()) {
206 let file_type = match entry.file_type() {
207 Err(_) => continue,
208 Ok(file_type) => file_type,
209 };
210
211 if file_type.is_file() {
212 if let Ok(metadata) = entry.metadata() {
213 non_artifact_size += metadata.len();
214 }
215 continue;
216 }
217
218 if file_type.is_dir() {
219 let file_name = match entry.file_name().into_string() {
220 Err(_) => continue,
221 Ok(file_name) => file_name,
222 };
223 let size = dir_size(&entry.path(), options);
224 let artifact_dir = self.artifact_dirs().contains(&file_name.as_str());
225 if artifact_dir {
226 artifact_size += size;
227 } else {
228 non_artifact_size += size;
229 }
230 dirs.push((file_name, size, artifact_dir));
231 }
232 }
233
234 ProjectSize {
235 artifact_size,
236 non_artifact_size,
237 dirs,
238 }
239 }
240
241 pub fn type_name(&self) -> &'static str {
242 match self.project_type {
243 ProjectType::Cargo => PROJECT_CARGO_NAME,
244 ProjectType::Node => PROJECT_NODE_NAME,
245 ProjectType::Unity => PROJECT_UNITY_NAME,
246 ProjectType::Stack => PROJECT_STACK_NAME,
247 ProjectType::SBT => PROJECT_SBT_NAME,
248 ProjectType::Maven => PROJECT_MVN_NAME,
249 ProjectType::Unreal => PROJECT_UNREAL_NAME,
250 ProjectType::Jupyter => PROJECT_JUPYTER_NAME,
251 ProjectType::Python => PROJECT_PYTHON_NAME,
252 ProjectType::CMake => PROJECT_CMAKE_NAME,
253 ProjectType::Composer => PROJECT_COMPOSER_NAME,
254 ProjectType::Pub => PROJECT_PUB_NAME,
255 ProjectType::Elixir => PROJECT_ELIXIR_NAME,
256 ProjectType::Swift => PROJECT_SWIFT_NAME,
257 ProjectType::Gradle => PROJECT_GRADLE_NAME,
258 ProjectType::Zig => PROJECT_ZIG_NAME,
259 ProjectType::Godot4 => PROJECT_GODOT_4_NAME,
260 ProjectType::Dotnet => PROJECT_DOTNET_NAME,
261 }
262 }
263
264 pub fn clean(&self) {
266 for artifact_dir in self
267 .artifact_dirs()
268 .iter()
269 .copied()
270 .map(|ad| self.path.join(ad))
271 .filter(|ad| ad.exists())
272 {
273 if let Err(e) = fs::remove_dir_all(&artifact_dir) {
274 eprintln!("error removing directory {:?}: {:?}", artifact_dir, e);
275 }
276 }
277 }
278}
279
280pub fn print_elapsed(secs: u64) -> String {
281 const MINUTE: u64 = 60;
282 const HOUR: u64 = MINUTE * 60;
283 const DAY: u64 = HOUR * 24;
284 const WEEK: u64 = DAY * 7;
285 const MONTH: u64 = WEEK * 4;
286 const YEAR: u64 = DAY * 365;
287
288 let (unit, fstring) = match secs {
289 secs if secs < MINUTE => (secs as f64, "second"),
290 secs if secs < HOUR * 2 => (secs as f64 / MINUTE as f64, "minute"),
291 secs if secs < DAY * 2 => (secs as f64 / HOUR as f64, "hour"),
292 secs if secs < WEEK * 2 => (secs as f64 / DAY as f64, "day"),
293 secs if secs < MONTH * 2 => (secs as f64 / WEEK as f64, "week"),
294 secs if secs < YEAR * 2 => (secs as f64 / MONTH as f64, "month"),
295 secs => (secs as f64 / YEAR as f64, "year"),
296 };
297
298 let unit = unit.round();
299
300 let plural = if unit == 1.0 { "" } else { "s" };
301
302 format!("{unit:.0} {fstring}{plural} ago")
303}
304
305fn is_hidden(entry: &walkdir::DirEntry) -> bool {
306 entry.file_name().to_string_lossy().starts_with('.')
307}
308
309struct ProjectIter {
310 it: walkdir::IntoIter,
311}
312
313pub enum Red {
314 IOError(::std::io::Error),
315 WalkdirError(walkdir::Error),
316}
317
318impl Iterator for ProjectIter {
319 type Item = Result<Project, Red>;
320
321 fn next(&mut self) -> Option<Self::Item> {
322 loop {
323 let entry: walkdir::DirEntry = match self.it.next() {
324 None => return None,
325 Some(Err(e)) => return Some(Err(Red::WalkdirError(e))),
326 Some(Ok(entry)) => entry,
327 };
328 if !entry.file_type().is_dir() {
329 continue;
330 }
331 if is_hidden(&entry) {
332 self.it.skip_current_dir();
333 continue;
334 }
335 let rd = match entry.path().read_dir() {
336 Err(e) => return Some(Err(Red::IOError(e))),
337 Ok(rd) => rd,
338 };
339 for dir_entry in rd
342 .filter_map(|rd| rd.ok())
343 .filter(|de| de.file_type().map(|ft| ft.is_file()).unwrap_or(false))
344 .map(|de| de.file_name())
345 {
346 let file_name = match dir_entry.to_str() {
347 None => continue,
348 Some(file_name) => file_name,
349 };
350 let p_type = match file_name {
351 FILE_CARGO_TOML => Some(ProjectType::Cargo),
352 FILE_PACKAGE_JSON => Some(ProjectType::Node),
353 FILE_ASSEMBLY_CSHARP => Some(ProjectType::Unity),
354 FILE_STACK_HASKELL => Some(ProjectType::Stack),
355 FILE_SBT_BUILD => Some(ProjectType::SBT),
356 FILE_MVN_BUILD => Some(ProjectType::Maven),
357 FILE_CMAKE_BUILD => Some(ProjectType::CMake),
358 FILE_COMPOSER_JSON => Some(ProjectType::Composer),
359 FILE_PUBSPEC_YAML => Some(ProjectType::Pub),
360 FILE_ELIXIR_MIX => Some(ProjectType::Elixir),
361 FILE_SWIFT_PACKAGE => Some(ProjectType::Swift),
362 FILE_BUILD_GRADLE => Some(ProjectType::Gradle),
363 FILE_BUILD_GRADLE_KTS => Some(ProjectType::Gradle),
364 FILE_BUILD_ZIG => Some(ProjectType::Zig),
365 FILE_GODOT_4_PROJECT => Some(ProjectType::Godot4),
366 file_name if file_name.ends_with(FILE_UNREAL_SUFFIX) => {
367 Some(ProjectType::Unreal)
368 }
369 file_name if file_name.ends_with(FILE_JUPYTER_SUFFIX) => {
370 Some(ProjectType::Jupyter)
371 }
372 file_name if file_name.ends_with(FILE_PYTHON_SUFFIX) => {
373 Some(ProjectType::Python)
374 }
375 file_name
376 if file_name.ends_with(FILE_CSPROJ_SUFFIX)
377 || file_name.ends_with(FILE_FSPROJ_SUFFIX) =>
378 {
379 if dir_contains_file(entry.path(), FILE_GODOT_4_PROJECT) {
380 Some(ProjectType::Godot4)
381 } else if dir_contains_file(entry.path(), FILE_ASSEMBLY_CSHARP) {
382 Some(ProjectType::Unity)
383 } else {
384 Some(ProjectType::Dotnet)
385 }
386 }
387 _ => None,
388 };
389 if let Some(project_type) = p_type {
390 self.it.skip_current_dir();
391 return Some(Ok(Project {
392 project_type,
393 path: entry.path().to_path_buf(),
394 }));
395 }
396 }
397 }
398 }
399}
400
401fn dir_contains_file(path: &Path, file: &str) -> bool {
402 path.read_dir()
403 .map(|rd| {
404 rd.filter_map(|rd| rd.ok()).any(|de| {
405 de.file_type().is_ok_and(|t| t.is_file()) && de.file_name().to_str() == Some(file)
406 })
407 })
408 .unwrap_or(false)
409}
410
411#[derive(Clone, Debug)]
412pub struct ScanOptions {
413 pub follow_symlinks: bool,
414 pub same_file_system: bool,
415}
416
417fn build_walkdir_iter<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> ProjectIter {
418 ProjectIter {
419 it: walkdir::WalkDir::new(path)
420 .follow_links(options.follow_symlinks)
421 .same_file_system(options.same_file_system)
422 .into_iter(),
423 }
424}
425
426pub fn scan<P: AsRef<path::Path>>(
427 path: &P,
428 options: &ScanOptions,
429) -> impl Iterator<Item = Result<Project, Red>> {
430 build_walkdir_iter(path, options)
431}
432
433pub fn dir_size<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> u64 {
435 build_walkdir_iter(path, options)
436 .it
437 .filter_map(|e| e.ok())
438 .filter(|e| e.file_type().is_file())
439 .filter_map(|e| e.metadata().ok())
440 .map(|e| e.len())
441 .sum()
442}
443
444pub fn pretty_size(size: u64) -> String {
445 const KIBIBYTE: u64 = 1024;
446 const MEBIBYTE: u64 = 1_048_576;
447 const GIBIBYTE: u64 = 1_073_741_824;
448 const TEBIBYTE: u64 = 1_099_511_627_776;
449 const PEBIBYTE: u64 = 1_125_899_906_842_624;
450 const EXBIBYTE: u64 = 1_152_921_504_606_846_976;
451
452 let (size, symbol) = match size {
453 size if size < KIBIBYTE => (size as f64, "B"),
454 size if size < MEBIBYTE => (size as f64 / KIBIBYTE as f64, "KiB"),
455 size if size < GIBIBYTE => (size as f64 / MEBIBYTE as f64, "MiB"),
456 size if size < TEBIBYTE => (size as f64 / GIBIBYTE as f64, "GiB"),
457 size if size < PEBIBYTE => (size as f64 / TEBIBYTE as f64, "TiB"),
458 size if size < EXBIBYTE => (size as f64 / PEBIBYTE as f64, "PiB"),
459 _ => (size as f64 / EXBIBYTE as f64, "EiB"),
460 };
461
462 format!("{:.1}{}", size, symbol)
463}
464
465pub fn clean(project_path: &str) -> Result<(), Box<dyn error::Error>> {
466 let project = fs::read_dir(project_path)?
467 .filter_map(|rd| rd.ok())
468 .find_map(|dir_entry| {
469 let file_name = dir_entry.file_name().into_string().ok()?;
470 let p_type = match file_name.as_str() {
471 FILE_CARGO_TOML => Some(ProjectType::Cargo),
472 FILE_PACKAGE_JSON => Some(ProjectType::Node),
473 FILE_ASSEMBLY_CSHARP => Some(ProjectType::Unity),
474 FILE_STACK_HASKELL => Some(ProjectType::Stack),
475 FILE_SBT_BUILD => Some(ProjectType::SBT),
476 FILE_MVN_BUILD => Some(ProjectType::Maven),
477 FILE_CMAKE_BUILD => Some(ProjectType::CMake),
478 FILE_COMPOSER_JSON => Some(ProjectType::Composer),
479 FILE_PUBSPEC_YAML => Some(ProjectType::Pub),
480 FILE_ELIXIR_MIX => Some(ProjectType::Elixir),
481 FILE_SWIFT_PACKAGE => Some(ProjectType::Swift),
482 FILE_BUILD_ZIG => Some(ProjectType::Zig),
483 FILE_GODOT_4_PROJECT => Some(ProjectType::Godot4),
484 _ => None,
485 };
486 if let Some(project_type) = p_type {
487 return Some(Project {
488 project_type,
489 path: project_path.into(),
490 });
491 }
492 None
493 });
494
495 if let Some(project) = project {
496 for artifact_dir in project
497 .artifact_dirs()
498 .iter()
499 .copied()
500 .map(|ad| path::PathBuf::from(project_path).join(ad))
501 .filter(|ad| ad.exists())
502 {
503 if let Err(e) = fs::remove_dir_all(&artifact_dir) {
504 eprintln!("error removing directory {:?}: {:?}", artifact_dir, e);
505 }
506 }
507 }
508
509 Ok(())
510}
511pub fn path_canonicalise(
512 base: &path::Path,
513 tail: path::PathBuf,
514) -> Result<path::PathBuf, Box<dyn Error>> {
515 if tail.is_absolute() {
516 Ok(tail)
517 } else {
518 Ok(base.join(tail).canonicalize()?)
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::print_elapsed;
525
526 #[test]
527 fn elapsed() {
528 assert_eq!(print_elapsed(0), "0 seconds ago");
529 assert_eq!(print_elapsed(1), "1 second ago");
530 assert_eq!(print_elapsed(2), "2 seconds ago");
531 assert_eq!(print_elapsed(59), "59 seconds ago");
532 assert_eq!(print_elapsed(60), "1 minute ago");
533 assert_eq!(print_elapsed(61), "1 minute ago");
534 assert_eq!(print_elapsed(119), "2 minutes ago");
535 assert_eq!(print_elapsed(120), "2 minutes ago");
536 assert_eq!(print_elapsed(121), "2 minutes ago");
537 assert_eq!(print_elapsed(3599), "60 minutes ago");
538 assert_eq!(print_elapsed(3600), "60 minutes ago");
539 assert_eq!(print_elapsed(3601), "60 minutes ago");
540 assert_eq!(print_elapsed(7199), "120 minutes ago");
541 assert_eq!(print_elapsed(7200), "2 hours ago");
542 assert_eq!(print_elapsed(7201), "2 hours ago");
543 assert_eq!(print_elapsed(86399), "24 hours ago");
544 assert_eq!(print_elapsed(86400), "24 hours ago");
545 assert_eq!(print_elapsed(86401), "24 hours ago");
546 assert_eq!(print_elapsed(172799), "48 hours ago");
547 assert_eq!(print_elapsed(172800), "2 days ago");
548 assert_eq!(print_elapsed(172801), "2 days ago");
549 assert_eq!(print_elapsed(604799), "7 days ago");
550 assert_eq!(print_elapsed(604800), "7 days ago");
551 assert_eq!(print_elapsed(604801), "7 days ago");
552 assert_eq!(print_elapsed(1209599), "14 days ago");
553 assert_eq!(print_elapsed(1209600), "2 weeks ago");
554 assert_eq!(print_elapsed(1209601), "2 weeks ago");
555 assert_eq!(print_elapsed(2419199), "4 weeks ago");
556 assert_eq!(print_elapsed(2419200), "4 weeks ago");
557 assert_eq!(print_elapsed(2419201), "4 weeks ago");
558 assert_eq!(print_elapsed(2419200 * 2), "2 months ago");
559 assert_eq!(print_elapsed(2419200 * 3), "3 months ago");
560 assert_eq!(print_elapsed(2419200 * 12), "12 months ago");
561 assert_eq!(print_elapsed(2419200 * 25), "25 months ago");
562 assert_eq!(print_elapsed(2419200 * 48), "4 years ago");
563 }
564}