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 fn dir_size<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> u64 {
748 build_walkdir_iter(path, options)
749 .it
750 .filter_map(|e| e.ok())
751 .filter(|e| e.file_type().is_file())
752 .filter_map(|e| {
753 let md = e.metadata().ok()?;
754 Some(file_size(&md, e.path(), options.apparent))
755 })
756 .sum()
757}
758
759#[derive(Debug, Clone)]
761pub struct ProjectAnalysis {
762 pub project: Project,
764 pub artifact_size: u64,
767 pub last_modified: Option<SystemTime>,
769}
770
771fn project_size_and_mtime(project: &Project, options: &ScanOptions) -> (u64, Option<SystemTime>) {
783 let artifact_prefixes: Vec<path::PathBuf> = project
786 .artifact_dirs()
787 .into_iter()
788 .map(|d| project.path.join(d))
789 .collect();
790
791 let mut total_artifact_size: u64 = 0;
792 let mut latest_mtime: Option<SystemTime> = None;
793
794 for entry in walkdir::WalkDir::new(&project.path)
795 .follow_links(options.follow_symlinks)
796 .same_file_system(options.same_file_system)
797 .into_iter()
798 .filter_map(|e| e.ok())
799 {
800 let metadata = match entry.metadata() {
801 Ok(m) => m,
802 Err(_) => continue,
803 };
804 if !metadata.is_file() {
805 continue;
806 }
807
808 if artifact_prefixes
810 .iter()
811 .any(|prefix| entry.path().starts_with(prefix))
812 {
813 total_artifact_size += file_size(&metadata, entry.path(), options.apparent);
814 }
815
816 if let Ok(modified) = metadata.modified() {
818 latest_mtime = Some(latest_mtime.map_or(modified, |prev| prev.max(modified)));
819 }
820 }
821
822 (total_artifact_size, latest_mtime)
823}
824
825pub fn analyze<'a, P: AsRef<Path>>(
837 path: &'a P,
838 options: &'a ScanOptions,
839) -> impl Iterator<Item = ProjectAnalysis> + use<'a, P> {
840 scan(path, options).filter_map(|project| {
841 let project = project.ok()?;
842 let (artifact_size, last_modified) = project_size_and_mtime(&project, options);
843 if artifact_size == 0 {
844 return None;
845 }
846 Some(ProjectAnalysis {
847 project,
848 artifact_size,
849 last_modified,
850 })
851 })
852}
853
854pub fn clean(project_path: &Path) -> Result<(), Box<dyn error::Error>> {
860 let project_types = detect_project_types(project_path)?;
861 if project_types.is_empty() {
862 return Ok(());
863 }
864 let project = Project {
865 project_types,
866 path: project_path.to_path_buf(),
867 };
868 project.clean()?;
869
870 Ok(())
871}
872#[cfg(test)]
873mod tests {
874 use super::{
875 analyze, detect_project_types, dir_size, scan, Project, ProjectType, ScanOptions,
876 };
877 use std::fs;
878 use std::path::{Path, PathBuf};
879
880 fn opts() -> ScanOptions {
882 ScanOptions {
883 follow_symlinks: false,
884 same_file_system: false,
885 apparent: true,
886 }
887 }
888
889 fn fresh_root() -> (tempfile::TempDir, PathBuf) {
896 let parent = tempfile::tempdir().unwrap();
897 let root = parent.path().join("kondo-test-root");
898 fs::create_dir_all(&root).unwrap();
899 (parent, root)
900 }
901
902 fn touch<P: AsRef<Path>>(dir: P, name: &str, contents: &str) {
904 let path = dir.as_ref().join(name);
905 fs::create_dir_all(path.parent().unwrap()).unwrap();
906 fs::write(path, contents).unwrap();
907 }
908
909 #[test]
914 fn detect_cargo_only() {
915 let dir = tempfile::tempdir().unwrap();
916 touch(dir.path(), "Cargo.toml", "");
917 let types = detect_project_types(dir.path()).unwrap();
918 assert_eq!(types, vec![ProjectType::Cargo]);
919 }
920
921 #[test]
922 fn detect_mixed_cargo_node() {
923 let dir = tempfile::tempdir().unwrap();
924 touch(dir.path(), "Cargo.toml", "");
925 touch(dir.path(), "package.json", "");
926 let types = detect_project_types(dir.path()).unwrap();
927 assert_eq!(types, vec![ProjectType::Cargo, ProjectType::Node]);
929 }
930
931 #[test]
932 fn detect_node_and_turborepo() {
933 let dir = tempfile::tempdir().unwrap();
934 touch(dir.path(), "package.json", "");
935 touch(dir.path(), "turbo.json", "");
936 let types = detect_project_types(dir.path()).unwrap();
937 assert_eq!(types, vec![ProjectType::Node, ProjectType::Turborepo]);
939 }
940
941 #[test]
942 fn detect_csproj_with_godot_is_godot4() {
943 let dir = tempfile::tempdir().unwrap();
944 touch(dir.path(), "something.csproj", "");
945 touch(dir.path(), "project.godot", "");
946 let types = detect_project_types(dir.path()).unwrap();
947 assert_eq!(types, vec![ProjectType::Godot4]);
949 }
950
951 #[test]
952 fn detect_assembly_csharp_is_unity() {
953 let dir = tempfile::tempdir().unwrap();
954 touch(dir.path(), "Assembly-CSharp.csproj", "");
955 let types = detect_project_types(dir.path()).unwrap();
956 assert_eq!(types, vec![ProjectType::Unity]);
957 }
958
959 #[test]
960 fn detect_plain_csproj_is_dotnet() {
961 let dir = tempfile::tempdir().unwrap();
962 touch(dir.path(), "app.csproj", "");
963 let types = detect_project_types(dir.path()).unwrap();
964 assert_eq!(types, vec![ProjectType::Dotnet]);
965 }
966
967 #[test]
968 fn detect_empty_directory_is_empty_vec() {
969 let dir = tempfile::tempdir().unwrap();
970 let types = detect_project_types(dir.path()).unwrap();
971 assert!(types.is_empty());
972 }
973
974 fn project_with(types: Vec<ProjectType>, path: &Path) -> Project {
979 Project {
980 project_types: types,
981 path: path.to_path_buf(),
982 }
983 }
984
985 #[test]
986 fn artifact_dirs_cargo_includes_target_and_xwin() {
987 let dir = tempfile::tempdir().unwrap();
988 let p = project_with(vec![ProjectType::Cargo], dir.path());
989 let dirs = p.artifact_dirs();
990 assert!(dirs.contains(&"target"));
991 assert!(dirs.contains(&".xwin-cache"));
992 }
993
994 #[test]
995 fn artifact_dirs_cargo_node_union() {
996 let dir = tempfile::tempdir().unwrap();
997 let p = project_with(vec![ProjectType::Cargo, ProjectType::Node], dir.path());
998 let dirs = p.artifact_dirs();
999 assert!(dirs.contains(&"target"));
1000 assert!(dirs.contains(&"node_modules"));
1001 assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
1003 }
1004
1005 #[test]
1006 fn artifact_dirs_sbt_maven_target_deduped() {
1007 let dir = tempfile::tempdir().unwrap();
1008 let p = project_with(vec![ProjectType::SBT, ProjectType::Maven], dir.path());
1009 let dirs = p.artifact_dirs();
1010 assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
1012 }
1013
1014 #[test]
1019 fn scan_finds_nested_subcrate_projects() {
1020 let (_keep, root) = fresh_root();
1021 touch(&root, "Cargo.toml", "");
1023 touch(&root, "target/keep.o", "x");
1024 let sub = root.join("crates").join("sub");
1026 touch(&sub, "Cargo.toml", "");
1027 touch(&sub, "target/keep.o", "y");
1028
1029 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1030 assert_eq!(projects.len(), 2);
1033 assert!(projects.iter().any(|p| p.path == root));
1034 assert!(projects.iter().any(|p| p.path == sub));
1035 }
1036
1037 #[test]
1038 fn scan_prunes_artifact_directories() {
1039 let (_keep, root) = fresh_root();
1040 touch(root.join("target"), "Cargo.toml", "");
1043
1044 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1045 assert!(projects.is_empty());
1046 }
1047
1048 #[test]
1049 fn scan_skips_hidden_directories() {
1050 let (_keep, root) = fresh_root();
1051 let hidden = root.join(".hidden");
1054 fs::create_dir_all(&hidden).unwrap();
1055 touch(&hidden, "Cargo.toml", "");
1056
1057 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1058 assert!(projects.is_empty());
1059 }
1060
1061 #[test]
1066 fn clean_removes_target() {
1067 let dir = tempfile::tempdir().unwrap();
1068 touch(dir.path(), "Cargo.toml", "");
1069 touch(dir.path(), "target/keep.o", "x");
1071 assert!(dir.path().join("target").exists());
1072
1073 let p = project_with(vec![ProjectType::Cargo], dir.path());
1074 p.clean().unwrap();
1075
1076 assert!(!dir.path().join("target").exists());
1077 }
1078
1079 #[test]
1084 fn dir_size_counts_file_contents() {
1085 let dir = tempfile::tempdir().unwrap();
1086 let path = dir.path();
1087 touch(path, "a.txt", "hello world");
1088 let size = dir_size(&path, &opts());
1089 assert!(size > 0);
1090 }
1091
1092 #[test]
1097 fn analyze_skips_zero_artifact_projects() {
1098 let (_keep, root) = fresh_root();
1099 let a = root.join("a");
1101 fs::create_dir_all(&a).unwrap();
1102 touch(&a, "Cargo.toml", "");
1103 touch(&a, "target/build.o", "data");
1104 let b = root.join("b");
1106 fs::create_dir_all(&b).unwrap();
1107 touch(&b, "Cargo.toml", "");
1108
1109 let analyses: Vec<_> = analyze(&root, &opts()).collect();
1110 assert_eq!(analyses.len(), 1);
1112 assert!(analyses[0].artifact_size > 0);
1113 assert!(analyses[0].project.path.ends_with(a.file_name().unwrap()));
1114 }
1115
1116 #[test]
1117 fn analyze_reports_size_and_mtime_for_populated_project() {
1118 let (_keep, root) = fresh_root();
1119 let a = root.join("a");
1120 fs::create_dir_all(&a).unwrap();
1121 touch(&a, "Cargo.toml", "");
1122 touch(&a, "target/build.o", "12345");
1124
1125 let analyses: Vec<_> = analyze(&root, &opts()).collect();
1126 assert_eq!(analyses.len(), 1);
1127 let analysis = &analyses[0];
1128 assert_eq!(analysis.artifact_size, 5);
1130 assert!(analysis.last_modified.is_some());
1132 }
1133
1134 #[test]
1135 fn analyze_reports_nested_subcrate_separately() {
1136 let (_keep, root) = fresh_root();
1137 touch(&root, "Cargo.toml", "");
1139 touch(&root, "target/root.o", "RRRR"); let sub = root.join("crates").join("sub");
1142 touch(&sub, "Cargo.toml", "");
1143 touch(&sub, "target/sub.o", "SSSSSS"); let analyses: Vec<_> = analyze(&root, &opts()).collect();
1146 assert_eq!(analyses.len(), 2);
1149 let size_of = |p: &Path| {
1150 analyses
1151 .iter()
1152 .find(|a| a.project.path == p)
1153 .map(|a| a.artifact_size)
1154 };
1155 assert_eq!(size_of(&root), Some(4));
1156 assert_eq!(size_of(&sub), Some(6));
1157 }
1158}