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/// Running byte counter that deduplicates hardlinks, mirroring `du`'s default
744/// behavior: a file with more than one directory entry is counted exactly once
745/// — on its first appearance — instead of once per link.
746///
747/// This matters because Cargo's `target/` hardlinks every build script and
748/// binary under both `target/debug/` and `target/debug/deps/`. Summing each
749/// directory entry naively counts the same blocks twice, so the "reclaimable"
750/// total balloons past what `df` reports as actually on disk. With dedup, the
751/// total matches `du`/`df` and reflects the bytes truly freed by deleting the
752/// artifact directory.
753///
754/// Only multiply-linked files consult the seen-inode set, so the common
755/// `nlink == 1` case stays out of the (contended) lock. The set lives behind a
756/// `Mutex` so a single counter can be shared across the parallel
757/// [`DiskItem::from_analyze`](crate::DiskItem::from_analyze) tree walk via
758/// `Arc`. On platforms without a cheap inode query (Windows, where Cargo
759/// doesn't hardlink) every file is counted, preserving the prior behavior.
760pub(crate) struct FileSizeCounter {
761    apparent: bool,
762    #[cfg(unix)]
763    seen: std::sync::Mutex<std::collections::HashSet<(u64, u64)>>,
764}
765
766impl FileSizeCounter {
767    pub(crate) fn new(apparent: bool) -> Self {
768        Self {
769            apparent,
770            #[cfg(unix)]
771            seen: std::sync::Mutex::new(std::collections::HashSet::new()),
772        }
773    }
774
775    /// Account for one file, returning its contribution to the running total:
776    /// its full size when newly seen (or singly-linked), `0` when it is a
777    /// hardlink whose inode was already counted. `path` is needed for the
778    /// non-apparent (on-disk allocation) size on Windows.
779    pub(crate) fn add(&self, md: &fs::Metadata, path: &Path) -> u64 {
780        #[cfg(unix)]
781        {
782            use std::os::unix::fs::MetadataExt;
783            // Only files with more than one link can be double-counted; track
784            // just those to keep the lock uncontended for the common case.
785            if md.nlink() > 1 {
786                let mut seen = self.seen.lock().expect("FileSizeCounter mutex poisoned");
787                if !seen.insert((md.dev(), md.ino())) {
788                    return 0;
789                }
790            }
791        }
792        file_size(md, path, self.apparent)
793    }
794}
795
796/// Total size in bytes of all regular files beneath `path` (recursive),
797/// traversed with the same options as [`scan`].
798///
799/// [`scan`]: scan
800pub fn dir_size<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> u64 {
801    let counter = FileSizeCounter::new(options.apparent);
802    build_walkdir_iter(path, options)
803        .it
804        .filter_map(|e| e.ok())
805        .filter(|e| e.file_type().is_file())
806        .filter_map(|e| {
807            let md = e.metadata().ok()?;
808            Some(counter.add(&md, e.path()))
809        })
810        .sum()
811}
812
813/// A project plus the results of analyzing it, produced by [`analyze`].
814#[derive(Debug, Clone)]
815pub struct ProjectAnalysis {
816    /// The discovered project.
817    pub project: Project,
818    /// Total bytes across the project's artifact directories — what
819    /// [`Project::clean`] would reclaim.
820    pub artifact_size: u64,
821    /// Most recent modification time across the project tree, if obtainable.
822    pub last_modified: Option<SystemTime>,
823}
824
825/// Compute a project's total artifact-directory size and most recent
826/// modification time in a **single** tree walk.
827///
828/// `artifact_size` sums the sizes of every regular file that lives beneath one
829/// of the project's [`artifact_dirs`](Project::artifact_dirs). `last_modified`
830/// is the maximum `mtime` across *all* files in the project tree (not just
831/// artifacts) — or `None` if no file's mtime could be read.
832///
833/// This replaces the older `analyze` path which walked the tree three times
834/// (`Project::size` + `Project::last_modified`, plus the scan walk). The
835/// artifact test is O(artifact_dirs) per file, which is small in practice.
836fn project_size_and_mtime(project: &Project, options: &ScanOptions) -> (u64, Option<SystemTime>) {
837    // Precompute the absolute artifact-directory prefixes once so the per-file
838    // `starts_with` check doesn't re-join on every entry.
839    let artifact_prefixes: Vec<path::PathBuf> = project
840        .artifact_dirs()
841        .into_iter()
842        .map(|d| project.path.join(d))
843        .collect();
844
845    let mut total_artifact_size: u64 = 0;
846    let mut latest_mtime: Option<SystemTime> = None;
847    let counter = FileSizeCounter::new(options.apparent);
848
849    for entry in walkdir::WalkDir::new(&project.path)
850        .follow_links(options.follow_symlinks)
851        .same_file_system(options.same_file_system)
852        .into_iter()
853        .filter_map(|e| e.ok())
854    {
855        let metadata = match entry.metadata() {
856            Ok(m) => m,
857            Err(_) => continue,
858        };
859        if !metadata.is_file() {
860            continue;
861        }
862
863        // Artifact accounting: only count files beneath an artifact directory.
864        // `counter.add` dedups hardlinks so a file linked under two paths inside
865        // the artifact tree (Cargo does this for build scripts/binaries) counts
866        // once — matching the bytes truly freed by deleting the directory.
867        if artifact_prefixes
868            .iter()
869            .any(|prefix| entry.path().starts_with(prefix))
870        {
871            total_artifact_size += counter.add(&metadata, entry.path());
872        }
873
874        // mtime accounting: every file, not just artifacts.
875        if let Ok(modified) = metadata.modified() {
876            latest_mtime = Some(latest_mtime.map_or(modified, |prev| prev.max(modified)));
877        }
878    }
879
880    (total_artifact_size, latest_mtime)
881}
882
883/// Scan `path` for projects and compute each one's reclaimable size and last
884/// modification time — a streaming convenience over [`scan`] +
885/// [`project_size_and_mtime`].
886///
887/// Each project is yielded as soon as it is found and analyzed in a single
888/// merged tree walk (size + mtime together, rather than the older separate
889/// `Project::size` + `Project::last_modified` calls). Projects that error or
890/// have zero reclaimable bytes are omitted, matching the kondo CLI's behavior.
891/// For error-aware use, drive [`scan`] directly.
892///
893/// [`scan`]: scan
894pub fn analyze<'a, P: AsRef<Path>>(
895    path: &'a P,
896    options: &'a ScanOptions,
897) -> impl Iterator<Item = ProjectAnalysis> + use<'a, P> {
898    scan(path, options).filter_map(|project| {
899        let project = project.ok()?;
900        let (artifact_size, last_modified) = project_size_and_mtime(&project, options);
901        if artifact_size == 0 {
902            return None;
903        }
904        Some(ProjectAnalysis {
905            project,
906            artifact_size,
907            last_modified,
908        })
909    })
910}
911
912/// Recursively delete every artifact directory of the project at `project_path`.
913///
914/// Does nothing if `project_path` is not a recognized project. Convenience
915/// wrapper around [`Project::clean`]; for finer control, use [`scan`] and call
916/// [`Project::clean`] on the resulting [`Project`]s directly.
917pub fn clean(project_path: &Path) -> Result<(), Box<dyn error::Error>> {
918    let project_types = detect_project_types(project_path)?;
919    if project_types.is_empty() {
920        return Ok(());
921    }
922    let project = Project {
923        project_types,
924        path: project_path.to_path_buf(),
925    };
926    project.clean()?;
927
928    Ok(())
929}
930#[cfg(test)]
931mod tests {
932    use super::{
933        analyze, detect_project_types, dir_size, scan, Project, ProjectType, ScanOptions,
934    };
935    use std::fs;
936    use std::path::{Path, PathBuf};
937
938    /// Shared default options used across the scan/dir_size tests.
939    fn opts() -> ScanOptions {
940        ScanOptions {
941            follow_symlinks: false,
942            same_file_system: false,
943            apparent: true,
944        }
945    }
946
947    /// Create a non-hidden scratch directory under the system temp dir.
948    ///
949    /// `tempfile::tempdir()` itself names its directory `.tmpXXXX`, which
950    /// `kondo` treats as hidden (name starts with `.`), so `scan` would skip
951    /// it entirely. For scan/analyze tests we need a root whose own name does
952    /// not start with a dot.
953    fn fresh_root() -> (tempfile::TempDir, PathBuf) {
954        let parent = tempfile::tempdir().unwrap();
955        let root = parent.path().join("kondo-test-root");
956        fs::create_dir_all(&root).unwrap();
957        (parent, root)
958    }
959
960    /// Write `contents` to `dir/<name>`, creating parent dirs as needed.
961    fn touch<P: AsRef<Path>>(dir: P, name: &str, contents: &str) {
962        let path = dir.as_ref().join(name);
963        fs::create_dir_all(path.parent().unwrap()).unwrap();
964        fs::write(path, contents).unwrap();
965    }
966
967    // ------------------------------------------------------------------
968    // detect_project_types
969    // ------------------------------------------------------------------
970
971    #[test]
972    fn detect_cargo_only() {
973        let dir = tempfile::tempdir().unwrap();
974        touch(dir.path(), "Cargo.toml", "");
975        let types = detect_project_types(dir.path()).unwrap();
976        assert_eq!(types, vec![ProjectType::Cargo]);
977    }
978
979    #[test]
980    fn detect_mixed_cargo_node() {
981        let dir = tempfile::tempdir().unwrap();
982        touch(dir.path(), "Cargo.toml", "");
983        touch(dir.path(), "package.json", "");
984        let types = detect_project_types(dir.path()).unwrap();
985        // Declaration order has Cargo before Node, and that survives sort.
986        assert_eq!(types, vec![ProjectType::Cargo, ProjectType::Node]);
987    }
988
989    #[test]
990    fn detect_node_and_turborepo() {
991        let dir = tempfile::tempdir().unwrap();
992        touch(dir.path(), "package.json", "");
993        touch(dir.path(), "turbo.json", "");
994        let types = detect_project_types(dir.path()).unwrap();
995        // Node is declared before Turborepo, so sort leaves them in this order.
996        assert_eq!(types, vec![ProjectType::Node, ProjectType::Turborepo]);
997    }
998
999    #[test]
1000    fn detect_csproj_with_godot_is_godot4() {
1001        let dir = tempfile::tempdir().unwrap();
1002        touch(dir.path(), "something.csproj", "");
1003        touch(dir.path(), "project.godot", "");
1004        let types = detect_project_types(dir.path()).unwrap();
1005        // A .csproj with a project.godot present classifies as Godot4.
1006        assert_eq!(types, vec![ProjectType::Godot4]);
1007    }
1008
1009    #[test]
1010    fn detect_assembly_csharp_is_unity() {
1011        let dir = tempfile::tempdir().unwrap();
1012        touch(dir.path(), "Assembly-CSharp.csproj", "");
1013        let types = detect_project_types(dir.path()).unwrap();
1014        assert_eq!(types, vec![ProjectType::Unity]);
1015    }
1016
1017    #[test]
1018    fn detect_plain_csproj_is_dotnet() {
1019        let dir = tempfile::tempdir().unwrap();
1020        touch(dir.path(), "app.csproj", "");
1021        let types = detect_project_types(dir.path()).unwrap();
1022        assert_eq!(types, vec![ProjectType::Dotnet]);
1023    }
1024
1025    #[test]
1026    fn detect_empty_directory_is_empty_vec() {
1027        let dir = tempfile::tempdir().unwrap();
1028        let types = detect_project_types(dir.path()).unwrap();
1029        assert!(types.is_empty());
1030    }
1031
1032    // ------------------------------------------------------------------
1033    // Project::artifact_dirs
1034    // ------------------------------------------------------------------
1035
1036    fn project_with(types: Vec<ProjectType>, path: &Path) -> Project {
1037        Project {
1038            project_types: types,
1039            path: path.to_path_buf(),
1040        }
1041    }
1042
1043    #[test]
1044    fn artifact_dirs_cargo_includes_target_and_xwin() {
1045        let dir = tempfile::tempdir().unwrap();
1046        let p = project_with(vec![ProjectType::Cargo], dir.path());
1047        let dirs = p.artifact_dirs();
1048        assert!(dirs.contains(&"target"));
1049        assert!(dirs.contains(&".xwin-cache"));
1050    }
1051
1052    #[test]
1053    fn artifact_dirs_cargo_node_union() {
1054        let dir = tempfile::tempdir().unwrap();
1055        let p = project_with(vec![ProjectType::Cargo, ProjectType::Node], dir.path());
1056        let dirs = p.artifact_dirs();
1057        assert!(dirs.contains(&"target"));
1058        assert!(dirs.contains(&"node_modules"));
1059        // No duplicates of shared dirs.
1060        assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
1061    }
1062
1063    #[test]
1064    fn artifact_dirs_sbt_maven_target_deduped() {
1065        let dir = tempfile::tempdir().unwrap();
1066        let p = project_with(vec![ProjectType::SBT, ProjectType::Maven], dir.path());
1067        let dirs = p.artifact_dirs();
1068        // Both SBT and Maven use `target`; it must appear exactly once.
1069        assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
1070    }
1071
1072    // ------------------------------------------------------------------
1073    // scan
1074    // ------------------------------------------------------------------
1075
1076    #[test]
1077    fn scan_finds_nested_subcrate_projects() {
1078        let (_keep, root) = fresh_root();
1079        // A workspace root with its shared target/ ...
1080        touch(&root, "Cargo.toml", "");
1081        touch(&root, "target/keep.o", "x");
1082        // ... and a sub-crate that has its OWN target/ (built independently).
1083        let sub = root.join("crates").join("sub");
1084        touch(&sub, "Cargo.toml", "");
1085        touch(&sub, "target/keep.o", "y");
1086
1087        let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1088        // Both the root and the nested sub-crate are reported (the old code
1089        // skipped the whole root subtree and missed the sub-crate).
1090        assert_eq!(projects.len(), 2);
1091        assert!(projects.iter().any(|p| p.path == root));
1092        assert!(projects.iter().any(|p| p.path == sub));
1093    }
1094
1095    #[test]
1096    fn scan_prunes_artifact_directories() {
1097        let (_keep, root) = fresh_root();
1098        // A Cargo.toml placed INSIDE a target/ dir must never be reported:
1099        // target/ is build output and pruned from the scan descent.
1100        touch(root.join("target"), "Cargo.toml", "");
1101
1102        let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1103        assert!(projects.is_empty());
1104    }
1105
1106    #[test]
1107    fn scan_skips_hidden_directories() {
1108        let (_keep, root) = fresh_root();
1109        // Root itself has no marker file, so we only check that the hidden
1110        // subtree is never descended into.
1111        let hidden = root.join(".hidden");
1112        fs::create_dir_all(&hidden).unwrap();
1113        touch(&hidden, "Cargo.toml", "");
1114
1115        let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
1116        assert!(projects.is_empty());
1117    }
1118
1119    // ------------------------------------------------------------------
1120    // Project::clean
1121    // ------------------------------------------------------------------
1122
1123    #[test]
1124    fn clean_removes_target() {
1125        let dir = tempfile::tempdir().unwrap();
1126        touch(dir.path(), "Cargo.toml", "");
1127        // Populate a target/ artifact directory with a real file.
1128        touch(dir.path(), "target/keep.o", "x");
1129        assert!(dir.path().join("target").exists());
1130
1131        let p = project_with(vec![ProjectType::Cargo], dir.path());
1132        p.clean().unwrap();
1133
1134        assert!(!dir.path().join("target").exists());
1135    }
1136
1137    // ------------------------------------------------------------------
1138    // dir_size
1139    // ------------------------------------------------------------------
1140
1141    #[test]
1142    fn dir_size_counts_file_contents() {
1143        let dir = tempfile::tempdir().unwrap();
1144        let path = dir.path();
1145        touch(path, "a.txt", "hello world");
1146        let size = dir_size(&path, &opts());
1147        assert!(size > 0);
1148    }
1149
1150    /// A hardlinked file is a single inode reachable under two names. `dir_size`
1151    /// must count its bytes once, not once per link — otherwise Cargo-style
1152    /// `target/` trees (which hardlink build scripts and binaries) are
1153    /// overcounted and the "reclaimable" figure exceeds what's truly on disk.
1154    #[cfg(unix)]
1155    #[test]
1156    fn dir_size_counts_hardlinked_file_once() {
1157        use std::os::unix::fs::MetadataExt;
1158
1159        let dir = tempfile::tempdir().unwrap();
1160        let target = dir.path().join("target");
1161        fs::create_dir_all(&target).unwrap();
1162        // 10-byte file, then a second directory entry (hardlink) to the same inode.
1163        touch(&target, "a.bin", "0123456789");
1164        fs::hard_link(target.join("a.bin"), target.join("b.bin")).unwrap();
1165
1166        // Sanity: the two names really do share one inode with nlink == 2.
1167        assert_eq!(fs::metadata(target.join("a.bin")).unwrap().nlink(), 2);
1168
1169        // Deduped: 10 bytes, not 20.
1170        assert_eq!(dir_size(&target, &opts()), 10);
1171    }
1172
1173    // ------------------------------------------------------------------
1174    // analyze
1175    // ------------------------------------------------------------------
1176
1177    #[test]
1178    fn analyze_skips_zero_artifact_projects() {
1179        let (_keep, root) = fresh_root();
1180        // Project A: has a non-empty target/ artifact directory.
1181        let a = root.join("a");
1182        fs::create_dir_all(&a).unwrap();
1183        touch(&a, "Cargo.toml", "");
1184        touch(&a, "target/build.o", "data");
1185        // Project B: only a marker, no artifact directory => 0 reclaimable.
1186        let b = root.join("b");
1187        fs::create_dir_all(&b).unwrap();
1188        touch(&b, "Cargo.toml", "");
1189
1190        let analyses: Vec<_> = analyze(&root, &opts()).collect();
1191        // Only the project with reclaimable bytes should be reported.
1192        assert_eq!(analyses.len(), 1);
1193        assert!(analyses[0].artifact_size > 0);
1194        assert!(analyses[0].project.path.ends_with(a.file_name().unwrap()));
1195    }
1196
1197    #[test]
1198    fn analyze_reports_size_and_mtime_for_populated_project() {
1199        let (_keep, root) = fresh_root();
1200        let a = root.join("a");
1201        fs::create_dir_all(&a).unwrap();
1202        touch(&a, "Cargo.toml", "");
1203        // A real artifact file whose size we can reason about.
1204        touch(&a, "target/build.o", "12345");
1205
1206        let analyses: Vec<_> = analyze(&root, &opts()).collect();
1207        assert_eq!(analyses.len(), 1);
1208        let analysis = &analyses[0];
1209        // The single 5-byte artifact file must be counted.
1210        assert_eq!(analysis.artifact_size, 5);
1211        // mtime must be obtainable for a freshly written file.
1212        assert!(analysis.last_modified.is_some());
1213    }
1214
1215    /// Regression: a hardlinked artifact (two directory entries, one inode)
1216    /// must contribute its bytes exactly once to the project's reclaimable
1217    /// total. This is the user-facing bug — Cargo hardlinks made the reported
1218    /// total far exceed the disk's real used space.
1219    #[cfg(unix)]
1220    #[test]
1221    fn analyze_dedups_hardlinked_artifacts() {
1222        let (_keep, root) = fresh_root();
1223        let a = root.join("a");
1224        fs::create_dir_all(a.join("target")).unwrap();
1225        touch(&a, "Cargo.toml", "");
1226        // 10-byte artifact, then a second link to it inside target/.
1227        touch(&a, "target/real.o", "0123456789");
1228        fs::hard_link(a.join("target/real.o"), a.join("target/link.o")).unwrap();
1229
1230        let analyses: Vec<_> = analyze(&root, &opts()).collect();
1231        assert_eq!(analyses.len(), 1);
1232        // 10 bytes reclaimable, NOT 20 — the hardlink must not double-count.
1233        assert_eq!(analyses[0].artifact_size, 10);
1234    }
1235
1236    #[test]
1237    fn analyze_reports_nested_subcrate_separately() {
1238        let (_keep, root) = fresh_root();
1239        // Workspace root with a shared target/.
1240        touch(&root, "Cargo.toml", "");
1241        touch(&root, "target/root.o", "RRRR"); // 4 bytes
1242        // A sub-crate with its OWN independent target/.
1243        let sub = root.join("crates").join("sub");
1244        touch(&sub, "Cargo.toml", "");
1245        touch(&sub, "target/sub.o", "SSSSSS"); // 6 bytes
1246
1247        let analyses: Vec<_> = analyze(&root, &opts()).collect();
1248        // Both are reported, each with its OWN artifact size — the root must
1249        // not absorb the sub-crate's target into its own total.
1250        assert_eq!(analyses.len(), 2);
1251        let size_of = |p: &Path| {
1252            analyses
1253                .iter()
1254                .find(|a| a.project.path == p)
1255                .map(|a| a.artifact_size)
1256        };
1257        assert_eq!(size_of(&root), Some(4));
1258        assert_eq!(size_of(&sub), Some(6));
1259    }
1260}