Skip to main content

disk_cleaner/
projects.rs

1//! Locate build-tool artifact directories across many project types and tell
2//! you how much disk space they waste — optionally deleting them.
3//!
4//! A *project* is any directory containing a recognized marker file
5//! (`Cargo.toml`, `package.json`, `pom.xml`, …). Each project has one or more
6//! [`ProjectType`]s, and each type maps to a set of *artifact directories*
7//! (`target`, `node_modules`, `build`, …) that are safe to delete because the
8//! toolchain will regenerate them.
9//!
10//! A single directory may match several project types at once: a Rust + web
11//! project with both `Cargo.toml` and `package.json` is `{Cargo, Node}`, and
12//! [`Project::artifact_dirs`] yields the **union** of their artifact
13//! directories (`target` + `node_modules`).
14//!
15//! This module performs no destructive actions on its own — you must call
16//! [`Project::clean`] explicitly to delete anything.
17//!
18//! # Example
19//!
20//! ```no_run
21//! use disk_cleaner::projects::{scan, ScanOptions};
22//!
23//! let opts = ScanOptions { follow_symlinks: false, same_file_system: false };
24//! for project in scan(&".", &opts).filter_map(Result::ok) {
25//!     println!("{} [{}]", project.path.display(), project.type_name());
26//! }
27//! ```
28//!
29//! [`ProjectType`]: ProjectType
30//! [`Project::artifact_dirs`]: Project::artifact_dirs
31//! [`Project::clean`]: Project::clean
32
33use 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/// A recognized development-project ecosystem (Cargo, Node, Unity, …).
154///
155/// A single directory may be classified as several `ProjectType`s at once —
156/// for example a directory with both `Cargo.toml` and `package.json` is
157/// `{Cargo, Node}`. Variant ordering follows declaration order and is used to
158/// keep `type_name()` and similar output stable and deterministic rather than
159/// dependent on filesystem `readdir` order.
160#[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/// A discovered project: a directory on disk plus the set of project types
189/// detected within it.
190///
191/// Obtain one via [`scan`](scan), or construct it directly. The struct is
192/// otherwise inert — call [`clean`](Project::clean) to delete the project's
193/// artifact directories.
194#[derive(Debug, Clone)]
195pub struct Project {
196    /// Every project type detected in `path` (at least one). Ordered by
197    /// `ProjectType` declaration order, de-duplicated.
198    pub project_types: Vec<ProjectType>,
199    /// The project's root directory.
200    pub path: path::PathBuf,
201}
202
203/// A breakdown of a project directory's disk usage, produced by
204/// [`Project::size_dirs`].
205#[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    /// The de-duplicated union of artifact directories across all detected
282    /// project types in this directory (e.g. a Cargo+Node project yields both
283    /// `target` and `node_modules`).
284    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    /// The project's path as a lossy UTF-8 string (for display).
297    pub fn name(&self) -> Cow<'_, str> {
298        self.path.to_string_lossy()
299    }
300
301    /// Total size in bytes of this project's artifact directories — the amount
302    /// [`clean`](Project::clean) would reclaim.
303    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    /// The most recent modification time across all entries in the project tree.
312    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    /// Per-top-level-entry disk usage for the project: total artifact bytes,
326    /// total non-artifact bytes, and a list of `(name, size, is_artifact)`.
327    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    /// Human-readable project-type label(s), joined by `" / "` — e.g.
380    /// `"Cargo"` or `"Cargo / Node"`.
381    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    /// Deletes the project's artifact directories and their contents.
390    ///
391    /// Every artifact directory is attempted even if an earlier one fails; the
392    /// first error (if any) is returned so callers — e.g. a TUI — can surface
393    /// it. Returns `Ok(())` when all present artifact directories were removed.
394    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
424/// Union of every artifact-directory name across all [`ProjectType`]s.
425///
426/// [`scan`] prunes any directory whose name matches, so it can descend into a
427/// project's real subdirectories — finding **nested** projects such as a Cargo
428/// workspace's sub-crates, each of which may carry its own reclaimable
429/// artifacts — without descending into heavy build-output trees (`target`,
430/// `node_modules`, …). Descending into `node_modules` in particular would
431/// otherwise report every vendored package as a project.
432///
433/// Names starting with `.` are also pruned by [`is_hidden`], but are listed
434/// here too so pruning does not silently depend on that coincidence. Kept as a
435/// flat const (rather than derived from the per-type arrays) for readability;
436/// the project-type list is stable.
437const ALL_ARTIFACT_DIR_NAMES: &[&str] = &[
438    // Cargo
439    "target",
440    ".xwin-cache",
441    // Node (incl. React Native leaf names)
442    "node_modules",
443    ".angular",
444    ".expo",
445    ".metro",
446    // Unity
447    "Library",
448    "Temp",
449    "Obj",
450    "Logs",
451    "MemoryCaptures",
452    "Build",
453    "Builds",
454    // Haskell
455    ".stack-work",
456    "dist-newstyle",
457    // SBT / Maven
458    // Gradle / CMake
459    "build",
460    ".gradle",
461    "cmake-build-debug",
462    "cmake-build-release",
463    // Unreal
464    "Binaries",
465    "Saved",
466    "DerivedDataCache",
467    "Intermediate",
468    // Jupyter / Python
469    ".ipynb_checkpoints",
470    ".mypy_cache",
471    ".nox",
472    ".pytest_cache",
473    ".ruff_cache",
474    ".tox",
475    "__pycache__",
476    "__pypackages__",
477    // Misc ecosystems
478    ".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
498/// Whether `name` is a known artifact (build-output) directory name — i.e. a
499/// subtree [`scan`] should never descend into when hunting for nested projects.
500fn 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/// Errors that can occur while scanning a directory tree for projects.
509#[derive(Debug)]
510pub enum ScanError {
511    /// A plain [`std::io::Error`], e.g. lacking permission to read a directory.
512    IOError(::std::io::Error),
513    /// An error from the underlying `walkdir` traversal.
514    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            // Prune hidden dirs and artifact (build-output) dirs: never descend
549            // into them when searching for nested projects. Artifact dirs are
550            // build output (target/, node_modules/, …) — descending into
551            // node_modules would report every vendored package as a project.
552            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            // Emit the project but KEEP descending into its subtree: a project
562            // may contain nested projects (e.g. a Cargo workspace's sub-crates)
563            // that carry their own reclaimable artifacts. Artifact subdirs were
564            // already pruned above, so this never descends into build output.
565            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
587/// Detect every project type whose marker file is present in `path`.
588///
589/// A directory may legitimately contain markers for more than one ecosystem
590/// (e.g. `Cargo.toml` + `package.json`); all of them are returned so the
591/// caller can clean the union of their artifact directories.
592///
593/// The directory is read exactly once: every file name is collected into a
594/// `Vec` first (rather than classified on the fly) because a `.csproj`/`.fsproj`
595/// may be visited before `project.godot` or `Assembly-CSharp.csproj`. Collecting
596/// everything first lets the disambiguation between Godot4 / Unity / .NET be
597/// done with precomputed flags after the full picture of the directory is known.
598fn detect_project_types(path: &Path) -> Result<Vec<ProjectType>, std::io::Error> {
599    // Single read_dir: collect every file name and precompute the two flags the
600    // csproj/fsproj branch needs. This avoids re-reading the directory once per
601    // csproj file (a Unity project commonly has 20+ of them).
602    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/// Options controlling directory traversal, passed to [`scan`](scan) and
673/// [`dir_size`](dir_size).
674#[derive(Clone, Debug)]
675pub struct ScanOptions {
676    /// Whether to follow symbolic links during traversal.
677    pub follow_symlinks: bool,
678    /// Whether to restrict traversal to the same filesystem as the root.
679    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
691/// Recursively scan `path` for projects, yielding each wrapped in a [`Result`].
692///
693/// Hidden directories (name starting with `.`) and artifact directories
694/// (`target`, `node_modules`, `build`, … — see [`ALL_ARTIFACT_DIR_NAMES`]) are
695/// never descended into. Otherwise the traversal keeps descending even after
696/// finding a project, so **nested** projects are reported too — a Cargo
697/// workspace and each of its sub-crates, for example, are emitted separately
698/// (sub-crates with no artifacts of their own are dropped by [`analyze`]).
699///
700/// Traversal errors are reported per-entry via [`ScanError`] but do not stop
701/// iteration: use `filter_map(Result::ok)` to ignore them, or handle them
702/// explicitly.
703///
704/// [`ScanError`]: ScanError
705pub 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
712/// Total size in bytes of all regular files beneath `path` (recursive),
713/// traversed with the same options as [`scan`].
714///
715/// [`scan`]: scan
716pub 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/// A project plus the results of analyzing it, produced by [`analyze`].
727#[derive(Debug, Clone)]
728pub struct ProjectAnalysis {
729    /// The discovered project.
730    pub project: Project,
731    /// Total bytes across the project's artifact directories — what
732    /// [`Project::clean`] would reclaim.
733    pub artifact_size: u64,
734    /// Most recent modification time across the project tree, if obtainable.
735    pub last_modified: Option<SystemTime>,
736}
737
738/// Compute a project's total artifact-directory size and most recent
739/// modification time in a **single** tree walk.
740///
741/// `artifact_size` sums the sizes of every regular file that lives beneath one
742/// of the project's [`artifact_dirs`](Project::artifact_dirs). `last_modified`
743/// is the maximum `mtime` across *all* files in the project tree (not just
744/// artifacts) — or `None` if no file's mtime could be read.
745///
746/// This replaces the older `analyze` path which walked the tree three times
747/// (`Project::size` + `Project::last_modified`, plus the scan walk). The
748/// artifact test is O(artifact_dirs) per file, which is small in practice.
749fn project_size_and_mtime(project: &Project, options: &ScanOptions) -> (u64, Option<SystemTime>) {
750    // Precompute the absolute artifact-directory prefixes once so the per-file
751    // `starts_with` check doesn't re-join on every entry.
752    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        // Artifact accounting: only count files beneath an artifact directory.
776        if artifact_prefixes
777            .iter()
778            .any(|prefix| entry.path().starts_with(prefix))
779        {
780            total_artifact_size += metadata.len();
781        }
782
783        // mtime accounting: every file, not just artifacts.
784        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
792/// Scan `path` for projects and compute each one's reclaimable size and last
793/// modification time — a streaming convenience over [`scan`] +
794/// [`project_size_and_mtime`].
795///
796/// Each project is yielded as soon as it is found and analyzed in a single
797/// merged tree walk (size + mtime together, rather than the older separate
798/// `Project::size` + `Project::last_modified` calls). Projects that error or
799/// have zero reclaimable bytes are omitted, matching the kondo CLI's behavior.
800/// For error-aware use, drive [`scan`] directly.
801///
802/// [`scan`]: scan
803pub 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
821/// Recursively delete every artifact directory of the project at `project_path`.
822///
823/// Does nothing if `project_path` is not a recognized project. Convenience
824/// wrapper around [`Project::clean`]; for finer control, use [`scan`] and call
825/// [`Project::clean`] on the resulting [`Project`]s directly.
826pub 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    /// Shared default options used across the scan/dir_size tests.
848    fn opts() -> ScanOptions {
849        ScanOptions {
850            follow_symlinks: false,
851            same_file_system: false,
852        }
853    }
854
855    /// Create a non-hidden scratch directory under the system temp dir.
856    ///
857    /// `tempfile::tempdir()` itself names its directory `.tmpXXXX`, which
858    /// `kondo` treats as hidden (name starts with `.`), so `scan` would skip
859    /// it entirely. For scan/analyze tests we need a root whose own name does
860    /// not start with a dot.
861    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    /// Write `contents` to `dir/<name>`, creating parent dirs as needed.
869    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    // ------------------------------------------------------------------
876    // detect_project_types
877    // ------------------------------------------------------------------
878
879    #[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        // Declaration order has Cargo before Node, and that survives sort.
894        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        // Node is declared before Turborepo, so sort leaves them in this order.
904        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        // A .csproj with a project.godot present classifies as Godot4.
914        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    // ------------------------------------------------------------------
941    // Project::artifact_dirs
942    // ------------------------------------------------------------------
943
944    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        // No duplicates of shared dirs.
968        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        // Both SBT and Maven use `target`; it must appear exactly once.
977        assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
978    }
979
980    // ------------------------------------------------------------------
981    // scan
982    // ------------------------------------------------------------------
983
984    #[test]
985    fn scan_finds_nested_subcrate_projects() {
986        let (_keep, root) = fresh_root();
987        // A workspace root with its shared target/ ...
988        touch(&root, "Cargo.toml", "");
989        touch(&root, "target/keep.o", "x");
990        // ... and a sub-crate that has its OWN target/ (built independently).
991        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        // Both the root and the nested sub-crate are reported (the old code
997        // skipped the whole root subtree and missed the sub-crate).
998        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        // A Cargo.toml placed INSIDE a target/ dir must never be reported:
1007        // target/ is build output and pruned from the scan descent.
1008        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        // Root itself has no marker file, so we only check that the hidden
1018        // subtree is never descended into.
1019        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    // ------------------------------------------------------------------
1028    // Project::clean
1029    // ------------------------------------------------------------------
1030
1031    #[test]
1032    fn clean_removes_target() {
1033        let dir = tempfile::tempdir().unwrap();
1034        touch(dir.path(), "Cargo.toml", "");
1035        // Populate a target/ artifact directory with a real file.
1036        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    // ------------------------------------------------------------------
1046    // dir_size
1047    // ------------------------------------------------------------------
1048
1049    #[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    // ------------------------------------------------------------------
1059    // analyze
1060    // ------------------------------------------------------------------
1061
1062    #[test]
1063    fn analyze_skips_zero_artifact_projects() {
1064        let (_keep, root) = fresh_root();
1065        // Project A: has a non-empty target/ artifact directory.
1066        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        // Project B: only a marker, no artifact directory => 0 reclaimable.
1071        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        // Only the project with reclaimable bytes should be reported.
1077        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        // A real artifact file whose size we can reason about.
1089        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        // The single 5-byte artifact file must be counted.
1095        assert_eq!(analysis.artifact_size, 5);
1096        // mtime must be obtainable for a freshly written file.
1097        assert!(analysis.last_modified.is_some());
1098    }
1099
1100    #[test]
1101    fn analyze_reports_nested_subcrate_separately() {
1102        let (_keep, root) = fresh_root();
1103        // Workspace root with a shared target/.
1104        touch(&root, "Cargo.toml", "");
1105        touch(&root, "target/root.o", "RRRR"); // 4 bytes
1106        // A sub-crate with its OWN independent target/.
1107        let sub = root.join("crates").join("sub");
1108        touch(&sub, "Cargo.toml", "");
1109        touch(&sub, "target/sub.o", "SSSSSS"); // 6 bytes
1110
1111        let analyses: Vec<_> = analyze(&root, &opts()).collect();
1112        // Both are reported, each with its OWN artifact size — the root must
1113        // not absorb the sub-crate's target into its own total.
1114        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}