1use std::{
34 borrow::Cow,
35 error,
36 fs,
37 path::{self, Path},
38 time::SystemTime,
39};
40
41const FILE_CARGO_TOML: &str = "Cargo.toml";
42const FILE_PACKAGE_JSON: &str = "package.json";
43const FILE_ASSEMBLY_CSHARP: &str = "Assembly-CSharp.csproj";
44const FILE_STACK_HASKELL: &str = "stack.yaml";
45const FILE_CABAL_HASKELL: &str = "cabal.project";
46const FILE_SBT_BUILD: &str = "build.sbt";
47const FILE_MVN_BUILD: &str = "pom.xml";
48const FILE_BUILD_GRADLE: &str = "build.gradle";
49const FILE_BUILD_GRADLE_KTS: &str = "build.gradle.kts";
50const FILE_CMAKE_BUILD: &str = "CMakeLists.txt";
51const FILE_UNREAL_SUFFIX: &str = ".uproject";
52const FILE_JUPYTER_SUFFIX: &str = ".ipynb";
53const FILE_PYTHON_SUFFIX: &str = ".py";
54const FILE_PIXI_PACKAGE: &str = "pixi.toml";
55const FILE_COMPOSER_JSON: &str = "composer.json";
56const FILE_PUBSPEC_YAML: &str = "pubspec.yaml";
57const FILE_ELIXIR_MIX: &str = "mix.exs";
58const FILE_SWIFT_PACKAGE: &str = "Package.swift";
59const FILE_BUILD_ZIG: &str = "build.zig";
60const FILE_GODOT_4_PROJECT: &str = "project.godot";
61const FILE_CSPROJ_SUFFIX: &str = ".csproj";
62const FILE_FSPROJ_SUFFIX: &str = ".fsproj";
63const FILE_TERRAFORM_HCL: &str = ".terraform.lock.hcl";
64const FILE_PROJECT_TURBOREPO: &str = "turbo.json";
65const FILE_PODFILE: &str = "Podfile";
66
67const PROJECT_CARGO_DIRS: [&str; 2] = ["target", ".xwin-cache"];
68const PROJECT_NODE_DIRS: [&str; 2] = ["node_modules", ".angular"];
69const PROJECT_REACT_NATIVE_DIRS: [&str; 8] = [
70 "node_modules",
71 "android/build",
72 "android/.gradle",
73 "ios/build",
74 "ios/DerivedData",
75 "ios/Pods",
76 ".expo",
77 ".metro",
78];
79const PROJECT_UNITY_DIRS: [&str; 7] = [
80 "Library",
81 "Temp",
82 "Obj",
83 "Logs",
84 "MemoryCaptures",
85 "Build",
86 "Builds",
87];
88const PROJECT_STACK_DIRS: [&str; 1] = [".stack-work"];
89const PROJECT_CABAL_DIRS: [&str; 1] = ["dist-newstyle"];
90const PROJECT_SBT_DIRS: [&str; 2] = ["target", "project/target"];
91const PROJECT_MVN_DIRS: [&str; 1] = ["target"];
92const PROJECT_GRADLE_DIRS: [&str; 2] = ["build", ".gradle"];
93const PROJECT_CMAKE_DIRS: [&str; 3] = ["build", "cmake-build-debug", "cmake-build-release"];
94const PROJECT_UNREAL_DIRS: [&str; 5] = [
95 "Binaries",
96 "Build",
97 "Saved",
98 "DerivedDataCache",
99 "Intermediate",
100];
101const PROJECT_JUPYTER_DIRS: [&str; 1] = [".ipynb_checkpoints"];
102const PROJECT_PYTHON_DIRS: [&str; 7] = [
103 ".mypy_cache",
104 ".nox",
105 ".pytest_cache",
106 ".ruff_cache",
107 ".tox",
108 "__pycache__",
109 "__pypackages__",
110];
111const PROJECT_PIXI_DIRS: [&str; 1] = [".pixi"];
112const PROJECT_COMPOSER_DIRS: [&str; 1] = ["vendor"];
113const PROJECT_PUB_DIRS: [&str; 4] = [
114 "build",
115 ".dart_tool",
116 "linux/flutter/ephemeral",
117 "windows/flutter/ephemeral",
118];
119const PROJECT_ELIXIR_DIRS: [&str; 4] = ["_build", ".elixir-tools", ".elixir_ls", ".lexical"];
120const PROJECT_SWIFT_DIRS: [&str; 2] = [".build", ".swiftpm"];
121const PROJECT_ZIG_DIRS: [&str; 3] = ["zig-cache", ".zig-cache", "zig-out"];
122const PROJECT_GODOT_4_DIRS: [&str; 1] = [".godot"];
123const PROJECT_DOTNET_DIRS: [&str; 2] = ["bin", "obj"];
124const PROJECT_TURBOREPO_DIRS: [&str; 1] = [".turbo"];
125const PROJECT_TERRAFORM_DIRS: [&str; 1] = [".terraform"];
126const PROJECT_COCOAPODS_DIRS: [&str; 1] = ["Pods"];
127
128const PROJECT_CARGO_NAME: &str = "Cargo";
129const PROJECT_NODE_NAME: &str = "Node";
130const PROJECT_NODE_REACT_NATIVE_NAME: &str = "Node (React Native)";
131const PROJECT_UNITY_NAME: &str = "Unity";
132const PROJECT_STACK_NAME: &str = "Stack";
133const PROJECT_CABAL_NAME: &str = "Cabal";
134const PROJECT_SBT_NAME: &str = "SBT";
135const PROJECT_MVN_NAME: &str = "Maven";
136const PROJECT_GRADLE_NAME: &str = "Gradle";
137const PROJECT_CMAKE_NAME: &str = "CMake";
138const PROJECT_UNREAL_NAME: &str = "Unreal";
139const PROJECT_JUPYTER_NAME: &str = "Jupyter";
140const PROJECT_PYTHON_NAME: &str = "Python";
141const PROJECT_PIXI_NAME: &str = "Pixi";
142const PROJECT_COMPOSER_NAME: &str = "Composer";
143const PROJECT_PUB_NAME: &str = "Pub";
144const PROJECT_ELIXIR_NAME: &str = "Elixir";
145const PROJECT_SWIFT_NAME: &str = "Swift";
146const PROJECT_ZIG_NAME: &str = "Zig";
147const PROJECT_GODOT_4_NAME: &str = "Godot 4.x";
148const PROJECT_DOTNET_NAME: &str = ".NET";
149const PROJECT_TURBOREPO_NAME: &str = "Turborepo";
150const PROJECT_TERRAFORM_NAME: &str = "Terraform";
151const PROJECT_COCOAPODS_NAME: &str = "CocoaPods";
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
161pub enum ProjectType {
162 Cargo,
163 Node,
164 Unity,
165 Stack,
166 Cabal,
167 #[allow(clippy::upper_case_acronyms)]
168 SBT,
169 Maven,
170 Gradle,
171 CMake,
172 Unreal,
173 Jupyter,
174 Python,
175 Pixi,
176 Composer,
177 Pub,
178 Elixir,
179 Swift,
180 Zig,
181 Godot4,
182 Dotnet,
183 Turborepo,
184 Terraform,
185 Cocoapods,
186}
187
188#[derive(Debug, Clone)]
195pub struct Project {
196 pub project_types: Vec<ProjectType>,
199 pub path: path::PathBuf,
201}
202
203#[derive(Debug, Clone)]
206pub struct ProjectSize {
207 pub artifact_size: u64,
208 pub non_artifact_size: u64,
209 pub dirs: Vec<(String, u64, bool)>,
210}
211
212fn artifact_dirs_for(pt: ProjectType, path: &Path) -> &'static [&'static str] {
213 match pt {
214 ProjectType::Cargo => &PROJECT_CARGO_DIRS,
215 ProjectType::Node => {
216 if is_react_native_project(path) {
217 &PROJECT_REACT_NATIVE_DIRS
218 } else {
219 &PROJECT_NODE_DIRS
220 }
221 }
222 ProjectType::Unity => &PROJECT_UNITY_DIRS,
223 ProjectType::Stack => &PROJECT_STACK_DIRS,
224 ProjectType::Cabal => &PROJECT_CABAL_DIRS,
225 ProjectType::SBT => &PROJECT_SBT_DIRS,
226 ProjectType::Maven => &PROJECT_MVN_DIRS,
227 ProjectType::Unreal => &PROJECT_UNREAL_DIRS,
228 ProjectType::Jupyter => &PROJECT_JUPYTER_DIRS,
229 ProjectType::Python => &PROJECT_PYTHON_DIRS,
230 ProjectType::Pixi => &PROJECT_PIXI_DIRS,
231 ProjectType::CMake => &PROJECT_CMAKE_DIRS,
232 ProjectType::Composer => &PROJECT_COMPOSER_DIRS,
233 ProjectType::Pub => &PROJECT_PUB_DIRS,
234 ProjectType::Elixir => &PROJECT_ELIXIR_DIRS,
235 ProjectType::Swift => &PROJECT_SWIFT_DIRS,
236 ProjectType::Gradle => &PROJECT_GRADLE_DIRS,
237 ProjectType::Zig => &PROJECT_ZIG_DIRS,
238 ProjectType::Godot4 => &PROJECT_GODOT_4_DIRS,
239 ProjectType::Dotnet => &PROJECT_DOTNET_DIRS,
240 ProjectType::Turborepo => &PROJECT_TURBOREPO_DIRS,
241 ProjectType::Terraform => &PROJECT_TERRAFORM_DIRS,
242 ProjectType::Cocoapods => &PROJECT_COCOAPODS_DIRS,
243 }
244}
245
246fn type_name_for(pt: ProjectType, path: &Path) -> &'static str {
247 match pt {
248 ProjectType::Cargo => PROJECT_CARGO_NAME,
249 ProjectType::Node => {
250 if is_react_native_project(path) {
251 PROJECT_NODE_REACT_NATIVE_NAME
252 } else {
253 PROJECT_NODE_NAME
254 }
255 }
256 ProjectType::Unity => PROJECT_UNITY_NAME,
257 ProjectType::Stack => PROJECT_STACK_NAME,
258 ProjectType::Cabal => PROJECT_CABAL_NAME,
259 ProjectType::SBT => PROJECT_SBT_NAME,
260 ProjectType::Maven => PROJECT_MVN_NAME,
261 ProjectType::Unreal => PROJECT_UNREAL_NAME,
262 ProjectType::Jupyter => PROJECT_JUPYTER_NAME,
263 ProjectType::Python => PROJECT_PYTHON_NAME,
264 ProjectType::Pixi => PROJECT_PIXI_NAME,
265 ProjectType::CMake => PROJECT_CMAKE_NAME,
266 ProjectType::Composer => PROJECT_COMPOSER_NAME,
267 ProjectType::Pub => PROJECT_PUB_NAME,
268 ProjectType::Elixir => PROJECT_ELIXIR_NAME,
269 ProjectType::Swift => PROJECT_SWIFT_NAME,
270 ProjectType::Gradle => PROJECT_GRADLE_NAME,
271 ProjectType::Zig => PROJECT_ZIG_NAME,
272 ProjectType::Godot4 => PROJECT_GODOT_4_NAME,
273 ProjectType::Dotnet => PROJECT_DOTNET_NAME,
274 ProjectType::Turborepo => PROJECT_TURBOREPO_NAME,
275 ProjectType::Terraform => PROJECT_TERRAFORM_NAME,
276 ProjectType::Cocoapods => PROJECT_COCOAPODS_NAME,
277 }
278}
279
280impl Project {
281 pub fn artifact_dirs(&self) -> Vec<&'static str> {
285 let mut dirs: Vec<&'static str> = Vec::new();
286 for pt in &self.project_types {
287 for d in artifact_dirs_for(*pt, &self.path) {
288 if !dirs.contains(d) {
289 dirs.push(*d);
290 }
291 }
292 }
293 dirs
294 }
295
296 pub fn name(&self) -> Cow<'_, str> {
298 self.path.to_string_lossy()
299 }
300
301 pub fn size(&self, options: &ScanOptions) -> u64 {
304 self.artifact_dirs()
305 .iter()
306 .copied()
307 .map(|p| dir_size(&self.path.join(p), options))
308 .sum()
309 }
310
311 pub fn last_modified(&self, options: &ScanOptions) -> Result<SystemTime, std::io::Error> {
313 let top_level_modified = fs::metadata(&self.path)?.modified()?;
314 let most_recent_modified = walkdir::WalkDir::new(&self.path)
315 .follow_links(options.follow_symlinks)
316 .same_file_system(options.same_file_system)
317 .into_iter()
318 .filter_map(|e| e.ok())
319 .filter_map(|e| e.metadata().ok())
320 .filter_map(|m| m.modified().ok())
321 .fold(top_level_modified, |acc, m| if m > acc { m } else { acc });
322 Ok(most_recent_modified)
323 }
324
325 pub fn size_dirs(&self, options: &ScanOptions) -> ProjectSize {
328 let mut artifact_size = 0;
329 let mut non_artifact_size = 0;
330 let mut dirs = Vec::new();
331
332 let project_root = match fs::read_dir(&self.path) {
333 Err(_) => {
334 return ProjectSize {
335 artifact_size,
336 non_artifact_size,
337 dirs,
338 }
339 }
340 Ok(rd) => rd,
341 };
342
343 for entry in project_root.filter_map(|rd| rd.ok()) {
344 let file_type = match entry.file_type() {
345 Err(_) => continue,
346 Ok(file_type) => file_type,
347 };
348
349 if file_type.is_file() {
350 if let Ok(metadata) = entry.metadata() {
351 non_artifact_size += metadata.len();
352 }
353 continue;
354 }
355
356 if file_type.is_dir() {
357 let file_name = match entry.file_name().into_string() {
358 Err(_) => continue,
359 Ok(file_name) => file_name,
360 };
361 let size = dir_size(&entry.path(), options);
362 let artifact_dir = self.artifact_dirs().contains(&file_name.as_str());
363 if artifact_dir {
364 artifact_size += size;
365 } else {
366 non_artifact_size += size;
367 }
368 dirs.push((file_name, size, artifact_dir));
369 }
370 }
371
372 ProjectSize {
373 artifact_size,
374 non_artifact_size,
375 dirs,
376 }
377 }
378
379 pub fn type_name(&self) -> String {
382 self.project_types
383 .iter()
384 .map(|pt| type_name_for(*pt, &self.path))
385 .collect::<Vec<&str>>()
386 .join(" / ")
387 }
388
389 pub fn clean(&self) -> Result<(), Box<dyn error::Error>> {
395 let mut failures: Vec<(path::PathBuf, std::io::Error)> = Vec::new();
396 for artifact_dir in self
397 .artifact_dirs()
398 .iter()
399 .copied()
400 .map(|ad| self.path.join(ad))
401 .filter(|ad| ad.exists())
402 {
403 if let Err(e) = fs::remove_dir_all(&artifact_dir) {
404 failures.push((artifact_dir, e));
405 }
406 }
407 if failures.is_empty() {
408 Ok(())
409 } else {
410 let detail = failures
411 .iter()
412 .map(|(p, e)| format!("{} ({})", p.display(), e))
413 .collect::<Vec<_>>()
414 .join("; ");
415 Err(format!("failed to remove some artifact directories: {detail}").into())
416 }
417 }
418}
419
420fn is_hidden(entry: &walkdir::DirEntry) -> bool {
421 entry.file_name().to_string_lossy().starts_with('.')
422}
423
424const ALL_ARTIFACT_DIR_NAMES: &[&str] = &[
438 "target",
440 ".xwin-cache",
441 "node_modules",
443 ".angular",
444 ".expo",
445 ".metro",
446 "Library",
448 "Temp",
449 "Obj",
450 "Logs",
451 "MemoryCaptures",
452 "Build",
453 "Builds",
454 ".stack-work",
456 "dist-newstyle",
457 "build",
460 ".gradle",
461 "cmake-build-debug",
462 "cmake-build-release",
463 "Binaries",
465 "Saved",
466 "DerivedDataCache",
467 "Intermediate",
468 ".ipynb_checkpoints",
470 ".mypy_cache",
471 ".nox",
472 ".pytest_cache",
473 ".ruff_cache",
474 ".tox",
475 "__pycache__",
476 "__pypackages__",
477 ".pixi",
479 "vendor",
480 ".dart_tool",
481 "_build",
482 ".elixir-tools",
483 ".elixir_ls",
484 ".lexical",
485 ".build",
486 ".swiftpm",
487 "zig-cache",
488 ".zig-cache",
489 "zig-out",
490 ".godot",
491 ".turbo",
492 ".terraform",
493 "Pods",
494 "bin",
495 "obj",
496];
497
498fn is_artifact_dir_name(name: &str) -> bool {
501 ALL_ARTIFACT_DIR_NAMES.contains(&name)
502}
503
504struct ProjectIter {
505 it: walkdir::IntoIter,
506}
507
508#[derive(Debug)]
510pub enum ScanError {
511 IOError(::std::io::Error),
513 WalkdirError(walkdir::Error),
515}
516
517impl std::fmt::Display for ScanError {
518 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
519 match self {
520 ScanError::IOError(e) => write!(f, "io error: {e}"),
521 ScanError::WalkdirError(e) => write!(f, "directory traversal error: {e}"),
522 }
523 }
524}
525
526impl std::error::Error for ScanError {
527 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
528 match self {
529 ScanError::IOError(e) => Some(e),
530 ScanError::WalkdirError(e) => Some(e),
531 }
532 }
533}
534
535impl Iterator for ProjectIter {
536 type Item = Result<Project, ScanError>;
537
538 fn next(&mut self) -> Option<Self::Item> {
539 loop {
540 let entry: walkdir::DirEntry = match self.it.next() {
541 None => return None,
542 Some(Err(e)) => return Some(Err(ScanError::WalkdirError(e))),
543 Some(Ok(entry)) => entry,
544 };
545 if !entry.file_type().is_dir() {
546 continue;
547 }
548 if is_hidden(&entry) || is_artifact_dir_name(&entry.file_name().to_string_lossy()) {
553 self.it.skip_current_dir();
554 continue;
555 }
556 let project_types = match detect_project_types(entry.path()) {
557 Err(e) => return Some(Err(ScanError::IOError(e))),
558 Ok(project_types) if project_types.is_empty() => continue,
559 Ok(project_types) => project_types,
560 };
561 return Some(Ok(Project {
566 project_types,
567 path: entry.path().to_path_buf(),
568 }));
569 }
570 }
571}
572
573fn dir_contains_subdir(path: &Path, subdir: &str) -> bool {
574 path.read_dir()
575 .map(|rd| {
576 rd.filter_map(|rd| rd.ok()).any(|de| {
577 de.file_type().is_ok_and(|t| t.is_dir()) && de.file_name().to_str() == Some(subdir)
578 })
579 })
580 .unwrap_or(false)
581}
582
583fn is_react_native_project(path: &Path) -> bool {
584 dir_contains_subdir(path, "ios") || dir_contains_subdir(path, "android")
585}
586
587fn detect_project_types(path: &Path) -> Result<Vec<ProjectType>, std::io::Error> {
599 let mut file_names: Vec<String> = Vec::new();
603 let mut has_godot = false;
604 let mut has_assembly = false;
605 for dir_entry in path.read_dir()?.filter_map(|rd| rd.ok()) {
606 if !dir_entry.file_type().is_ok_and(|ft| ft.is_file()) {
607 continue;
608 }
609 let file_name = match dir_entry.file_name().into_string() {
610 Ok(file_name) => file_name,
611 Err(_) => continue,
612 };
613 if file_name == FILE_GODOT_4_PROJECT {
614 has_godot = true;
615 } else if file_name == FILE_ASSEMBLY_CSHARP {
616 has_assembly = true;
617 }
618 file_names.push(file_name);
619 }
620
621 let mut types: Vec<ProjectType> = Vec::new();
622 for file_name in &file_names {
623 let p_type = match file_name.as_str() {
624 FILE_CARGO_TOML => Some(ProjectType::Cargo),
625 FILE_PACKAGE_JSON => Some(ProjectType::Node),
626 FILE_ASSEMBLY_CSHARP => Some(ProjectType::Unity),
627 FILE_STACK_HASKELL => Some(ProjectType::Stack),
628 FILE_CABAL_HASKELL => Some(ProjectType::Cabal),
629 FILE_SBT_BUILD => Some(ProjectType::SBT),
630 FILE_MVN_BUILD => Some(ProjectType::Maven),
631 FILE_CMAKE_BUILD => Some(ProjectType::CMake),
632 FILE_COMPOSER_JSON => Some(ProjectType::Composer),
633 FILE_PUBSPEC_YAML => Some(ProjectType::Pub),
634 FILE_PIXI_PACKAGE => Some(ProjectType::Pixi),
635 FILE_ELIXIR_MIX => Some(ProjectType::Elixir),
636 FILE_SWIFT_PACKAGE => Some(ProjectType::Swift),
637 FILE_BUILD_GRADLE => Some(ProjectType::Gradle),
638 FILE_BUILD_GRADLE_KTS => Some(ProjectType::Gradle),
639 FILE_BUILD_ZIG => Some(ProjectType::Zig),
640 FILE_GODOT_4_PROJECT => Some(ProjectType::Godot4),
641 FILE_PROJECT_TURBOREPO => Some(ProjectType::Turborepo),
642 FILE_TERRAFORM_HCL => Some(ProjectType::Terraform),
643 FILE_PODFILE => Some(ProjectType::Cocoapods),
644 file_name if file_name.ends_with(FILE_UNREAL_SUFFIX) => Some(ProjectType::Unreal),
645 file_name if file_name.ends_with(FILE_JUPYTER_SUFFIX) => Some(ProjectType::Jupyter),
646 file_name if file_name.ends_with(FILE_PYTHON_SUFFIX) => Some(ProjectType::Python),
647 file_name
648 if file_name.ends_with(FILE_CSPROJ_SUFFIX)
649 || file_name.ends_with(FILE_FSPROJ_SUFFIX) =>
650 {
651 if has_godot {
652 Some(ProjectType::Godot4)
653 } else if has_assembly {
654 Some(ProjectType::Unity)
655 } else {
656 Some(ProjectType::Dotnet)
657 }
658 }
659 _ => None,
660 };
661 if let Some(pt) = p_type {
662 if !types.contains(&pt) {
663 types.push(pt);
664 }
665 }
666 }
667 types.sort();
668 types.dedup();
669 Ok(types)
670}
671
672#[derive(Clone, Debug)]
675pub struct ScanOptions {
676 pub follow_symlinks: bool,
678 pub same_file_system: bool,
680 pub apparent: bool,
684}
685
686fn build_walkdir_iter<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> ProjectIter {
687 ProjectIter {
688 it: walkdir::WalkDir::new(path)
689 .follow_links(options.follow_symlinks)
690 .same_file_system(options.same_file_system)
691 .into_iter(),
692 }
693}
694
695pub fn scan<P: AsRef<path::Path>>(
710 path: &P,
711 options: &ScanOptions,
712) -> impl Iterator<Item = Result<Project, ScanError>> {
713 build_walkdir_iter(path, options)
714}
715
716fn file_size(md: &fs::Metadata, path: &Path, apparent: bool) -> u64 {
720 if apparent {
721 md.len()
722 } else {
723 allocated_size(md, path)
724 }
725}
726
727#[cfg(unix)]
728fn allocated_size(md: &fs::Metadata, _path: &Path) -> u64 {
729 use std::os::unix::fs::MetadataExt;
730 md.blocks() * 512
731}
732
733#[cfg(windows)]
734fn allocated_size(md: &fs::Metadata, path: &Path) -> u64 {
735 crate::ffi::compressed_size(path).unwrap_or_else(|_| md.len())
736}
737
738#[cfg(not(any(unix, windows)))]
739fn allocated_size(md: &fs::Metadata, _path: &Path) -> u64 {
740 md.len()
741}
742
743pub(crate) struct FileSizeCounter {
761 apparent: bool,
762 #[cfg(unix)]
763 seen: std::sync::Mutex<std::collections::HashSet<(u64, u64)>>,
764}
765
766impl FileSizeCounter {
767 pub(crate) fn new(apparent: bool) -> Self {
768 Self {
769 apparent,
770 #[cfg(unix)]
771 seen: std::sync::Mutex::new(std::collections::HashSet::new()),
772 }
773 }
774
775 pub(crate) fn add(&self, md: &fs::Metadata, path: &Path) -> u64 {
780 #[cfg(unix)]
781 {
782 use std::os::unix::fs::MetadataExt;
783 if md.nlink() > 1 {
786 let mut seen = self.seen.lock().expect("FileSizeCounter mutex poisoned");
787 if !seen.insert((md.dev(), md.ino())) {
788 return 0;
789 }
790 }
791 }
792 file_size(md, path, self.apparent)
793 }
794}
795
796pub fn dir_size<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> u64 {
801 let counter = FileSizeCounter::new(options.apparent);
802 build_walkdir_iter(path, options)
803 .it
804 .filter_map(|e| e.ok())
805 .filter(|e| e.file_type().is_file())
806 .filter_map(|e| {
807 let md = e.metadata().ok()?;
808 Some(counter.add(&md, e.path()))
809 })
810 .sum()
811}
812
813#[derive(Debug, Clone)]
815pub struct ProjectAnalysis {
816 pub project: Project,
818 pub artifact_size: u64,
821 pub last_modified: Option<SystemTime>,
823}
824
825fn project_size_and_mtime(project: &Project, options: &ScanOptions) -> (u64, Option<SystemTime>) {
837 let artifact_prefixes: Vec<path::PathBuf> = project
840 .artifact_dirs()
841 .into_iter()
842 .map(|d| project.path.join(d))
843 .collect();
844
845 let mut total_artifact_size: u64 = 0;
846 let mut latest_mtime: Option<SystemTime> = None;
847 let counter = FileSizeCounter::new(options.apparent);
848
849 for entry in walkdir::WalkDir::new(&project.path)
850 .follow_links(options.follow_symlinks)
851 .same_file_system(options.same_file_system)
852 .into_iter()
853 .filter_map(|e| e.ok())
854 {
855 let metadata = match entry.metadata() {
856 Ok(m) => m,
857 Err(_) => continue,
858 };
859 if !metadata.is_file() {
860 continue;
861 }
862
863 if artifact_prefixes
868 .iter()
869 .any(|prefix| entry.path().starts_with(prefix))
870 {
871 total_artifact_size += counter.add(&metadata, entry.path());
872 }
873
874 if let Ok(modified) = metadata.modified() {
876 latest_mtime = Some(latest_mtime.map_or(modified, |prev| prev.max(modified)));
877 }
878 }
879
880 (total_artifact_size, latest_mtime)
881}
882
883pub fn analyze<'a, P: AsRef<Path>>(
895 path: &'a P,
896 options: &'a ScanOptions,
897) -> impl Iterator<Item = ProjectAnalysis> + use<'a, P> {
898 scan(path, options).filter_map(|project| {
899 let project = project.ok()?;
900 let (artifact_size, last_modified) = project_size_and_mtime(&project, options);
901 if artifact_size == 0 {
902 return None;
903 }
904 Some(ProjectAnalysis {
905 project,
906 artifact_size,
907 last_modified,
908 })
909 })
910}
911
912pub fn clean(project_path: &Path) -> Result<(), Box<dyn error::Error>> {
918 let project_types = detect_project_types(project_path)?;
919 if project_types.is_empty() {
920 return Ok(());
921 }
922 let project = Project {
923 project_types,
924 path: project_path.to_path_buf(),
925 };
926 project.clean()?;
927
928 Ok(())
929}
930#[cfg(test)]
931mod tests {
932 use super::{
933 analyze, detect_project_types, dir_size, scan, Project, ProjectType, ScanOptions,
934 };
935 use std::fs;
936 use std::path::{Path, PathBuf};
937
938 fn opts() -> ScanOptions {
940 ScanOptions {
941 follow_symlinks: false,
942 same_file_system: false,
943 apparent: true,
944 }
945 }
946
947 fn fresh_root() -> (tempfile::TempDir, PathBuf) {
954 let parent = tempfile::tempdir().unwrap();
955 let root = parent.path().join("kondo-test-root");
956 fs::create_dir_all(&root).unwrap();
957 (parent, root)
958 }
959
960 fn touch<P: AsRef<Path>>(dir: P, name: &str, contents: &str) {
962 let path = dir.as_ref().join(name);
963 fs::create_dir_all(path.parent().unwrap()).unwrap();
964 fs::write(path, contents).unwrap();
965 }
966
967 #[test]
972 fn detect_cargo_only() {
973 let dir = tempfile::tempdir().unwrap();
974 touch(dir.path(), "Cargo.toml", "");
975 let types = detect_project_types(dir.path()).unwrap();
976 assert_eq!(types, vec![ProjectType::Cargo]);
977 }
978
979 #[test]
980 fn detect_mixed_cargo_node() {
981 let dir = tempfile::tempdir().unwrap();
982 touch(dir.path(), "Cargo.toml", "");
983 touch(dir.path(), "package.json", "");
984 let types = detect_project_types(dir.path()).unwrap();
985 assert_eq!(types, vec![ProjectType::Cargo, ProjectType::Node]);
987 }
988
989 #[test]
990 fn detect_node_and_turborepo() {
991 let dir = tempfile::tempdir().unwrap();
992 touch(dir.path(), "package.json", "");
993 touch(dir.path(), "turbo.json", "");
994 let types = detect_project_types(dir.path()).unwrap();
995 assert_eq!(types, vec![ProjectType::Node, ProjectType::Turborepo]);
997 }
998
999 #[test]
1000 fn detect_csproj_with_godot_is_godot4() {
1001 let dir = tempfile::tempdir().unwrap();
1002 touch(dir.path(), "something.csproj", "");
1003 touch(dir.path(), "project.godot", "");
1004 let types = detect_project_types(dir.path()).unwrap();
1005 assert_eq!(types, vec![ProjectType::Godot4]);
1007 }
1008
1009 #[test]
1010 fn detect_assembly_csharp_is_unity() {
1011 let dir = tempfile::tempdir().unwrap();
1012 touch(dir.path(), "Assembly-CSharp.csproj", "");
1013 let types = detect_project_types(dir.path()).unwrap();
1014 assert_eq!(types, vec![ProjectType::Unity]);
1015 }
1016
1017 #[test]
1018 fn detect_plain_csproj_is_dotnet() {
1019 let dir = tempfile::tempdir().unwrap();
1020 touch(dir.path(), "app.csproj", "");
1021 let types = detect_project_types(dir.path()).unwrap();
1022 assert_eq!(types, vec![ProjectType::Dotnet]);
1023 }
1024
1025 #[test]
1026 fn detect_empty_directory_is_empty_vec() {
1027 let dir = tempfile::tempdir().unwrap();
1028 let types = detect_project_types(dir.path()).unwrap();
1029 assert!(types.is_empty());
1030 }
1031
1032 fn project_with(types: Vec<ProjectType>, path: &Path) -> Project {
1037 Project {
1038 project_types: types,
1039 path: path.to_path_buf(),
1040 }
1041 }
1042
1043 #[test]
1044 fn artifact_dirs_cargo_includes_target_and_xwin() {
1045 let dir = tempfile::tempdir().unwrap();
1046 let p = project_with(vec![ProjectType::Cargo], dir.path());
1047 let dirs = p.artifact_dirs();
1048 assert!(dirs.contains(&"target"));
1049 assert!(dirs.contains(&".xwin-cache"));
1050 }
1051
1052 #[test]
1053 fn artifact_dirs_cargo_node_union() {
1054 let dir = tempfile::tempdir().unwrap();
1055 let p = project_with(vec![ProjectType::Cargo, ProjectType::Node], dir.path());
1056 let dirs = p.artifact_dirs();
1057 assert!(dirs.contains(&"target"));
1058 assert!(dirs.contains(&"node_modules"));
1059 assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
1061 }
1062
1063 #[test]
1064 fn artifact_dirs_sbt_maven_target_deduped() {
1065 let dir = tempfile::tempdir().unwrap();
1066 let p = project_with(vec![ProjectType::SBT, ProjectType::Maven], dir.path());
1067 let dirs = p.artifact_dirs();
1068 assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
1070 }
1071
1072 #[test]
1077 fn scan_finds_nested_subcrate_projects() {
1078 let (_keep, root) = fresh_root();
1079 touch(&root, "Cargo.toml", "");
1081 touch(&root, "target/keep.o", "x");
1082 let sub = root.join("crates").join("sub");
1084 touch(&sub, "Cargo.toml", "");
1085 touch(&sub, "target/keep.o", "y");
1086
1087 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1088 assert_eq!(projects.len(), 2);
1091 assert!(projects.iter().any(|p| p.path == root));
1092 assert!(projects.iter().any(|p| p.path == sub));
1093 }
1094
1095 #[test]
1096 fn scan_prunes_artifact_directories() {
1097 let (_keep, root) = fresh_root();
1098 touch(root.join("target"), "Cargo.toml", "");
1101
1102 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1103 assert!(projects.is_empty());
1104 }
1105
1106 #[test]
1107 fn scan_skips_hidden_directories() {
1108 let (_keep, root) = fresh_root();
1109 let hidden = root.join(".hidden");
1112 fs::create_dir_all(&hidden).unwrap();
1113 touch(&hidden, "Cargo.toml", "");
1114
1115 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1116 assert!(projects.is_empty());
1117 }
1118
1119 #[test]
1124 fn clean_removes_target() {
1125 let dir = tempfile::tempdir().unwrap();
1126 touch(dir.path(), "Cargo.toml", "");
1127 touch(dir.path(), "target/keep.o", "x");
1129 assert!(dir.path().join("target").exists());
1130
1131 let p = project_with(vec![ProjectType::Cargo], dir.path());
1132 p.clean().unwrap();
1133
1134 assert!(!dir.path().join("target").exists());
1135 }
1136
1137 #[test]
1142 fn dir_size_counts_file_contents() {
1143 let dir = tempfile::tempdir().unwrap();
1144 let path = dir.path();
1145 touch(path, "a.txt", "hello world");
1146 let size = dir_size(&path, &opts());
1147 assert!(size > 0);
1148 }
1149
1150 #[cfg(unix)]
1155 #[test]
1156 fn dir_size_counts_hardlinked_file_once() {
1157 use std::os::unix::fs::MetadataExt;
1158
1159 let dir = tempfile::tempdir().unwrap();
1160 let target = dir.path().join("target");
1161 fs::create_dir_all(&target).unwrap();
1162 touch(&target, "a.bin", "0123456789");
1164 fs::hard_link(target.join("a.bin"), target.join("b.bin")).unwrap();
1165
1166 assert_eq!(fs::metadata(target.join("a.bin")).unwrap().nlink(), 2);
1168
1169 assert_eq!(dir_size(&target, &opts()), 10);
1171 }
1172
1173 #[test]
1178 fn analyze_skips_zero_artifact_projects() {
1179 let (_keep, root) = fresh_root();
1180 let a = root.join("a");
1182 fs::create_dir_all(&a).unwrap();
1183 touch(&a, "Cargo.toml", "");
1184 touch(&a, "target/build.o", "data");
1185 let b = root.join("b");
1187 fs::create_dir_all(&b).unwrap();
1188 touch(&b, "Cargo.toml", "");
1189
1190 let analyses: Vec<_> = analyze(&root, &opts()).collect();
1191 assert_eq!(analyses.len(), 1);
1193 assert!(analyses[0].artifact_size > 0);
1194 assert!(analyses[0].project.path.ends_with(a.file_name().unwrap()));
1195 }
1196
1197 #[test]
1198 fn analyze_reports_size_and_mtime_for_populated_project() {
1199 let (_keep, root) = fresh_root();
1200 let a = root.join("a");
1201 fs::create_dir_all(&a).unwrap();
1202 touch(&a, "Cargo.toml", "");
1203 touch(&a, "target/build.o", "12345");
1205
1206 let analyses: Vec<_> = analyze(&root, &opts()).collect();
1207 assert_eq!(analyses.len(), 1);
1208 let analysis = &analyses[0];
1209 assert_eq!(analysis.artifact_size, 5);
1211 assert!(analysis.last_modified.is_some());
1213 }
1214
1215 #[cfg(unix)]
1220 #[test]
1221 fn analyze_dedups_hardlinked_artifacts() {
1222 let (_keep, root) = fresh_root();
1223 let a = root.join("a");
1224 fs::create_dir_all(a.join("target")).unwrap();
1225 touch(&a, "Cargo.toml", "");
1226 touch(&a, "target/real.o", "0123456789");
1228 fs::hard_link(a.join("target/real.o"), a.join("target/link.o")).unwrap();
1229
1230 let analyses: Vec<_> = analyze(&root, &opts()).collect();
1231 assert_eq!(analyses.len(), 1);
1232 assert_eq!(analyses[0].artifact_size, 10);
1234 }
1235
1236 #[test]
1237 fn analyze_reports_nested_subcrate_separately() {
1238 let (_keep, root) = fresh_root();
1239 touch(&root, "Cargo.toml", "");
1241 touch(&root, "target/root.o", "RRRR"); let sub = root.join("crates").join("sub");
1244 touch(&sub, "Cargo.toml", "");
1245 touch(&sub, "target/sub.o", "SSSSSS"); let analyses: Vec<_> = analyze(&root, &opts()).collect();
1248 assert_eq!(analyses.len(), 2);
1251 let size_of = |p: &Path| {
1252 analyses
1253 .iter()
1254 .find(|a| a.project.path == p)
1255 .map(|a| a.artifact_size)
1256 };
1257 assert_eq!(size_of(&root), Some(4));
1258 assert_eq!(size_of(&sub), Some(6));
1259 }
1260}