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, apparent: true };
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    /// When `true`, count each file's logical length (`metadata.len()`); when
681    /// `false`, count its on-disk allocation (Unix `blocks()*512`, Windows
682    /// compressed size).
683    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
695/// Recursively scan `path` for projects, yielding each wrapped in a [`Result`].
696///
697/// Hidden directories (name starting with `.`) and artifact directories
698/// (`target`, `node_modules`, `build`, … — see [`ALL_ARTIFACT_DIR_NAMES`]) are
699/// never descended into. Otherwise the traversal keeps descending even after
700/// finding a project, so **nested** projects are reported too — a Cargo
701/// workspace and each of its sub-crates, for example, are emitted separately
702/// (sub-crates with no artifacts of their own are dropped by [`analyze`]).
703///
704/// Traversal errors are reported per-entry via [`ScanError`] but do not stop
705/// iteration: use `filter_map(Result::ok)` to ignore them, or handle them
706/// explicitly.
707///
708/// [`ScanError`]: ScanError
709pub 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
716/// Single file's counted size: its logical length when `apparent`, otherwise
717/// its on-disk allocation. Mirrors the size semantics of `FileInfo::from_path`
718/// but operates on borrowed metadata already obtained by walkdir (no re-stat).
719fn 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
743/// Total size in bytes of all regular files beneath `path` (recursive),
744/// traversed with the same options as [`scan`].
745///
746/// [`scan`]: scan
747pub 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/// A project plus the results of analyzing it, produced by [`analyze`].
760#[derive(Debug, Clone)]
761pub struct ProjectAnalysis {
762    /// The discovered project.
763    pub project: Project,
764    /// Total bytes across the project's artifact directories — what
765    /// [`Project::clean`] would reclaim.
766    pub artifact_size: u64,
767    /// Most recent modification time across the project tree, if obtainable.
768    pub last_modified: Option<SystemTime>,
769}
770
771/// Compute a project's total artifact-directory size and most recent
772/// modification time in a **single** tree walk.
773///
774/// `artifact_size` sums the sizes of every regular file that lives beneath one
775/// of the project's [`artifact_dirs`](Project::artifact_dirs). `last_modified`
776/// is the maximum `mtime` across *all* files in the project tree (not just
777/// artifacts) — or `None` if no file's mtime could be read.
778///
779/// This replaces the older `analyze` path which walked the tree three times
780/// (`Project::size` + `Project::last_modified`, plus the scan walk). The
781/// artifact test is O(artifact_dirs) per file, which is small in practice.
782fn project_size_and_mtime(project: &Project, options: &ScanOptions) -> (u64, Option<SystemTime>) {
783    // Precompute the absolute artifact-directory prefixes once so the per-file
784    // `starts_with` check doesn't re-join on every entry.
785    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        // Artifact accounting: only count files beneath an artifact directory.
809        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        // mtime accounting: every file, not just artifacts.
817        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
825/// Scan `path` for projects and compute each one's reclaimable size and last
826/// modification time — a streaming convenience over [`scan`] +
827/// [`project_size_and_mtime`].
828///
829/// Each project is yielded as soon as it is found and analyzed in a single
830/// merged tree walk (size + mtime together, rather than the older separate
831/// `Project::size` + `Project::last_modified` calls). Projects that error or
832/// have zero reclaimable bytes are omitted, matching the kondo CLI's behavior.
833/// For error-aware use, drive [`scan`] directly.
834///
835/// [`scan`]: scan
836pub 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
854/// Recursively delete every artifact directory of the project at `project_path`.
855///
856/// Does nothing if `project_path` is not a recognized project. Convenience
857/// wrapper around [`Project::clean`]; for finer control, use [`scan`] and call
858/// [`Project::clean`] on the resulting [`Project`]s directly.
859pub 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    /// Shared default options used across the scan/dir_size tests.
881    fn opts() -> ScanOptions {
882        ScanOptions {
883            follow_symlinks: false,
884            same_file_system: false,
885            apparent: true,
886        }
887    }
888
889    /// Create a non-hidden scratch directory under the system temp dir.
890    ///
891    /// `tempfile::tempdir()` itself names its directory `.tmpXXXX`, which
892    /// `kondo` treats as hidden (name starts with `.`), so `scan` would skip
893    /// it entirely. For scan/analyze tests we need a root whose own name does
894    /// not start with a dot.
895    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    /// Write `contents` to `dir/<name>`, creating parent dirs as needed.
903    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    // ------------------------------------------------------------------
910    // detect_project_types
911    // ------------------------------------------------------------------
912
913    #[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        // Declaration order has Cargo before Node, and that survives sort.
928        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        // Node is declared before Turborepo, so sort leaves them in this order.
938        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        // A .csproj with a project.godot present classifies as Godot4.
948        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    // ------------------------------------------------------------------
975    // Project::artifact_dirs
976    // ------------------------------------------------------------------
977
978    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        // No duplicates of shared dirs.
1002        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        // Both SBT and Maven use `target`; it must appear exactly once.
1011        assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
1012    }
1013
1014    // ------------------------------------------------------------------
1015    // scan
1016    // ------------------------------------------------------------------
1017
1018    #[test]
1019    fn scan_finds_nested_subcrate_projects() {
1020        let (_keep, root) = fresh_root();
1021        // A workspace root with its shared target/ ...
1022        touch(&root, "Cargo.toml", "");
1023        touch(&root, "target/keep.o", "x");
1024        // ... and a sub-crate that has its OWN target/ (built independently).
1025        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        // Both the root and the nested sub-crate are reported (the old code
1031        // skipped the whole root subtree and missed the sub-crate).
1032        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        // A Cargo.toml placed INSIDE a target/ dir must never be reported:
1041        // target/ is build output and pruned from the scan descent.
1042        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        // Root itself has no marker file, so we only check that the hidden
1052        // subtree is never descended into.
1053        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    // ------------------------------------------------------------------
1062    // Project::clean
1063    // ------------------------------------------------------------------
1064
1065    #[test]
1066    fn clean_removes_target() {
1067        let dir = tempfile::tempdir().unwrap();
1068        touch(dir.path(), "Cargo.toml", "");
1069        // Populate a target/ artifact directory with a real file.
1070        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    // ------------------------------------------------------------------
1080    // dir_size
1081    // ------------------------------------------------------------------
1082
1083    #[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    // ------------------------------------------------------------------
1093    // analyze
1094    // ------------------------------------------------------------------
1095
1096    #[test]
1097    fn analyze_skips_zero_artifact_projects() {
1098        let (_keep, root) = fresh_root();
1099        // Project A: has a non-empty target/ artifact directory.
1100        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        // Project B: only a marker, no artifact directory => 0 reclaimable.
1105        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        // Only the project with reclaimable bytes should be reported.
1111        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        // A real artifact file whose size we can reason about.
1123        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        // The single 5-byte artifact file must be counted.
1129        assert_eq!(analysis.artifact_size, 5);
1130        // mtime must be obtainable for a freshly written file.
1131        assert!(analysis.last_modified.is_some());
1132    }
1133
1134    #[test]
1135    fn analyze_reports_nested_subcrate_separately() {
1136        let (_keep, root) = fresh_root();
1137        // Workspace root with a shared target/.
1138        touch(&root, "Cargo.toml", "");
1139        touch(&root, "target/root.o", "RRRR"); // 4 bytes
1140        // A sub-crate with its OWN independent target/.
1141        let sub = root.join("crates").join("sub");
1142        touch(&sub, "Cargo.toml", "");
1143        touch(&sub, "target/sub.o", "SSSSSS"); // 6 bytes
1144
1145        let analyses: Vec<_> = analyze(&root, &opts()).collect();
1146        // Both are reported, each with its OWN artifact size — the root must
1147        // not absorb the sub-crate's target into its own total.
1148        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}