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}
681
682fn build_walkdir_iter<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> ProjectIter {
683 ProjectIter {
684 it: walkdir::WalkDir::new(path)
685 .follow_links(options.follow_symlinks)
686 .same_file_system(options.same_file_system)
687 .into_iter(),
688 }
689}
690
691pub fn scan<P: AsRef<path::Path>>(
706 path: &P,
707 options: &ScanOptions,
708) -> impl Iterator<Item = Result<Project, ScanError>> {
709 build_walkdir_iter(path, options)
710}
711
712pub fn dir_size<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> u64 {
717 build_walkdir_iter(path, options)
718 .it
719 .filter_map(|e| e.ok())
720 .filter(|e| e.file_type().is_file())
721 .filter_map(|e| e.metadata().ok())
722 .map(|e| e.len())
723 .sum()
724}
725
726#[derive(Debug, Clone)]
728pub struct ProjectAnalysis {
729 pub project: Project,
731 pub artifact_size: u64,
734 pub last_modified: Option<SystemTime>,
736}
737
738fn project_size_and_mtime(project: &Project, options: &ScanOptions) -> (u64, Option<SystemTime>) {
750 let artifact_prefixes: Vec<path::PathBuf> = project
753 .artifact_dirs()
754 .into_iter()
755 .map(|d| project.path.join(d))
756 .collect();
757
758 let mut total_artifact_size: u64 = 0;
759 let mut latest_mtime: Option<SystemTime> = None;
760
761 for entry in walkdir::WalkDir::new(&project.path)
762 .follow_links(options.follow_symlinks)
763 .same_file_system(options.same_file_system)
764 .into_iter()
765 .filter_map(|e| e.ok())
766 {
767 let metadata = match entry.metadata() {
768 Ok(m) => m,
769 Err(_) => continue,
770 };
771 if !metadata.is_file() {
772 continue;
773 }
774
775 if artifact_prefixes
777 .iter()
778 .any(|prefix| entry.path().starts_with(prefix))
779 {
780 total_artifact_size += metadata.len();
781 }
782
783 if let Ok(modified) = metadata.modified() {
785 latest_mtime = Some(latest_mtime.map_or(modified, |prev| prev.max(modified)));
786 }
787 }
788
789 (total_artifact_size, latest_mtime)
790}
791
792pub fn analyze<'a, P: AsRef<Path>>(
804 path: &'a P,
805 options: &'a ScanOptions,
806) -> impl Iterator<Item = ProjectAnalysis> + use<'a, P> {
807 scan(path, options).filter_map(|project| {
808 let project = project.ok()?;
809 let (artifact_size, last_modified) = project_size_and_mtime(&project, options);
810 if artifact_size == 0 {
811 return None;
812 }
813 Some(ProjectAnalysis {
814 project,
815 artifact_size,
816 last_modified,
817 })
818 })
819}
820
821pub fn clean(project_path: &Path) -> Result<(), Box<dyn error::Error>> {
827 let project_types = detect_project_types(project_path)?;
828 if project_types.is_empty() {
829 return Ok(());
830 }
831 let project = Project {
832 project_types,
833 path: project_path.to_path_buf(),
834 };
835 project.clean()?;
836
837 Ok(())
838}
839#[cfg(test)]
840mod tests {
841 use super::{
842 analyze, detect_project_types, dir_size, scan, Project, ProjectType, ScanOptions,
843 };
844 use std::fs;
845 use std::path::{Path, PathBuf};
846
847 fn opts() -> ScanOptions {
849 ScanOptions {
850 follow_symlinks: false,
851 same_file_system: false,
852 }
853 }
854
855 fn fresh_root() -> (tempfile::TempDir, PathBuf) {
862 let parent = tempfile::tempdir().unwrap();
863 let root = parent.path().join("kondo-test-root");
864 fs::create_dir_all(&root).unwrap();
865 (parent, root)
866 }
867
868 fn touch<P: AsRef<Path>>(dir: P, name: &str, contents: &str) {
870 let path = dir.as_ref().join(name);
871 fs::create_dir_all(path.parent().unwrap()).unwrap();
872 fs::write(path, contents).unwrap();
873 }
874
875 #[test]
880 fn detect_cargo_only() {
881 let dir = tempfile::tempdir().unwrap();
882 touch(dir.path(), "Cargo.toml", "");
883 let types = detect_project_types(dir.path()).unwrap();
884 assert_eq!(types, vec![ProjectType::Cargo]);
885 }
886
887 #[test]
888 fn detect_mixed_cargo_node() {
889 let dir = tempfile::tempdir().unwrap();
890 touch(dir.path(), "Cargo.toml", "");
891 touch(dir.path(), "package.json", "");
892 let types = detect_project_types(dir.path()).unwrap();
893 assert_eq!(types, vec![ProjectType::Cargo, ProjectType::Node]);
895 }
896
897 #[test]
898 fn detect_node_and_turborepo() {
899 let dir = tempfile::tempdir().unwrap();
900 touch(dir.path(), "package.json", "");
901 touch(dir.path(), "turbo.json", "");
902 let types = detect_project_types(dir.path()).unwrap();
903 assert_eq!(types, vec![ProjectType::Node, ProjectType::Turborepo]);
905 }
906
907 #[test]
908 fn detect_csproj_with_godot_is_godot4() {
909 let dir = tempfile::tempdir().unwrap();
910 touch(dir.path(), "something.csproj", "");
911 touch(dir.path(), "project.godot", "");
912 let types = detect_project_types(dir.path()).unwrap();
913 assert_eq!(types, vec![ProjectType::Godot4]);
915 }
916
917 #[test]
918 fn detect_assembly_csharp_is_unity() {
919 let dir = tempfile::tempdir().unwrap();
920 touch(dir.path(), "Assembly-CSharp.csproj", "");
921 let types = detect_project_types(dir.path()).unwrap();
922 assert_eq!(types, vec![ProjectType::Unity]);
923 }
924
925 #[test]
926 fn detect_plain_csproj_is_dotnet() {
927 let dir = tempfile::tempdir().unwrap();
928 touch(dir.path(), "app.csproj", "");
929 let types = detect_project_types(dir.path()).unwrap();
930 assert_eq!(types, vec![ProjectType::Dotnet]);
931 }
932
933 #[test]
934 fn detect_empty_directory_is_empty_vec() {
935 let dir = tempfile::tempdir().unwrap();
936 let types = detect_project_types(dir.path()).unwrap();
937 assert!(types.is_empty());
938 }
939
940 fn project_with(types: Vec<ProjectType>, path: &Path) -> Project {
945 Project {
946 project_types: types,
947 path: path.to_path_buf(),
948 }
949 }
950
951 #[test]
952 fn artifact_dirs_cargo_includes_target_and_xwin() {
953 let dir = tempfile::tempdir().unwrap();
954 let p = project_with(vec![ProjectType::Cargo], dir.path());
955 let dirs = p.artifact_dirs();
956 assert!(dirs.contains(&"target"));
957 assert!(dirs.contains(&".xwin-cache"));
958 }
959
960 #[test]
961 fn artifact_dirs_cargo_node_union() {
962 let dir = tempfile::tempdir().unwrap();
963 let p = project_with(vec![ProjectType::Cargo, ProjectType::Node], dir.path());
964 let dirs = p.artifact_dirs();
965 assert!(dirs.contains(&"target"));
966 assert!(dirs.contains(&"node_modules"));
967 assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
969 }
970
971 #[test]
972 fn artifact_dirs_sbt_maven_target_deduped() {
973 let dir = tempfile::tempdir().unwrap();
974 let p = project_with(vec![ProjectType::SBT, ProjectType::Maven], dir.path());
975 let dirs = p.artifact_dirs();
976 assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
978 }
979
980 #[test]
985 fn scan_finds_nested_subcrate_projects() {
986 let (_keep, root) = fresh_root();
987 touch(&root, "Cargo.toml", "");
989 touch(&root, "target/keep.o", "x");
990 let sub = root.join("crates").join("sub");
992 touch(&sub, "Cargo.toml", "");
993 touch(&sub, "target/keep.o", "y");
994
995 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
996 assert_eq!(projects.len(), 2);
999 assert!(projects.iter().any(|p| p.path == root));
1000 assert!(projects.iter().any(|p| p.path == sub));
1001 }
1002
1003 #[test]
1004 fn scan_prunes_artifact_directories() {
1005 let (_keep, root) = fresh_root();
1006 touch(root.join("target"), "Cargo.toml", "");
1009
1010 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1011 assert!(projects.is_empty());
1012 }
1013
1014 #[test]
1015 fn scan_skips_hidden_directories() {
1016 let (_keep, root) = fresh_root();
1017 let hidden = root.join(".hidden");
1020 fs::create_dir_all(&hidden).unwrap();
1021 touch(&hidden, "Cargo.toml", "");
1022
1023 let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1024 assert!(projects.is_empty());
1025 }
1026
1027 #[test]
1032 fn clean_removes_target() {
1033 let dir = tempfile::tempdir().unwrap();
1034 touch(dir.path(), "Cargo.toml", "");
1035 touch(dir.path(), "target/keep.o", "x");
1037 assert!(dir.path().join("target").exists());
1038
1039 let p = project_with(vec![ProjectType::Cargo], dir.path());
1040 p.clean().unwrap();
1041
1042 assert!(!dir.path().join("target").exists());
1043 }
1044
1045 #[test]
1050 fn dir_size_counts_file_contents() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let path = dir.path();
1053 touch(path, "a.txt", "hello world");
1054 let size = dir_size(&path, &opts());
1055 assert!(size > 0);
1056 }
1057
1058 #[test]
1063 fn analyze_skips_zero_artifact_projects() {
1064 let (_keep, root) = fresh_root();
1065 let a = root.join("a");
1067 fs::create_dir_all(&a).unwrap();
1068 touch(&a, "Cargo.toml", "");
1069 touch(&a, "target/build.o", "data");
1070 let b = root.join("b");
1072 fs::create_dir_all(&b).unwrap();
1073 touch(&b, "Cargo.toml", "");
1074
1075 let analyses: Vec<_> = analyze(&root, &opts()).collect();
1076 assert_eq!(analyses.len(), 1);
1078 assert!(analyses[0].artifact_size > 0);
1079 assert!(analyses[0].project.path.ends_with(a.file_name().unwrap()));
1080 }
1081
1082 #[test]
1083 fn analyze_reports_size_and_mtime_for_populated_project() {
1084 let (_keep, root) = fresh_root();
1085 let a = root.join("a");
1086 fs::create_dir_all(&a).unwrap();
1087 touch(&a, "Cargo.toml", "");
1088 touch(&a, "target/build.o", "12345");
1090
1091 let analyses: Vec<_> = analyze(&root, &opts()).collect();
1092 assert_eq!(analyses.len(), 1);
1093 let analysis = &analyses[0];
1094 assert_eq!(analysis.artifact_size, 5);
1096 assert!(analysis.last_modified.is_some());
1098 }
1099
1100 #[test]
1101 fn analyze_reports_nested_subcrate_separately() {
1102 let (_keep, root) = fresh_root();
1103 touch(&root, "Cargo.toml", "");
1105 touch(&root, "target/root.o", "RRRR"); let sub = root.join("crates").join("sub");
1108 touch(&sub, "Cargo.toml", "");
1109 touch(&sub, "target/sub.o", "SSSSSS"); let analyses: Vec<_> = analyze(&root, &opts()).collect();
1112 assert_eq!(analyses.len(), 2);
1115 let size_of = |p: &Path| {
1116 analyses
1117 .iter()
1118 .find(|a| a.project.path == p)
1119 .map(|a| a.artifact_size)
1120 };
1121 assert_eq!(size_of(&root), Some(4));
1122 assert_eq!(size_of(&sub), Some(6));
1123 }
1124}