use std::{
borrow::Cow,
error,
fs,
path::{self, Path},
time::SystemTime,
};
const FILE_CARGO_TOML: &str = "Cargo.toml";
const FILE_PACKAGE_JSON: &str = "package.json";
const FILE_ASSEMBLY_CSHARP: &str = "Assembly-CSharp.csproj";
const FILE_STACK_HASKELL: &str = "stack.yaml";
const FILE_CABAL_HASKELL: &str = "cabal.project";
const FILE_SBT_BUILD: &str = "build.sbt";
const FILE_MVN_BUILD: &str = "pom.xml";
const FILE_BUILD_GRADLE: &str = "build.gradle";
const FILE_BUILD_GRADLE_KTS: &str = "build.gradle.kts";
const FILE_CMAKE_BUILD: &str = "CMakeLists.txt";
const FILE_UNREAL_SUFFIX: &str = ".uproject";
const FILE_JUPYTER_SUFFIX: &str = ".ipynb";
const FILE_PYTHON_SUFFIX: &str = ".py";
const FILE_PIXI_PACKAGE: &str = "pixi.toml";
const FILE_COMPOSER_JSON: &str = "composer.json";
const FILE_PUBSPEC_YAML: &str = "pubspec.yaml";
const FILE_ELIXIR_MIX: &str = "mix.exs";
const FILE_SWIFT_PACKAGE: &str = "Package.swift";
const FILE_BUILD_ZIG: &str = "build.zig";
const FILE_GODOT_4_PROJECT: &str = "project.godot";
const FILE_CSPROJ_SUFFIX: &str = ".csproj";
const FILE_FSPROJ_SUFFIX: &str = ".fsproj";
const FILE_TERRAFORM_HCL: &str = ".terraform.lock.hcl";
const FILE_PROJECT_TURBOREPO: &str = "turbo.json";
const FILE_PODFILE: &str = "Podfile";
const PROJECT_CARGO_DIRS: [&str; 2] = ["target", ".xwin-cache"];
const PROJECT_NODE_DIRS: [&str; 2] = ["node_modules", ".angular"];
const PROJECT_REACT_NATIVE_DIRS: [&str; 8] = [
"node_modules",
"android/build",
"android/.gradle",
"ios/build",
"ios/DerivedData",
"ios/Pods",
".expo",
".metro",
];
const PROJECT_UNITY_DIRS: [&str; 7] = [
"Library",
"Temp",
"Obj",
"Logs",
"MemoryCaptures",
"Build",
"Builds",
];
const PROJECT_STACK_DIRS: [&str; 1] = [".stack-work"];
const PROJECT_CABAL_DIRS: [&str; 1] = ["dist-newstyle"];
const PROJECT_SBT_DIRS: [&str; 2] = ["target", "project/target"];
const PROJECT_MVN_DIRS: [&str; 1] = ["target"];
const PROJECT_GRADLE_DIRS: [&str; 2] = ["build", ".gradle"];
const PROJECT_CMAKE_DIRS: [&str; 3] = ["build", "cmake-build-debug", "cmake-build-release"];
const PROJECT_UNREAL_DIRS: [&str; 5] = [
"Binaries",
"Build",
"Saved",
"DerivedDataCache",
"Intermediate",
];
const PROJECT_JUPYTER_DIRS: [&str; 1] = [".ipynb_checkpoints"];
const PROJECT_PYTHON_DIRS: [&str; 7] = [
".mypy_cache",
".nox",
".pytest_cache",
".ruff_cache",
".tox",
"__pycache__",
"__pypackages__",
];
const PROJECT_PIXI_DIRS: [&str; 1] = [".pixi"];
const PROJECT_COMPOSER_DIRS: [&str; 1] = ["vendor"];
const PROJECT_PUB_DIRS: [&str; 4] = [
"build",
".dart_tool",
"linux/flutter/ephemeral",
"windows/flutter/ephemeral",
];
const PROJECT_ELIXIR_DIRS: [&str; 4] = ["_build", ".elixir-tools", ".elixir_ls", ".lexical"];
const PROJECT_SWIFT_DIRS: [&str; 2] = [".build", ".swiftpm"];
const PROJECT_ZIG_DIRS: [&str; 3] = ["zig-cache", ".zig-cache", "zig-out"];
const PROJECT_GODOT_4_DIRS: [&str; 1] = [".godot"];
const PROJECT_DOTNET_DIRS: [&str; 2] = ["bin", "obj"];
const PROJECT_TURBOREPO_DIRS: [&str; 1] = [".turbo"];
const PROJECT_TERRAFORM_DIRS: [&str; 1] = [".terraform"];
const PROJECT_COCOAPODS_DIRS: [&str; 1] = ["Pods"];
const PROJECT_CARGO_NAME: &str = "Cargo";
const PROJECT_NODE_NAME: &str = "Node";
const PROJECT_NODE_REACT_NATIVE_NAME: &str = "Node (React Native)";
const PROJECT_UNITY_NAME: &str = "Unity";
const PROJECT_STACK_NAME: &str = "Stack";
const PROJECT_CABAL_NAME: &str = "Cabal";
const PROJECT_SBT_NAME: &str = "SBT";
const PROJECT_MVN_NAME: &str = "Maven";
const PROJECT_GRADLE_NAME: &str = "Gradle";
const PROJECT_CMAKE_NAME: &str = "CMake";
const PROJECT_UNREAL_NAME: &str = "Unreal";
const PROJECT_JUPYTER_NAME: &str = "Jupyter";
const PROJECT_PYTHON_NAME: &str = "Python";
const PROJECT_PIXI_NAME: &str = "Pixi";
const PROJECT_COMPOSER_NAME: &str = "Composer";
const PROJECT_PUB_NAME: &str = "Pub";
const PROJECT_ELIXIR_NAME: &str = "Elixir";
const PROJECT_SWIFT_NAME: &str = "Swift";
const PROJECT_ZIG_NAME: &str = "Zig";
const PROJECT_GODOT_4_NAME: &str = "Godot 4.x";
const PROJECT_DOTNET_NAME: &str = ".NET";
const PROJECT_TURBOREPO_NAME: &str = "Turborepo";
const PROJECT_TERRAFORM_NAME: &str = "Terraform";
const PROJECT_COCOAPODS_NAME: &str = "CocoaPods";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ProjectType {
Cargo,
Node,
Unity,
Stack,
Cabal,
#[allow(clippy::upper_case_acronyms)]
SBT,
Maven,
Gradle,
CMake,
Unreal,
Jupyter,
Python,
Pixi,
Composer,
Pub,
Elixir,
Swift,
Zig,
Godot4,
Dotnet,
Turborepo,
Terraform,
Cocoapods,
}
#[derive(Debug, Clone)]
pub struct Project {
pub project_types: Vec<ProjectType>,
pub path: path::PathBuf,
}
#[derive(Debug, Clone)]
pub struct ProjectSize {
pub artifact_size: u64,
pub non_artifact_size: u64,
pub dirs: Vec<(String, u64, bool)>,
}
fn artifact_dirs_for(pt: ProjectType, path: &Path) -> &'static [&'static str] {
match pt {
ProjectType::Cargo => &PROJECT_CARGO_DIRS,
ProjectType::Node => {
if is_react_native_project(path) {
&PROJECT_REACT_NATIVE_DIRS
} else {
&PROJECT_NODE_DIRS
}
}
ProjectType::Unity => &PROJECT_UNITY_DIRS,
ProjectType::Stack => &PROJECT_STACK_DIRS,
ProjectType::Cabal => &PROJECT_CABAL_DIRS,
ProjectType::SBT => &PROJECT_SBT_DIRS,
ProjectType::Maven => &PROJECT_MVN_DIRS,
ProjectType::Unreal => &PROJECT_UNREAL_DIRS,
ProjectType::Jupyter => &PROJECT_JUPYTER_DIRS,
ProjectType::Python => &PROJECT_PYTHON_DIRS,
ProjectType::Pixi => &PROJECT_PIXI_DIRS,
ProjectType::CMake => &PROJECT_CMAKE_DIRS,
ProjectType::Composer => &PROJECT_COMPOSER_DIRS,
ProjectType::Pub => &PROJECT_PUB_DIRS,
ProjectType::Elixir => &PROJECT_ELIXIR_DIRS,
ProjectType::Swift => &PROJECT_SWIFT_DIRS,
ProjectType::Gradle => &PROJECT_GRADLE_DIRS,
ProjectType::Zig => &PROJECT_ZIG_DIRS,
ProjectType::Godot4 => &PROJECT_GODOT_4_DIRS,
ProjectType::Dotnet => &PROJECT_DOTNET_DIRS,
ProjectType::Turborepo => &PROJECT_TURBOREPO_DIRS,
ProjectType::Terraform => &PROJECT_TERRAFORM_DIRS,
ProjectType::Cocoapods => &PROJECT_COCOAPODS_DIRS,
}
}
fn type_name_for(pt: ProjectType, path: &Path) -> &'static str {
match pt {
ProjectType::Cargo => PROJECT_CARGO_NAME,
ProjectType::Node => {
if is_react_native_project(path) {
PROJECT_NODE_REACT_NATIVE_NAME
} else {
PROJECT_NODE_NAME
}
}
ProjectType::Unity => PROJECT_UNITY_NAME,
ProjectType::Stack => PROJECT_STACK_NAME,
ProjectType::Cabal => PROJECT_CABAL_NAME,
ProjectType::SBT => PROJECT_SBT_NAME,
ProjectType::Maven => PROJECT_MVN_NAME,
ProjectType::Unreal => PROJECT_UNREAL_NAME,
ProjectType::Jupyter => PROJECT_JUPYTER_NAME,
ProjectType::Python => PROJECT_PYTHON_NAME,
ProjectType::Pixi => PROJECT_PIXI_NAME,
ProjectType::CMake => PROJECT_CMAKE_NAME,
ProjectType::Composer => PROJECT_COMPOSER_NAME,
ProjectType::Pub => PROJECT_PUB_NAME,
ProjectType::Elixir => PROJECT_ELIXIR_NAME,
ProjectType::Swift => PROJECT_SWIFT_NAME,
ProjectType::Gradle => PROJECT_GRADLE_NAME,
ProjectType::Zig => PROJECT_ZIG_NAME,
ProjectType::Godot4 => PROJECT_GODOT_4_NAME,
ProjectType::Dotnet => PROJECT_DOTNET_NAME,
ProjectType::Turborepo => PROJECT_TURBOREPO_NAME,
ProjectType::Terraform => PROJECT_TERRAFORM_NAME,
ProjectType::Cocoapods => PROJECT_COCOAPODS_NAME,
}
}
impl Project {
pub fn artifact_dirs(&self) -> Vec<&'static str> {
let mut dirs: Vec<&'static str> = Vec::new();
for pt in &self.project_types {
for d in artifact_dirs_for(*pt, &self.path) {
if !dirs.contains(d) {
dirs.push(*d);
}
}
}
dirs
}
pub fn name(&self) -> Cow<'_, str> {
self.path.to_string_lossy()
}
pub fn size(&self, options: &ScanOptions) -> u64 {
self.artifact_dirs()
.iter()
.copied()
.map(|p| dir_size(&self.path.join(p), options))
.sum()
}
pub fn last_modified(&self, options: &ScanOptions) -> Result<SystemTime, std::io::Error> {
let top_level_modified = fs::metadata(&self.path)?.modified()?;
let most_recent_modified = walkdir::WalkDir::new(&self.path)
.follow_links(options.follow_symlinks)
.same_file_system(options.same_file_system)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter_map(|m| m.modified().ok())
.fold(top_level_modified, |acc, m| if m > acc { m } else { acc });
Ok(most_recent_modified)
}
pub fn size_dirs(&self, options: &ScanOptions) -> ProjectSize {
let mut artifact_size = 0;
let mut non_artifact_size = 0;
let mut dirs = Vec::new();
let project_root = match fs::read_dir(&self.path) {
Err(_) => {
return ProjectSize {
artifact_size,
non_artifact_size,
dirs,
}
}
Ok(rd) => rd,
};
for entry in project_root.filter_map(|rd| rd.ok()) {
let file_type = match entry.file_type() {
Err(_) => continue,
Ok(file_type) => file_type,
};
if file_type.is_file() {
if let Ok(metadata) = entry.metadata() {
non_artifact_size += metadata.len();
}
continue;
}
if file_type.is_dir() {
let file_name = match entry.file_name().into_string() {
Err(_) => continue,
Ok(file_name) => file_name,
};
let size = dir_size(&entry.path(), options);
let artifact_dir = self.artifact_dirs().contains(&file_name.as_str());
if artifact_dir {
artifact_size += size;
} else {
non_artifact_size += size;
}
dirs.push((file_name, size, artifact_dir));
}
}
ProjectSize {
artifact_size,
non_artifact_size,
dirs,
}
}
pub fn type_name(&self) -> String {
self.project_types
.iter()
.map(|pt| type_name_for(*pt, &self.path))
.collect::<Vec<&str>>()
.join(" / ")
}
pub fn clean(&self) -> Result<(), Box<dyn error::Error>> {
let mut failures: Vec<(path::PathBuf, std::io::Error)> = Vec::new();
for artifact_dir in self
.artifact_dirs()
.iter()
.copied()
.map(|ad| self.path.join(ad))
.filter(|ad| ad.exists())
{
if let Err(e) = fs::remove_dir_all(&artifact_dir) {
failures.push((artifact_dir, e));
}
}
if failures.is_empty() {
Ok(())
} else {
let detail = failures
.iter()
.map(|(p, e)| format!("{} ({})", p.display(), e))
.collect::<Vec<_>>()
.join("; ");
Err(format!("failed to remove some artifact directories: {detail}").into())
}
}
}
fn is_hidden(entry: &walkdir::DirEntry) -> bool {
entry.file_name().to_string_lossy().starts_with('.')
}
const ALL_ARTIFACT_DIR_NAMES: &[&str] = &[
"target",
".xwin-cache",
"node_modules",
".angular",
".expo",
".metro",
"Library",
"Temp",
"Obj",
"Logs",
"MemoryCaptures",
"Build",
"Builds",
".stack-work",
"dist-newstyle",
"build",
".gradle",
"cmake-build-debug",
"cmake-build-release",
"Binaries",
"Saved",
"DerivedDataCache",
"Intermediate",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pytest_cache",
".ruff_cache",
".tox",
"__pycache__",
"__pypackages__",
".pixi",
"vendor",
".dart_tool",
"_build",
".elixir-tools",
".elixir_ls",
".lexical",
".build",
".swiftpm",
"zig-cache",
".zig-cache",
"zig-out",
".godot",
".turbo",
".terraform",
"Pods",
"bin",
"obj",
];
fn is_artifact_dir_name(name: &str) -> bool {
ALL_ARTIFACT_DIR_NAMES.contains(&name)
}
struct ProjectIter {
it: walkdir::IntoIter,
}
#[derive(Debug)]
pub enum ScanError {
IOError(::std::io::Error),
WalkdirError(walkdir::Error),
}
impl std::fmt::Display for ScanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScanError::IOError(e) => write!(f, "io error: {e}"),
ScanError::WalkdirError(e) => write!(f, "directory traversal error: {e}"),
}
}
}
impl std::error::Error for ScanError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ScanError::IOError(e) => Some(e),
ScanError::WalkdirError(e) => Some(e),
}
}
}
impl Iterator for ProjectIter {
type Item = Result<Project, ScanError>;
fn next(&mut self) -> Option<Self::Item> {
loop {
let entry: walkdir::DirEntry = match self.it.next() {
None => return None,
Some(Err(e)) => return Some(Err(ScanError::WalkdirError(e))),
Some(Ok(entry)) => entry,
};
if !entry.file_type().is_dir() {
continue;
}
if is_hidden(&entry) || is_artifact_dir_name(&entry.file_name().to_string_lossy()) {
self.it.skip_current_dir();
continue;
}
let project_types = match detect_project_types(entry.path()) {
Err(e) => return Some(Err(ScanError::IOError(e))),
Ok(project_types) if project_types.is_empty() => continue,
Ok(project_types) => project_types,
};
return Some(Ok(Project {
project_types,
path: entry.path().to_path_buf(),
}));
}
}
}
fn dir_contains_subdir(path: &Path, subdir: &str) -> bool {
path.read_dir()
.map(|rd| {
rd.filter_map(|rd| rd.ok()).any(|de| {
de.file_type().is_ok_and(|t| t.is_dir()) && de.file_name().to_str() == Some(subdir)
})
})
.unwrap_or(false)
}
fn is_react_native_project(path: &Path) -> bool {
dir_contains_subdir(path, "ios") || dir_contains_subdir(path, "android")
}
fn detect_project_types(path: &Path) -> Result<Vec<ProjectType>, std::io::Error> {
let mut file_names: Vec<String> = Vec::new();
let mut has_godot = false;
let mut has_assembly = false;
for dir_entry in path.read_dir()?.filter_map(|rd| rd.ok()) {
if !dir_entry.file_type().is_ok_and(|ft| ft.is_file()) {
continue;
}
let file_name = match dir_entry.file_name().into_string() {
Ok(file_name) => file_name,
Err(_) => continue,
};
if file_name == FILE_GODOT_4_PROJECT {
has_godot = true;
} else if file_name == FILE_ASSEMBLY_CSHARP {
has_assembly = true;
}
file_names.push(file_name);
}
let mut types: Vec<ProjectType> = Vec::new();
for file_name in &file_names {
let p_type = match file_name.as_str() {
FILE_CARGO_TOML => Some(ProjectType::Cargo),
FILE_PACKAGE_JSON => Some(ProjectType::Node),
FILE_ASSEMBLY_CSHARP => Some(ProjectType::Unity),
FILE_STACK_HASKELL => Some(ProjectType::Stack),
FILE_CABAL_HASKELL => Some(ProjectType::Cabal),
FILE_SBT_BUILD => Some(ProjectType::SBT),
FILE_MVN_BUILD => Some(ProjectType::Maven),
FILE_CMAKE_BUILD => Some(ProjectType::CMake),
FILE_COMPOSER_JSON => Some(ProjectType::Composer),
FILE_PUBSPEC_YAML => Some(ProjectType::Pub),
FILE_PIXI_PACKAGE => Some(ProjectType::Pixi),
FILE_ELIXIR_MIX => Some(ProjectType::Elixir),
FILE_SWIFT_PACKAGE => Some(ProjectType::Swift),
FILE_BUILD_GRADLE => Some(ProjectType::Gradle),
FILE_BUILD_GRADLE_KTS => Some(ProjectType::Gradle),
FILE_BUILD_ZIG => Some(ProjectType::Zig),
FILE_GODOT_4_PROJECT => Some(ProjectType::Godot4),
FILE_PROJECT_TURBOREPO => Some(ProjectType::Turborepo),
FILE_TERRAFORM_HCL => Some(ProjectType::Terraform),
FILE_PODFILE => Some(ProjectType::Cocoapods),
file_name if file_name.ends_with(FILE_UNREAL_SUFFIX) => Some(ProjectType::Unreal),
file_name if file_name.ends_with(FILE_JUPYTER_SUFFIX) => Some(ProjectType::Jupyter),
file_name if file_name.ends_with(FILE_PYTHON_SUFFIX) => Some(ProjectType::Python),
file_name
if file_name.ends_with(FILE_CSPROJ_SUFFIX)
|| file_name.ends_with(FILE_FSPROJ_SUFFIX) =>
{
if has_godot {
Some(ProjectType::Godot4)
} else if has_assembly {
Some(ProjectType::Unity)
} else {
Some(ProjectType::Dotnet)
}
}
_ => None,
};
if let Some(pt) = p_type {
if !types.contains(&pt) {
types.push(pt);
}
}
}
types.sort();
types.dedup();
Ok(types)
}
#[derive(Clone, Debug)]
pub struct ScanOptions {
pub follow_symlinks: bool,
pub same_file_system: bool,
}
fn build_walkdir_iter<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> ProjectIter {
ProjectIter {
it: walkdir::WalkDir::new(path)
.follow_links(options.follow_symlinks)
.same_file_system(options.same_file_system)
.into_iter(),
}
}
pub fn scan<P: AsRef<path::Path>>(
path: &P,
options: &ScanOptions,
) -> impl Iterator<Item = Result<Project, ScanError>> {
build_walkdir_iter(path, options)
}
pub fn dir_size<P: AsRef<path::Path>>(path: &P, options: &ScanOptions) -> u64 {
build_walkdir_iter(path, options)
.it
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter_map(|e| e.metadata().ok())
.map(|e| e.len())
.sum()
}
#[derive(Debug, Clone)]
pub struct ProjectAnalysis {
pub project: Project,
pub artifact_size: u64,
pub last_modified: Option<SystemTime>,
}
fn project_size_and_mtime(project: &Project, options: &ScanOptions) -> (u64, Option<SystemTime>) {
let artifact_prefixes: Vec<path::PathBuf> = project
.artifact_dirs()
.into_iter()
.map(|d| project.path.join(d))
.collect();
let mut total_artifact_size: u64 = 0;
let mut latest_mtime: Option<SystemTime> = None;
for entry in walkdir::WalkDir::new(&project.path)
.follow_links(options.follow_symlinks)
.same_file_system(options.same_file_system)
.into_iter()
.filter_map(|e| e.ok())
{
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if !metadata.is_file() {
continue;
}
if artifact_prefixes
.iter()
.any(|prefix| entry.path().starts_with(prefix))
{
total_artifact_size += metadata.len();
}
if let Ok(modified) = metadata.modified() {
latest_mtime = Some(latest_mtime.map_or(modified, |prev| prev.max(modified)));
}
}
(total_artifact_size, latest_mtime)
}
pub fn analyze<'a, P: AsRef<Path>>(
path: &'a P,
options: &'a ScanOptions,
) -> impl Iterator<Item = ProjectAnalysis> + use<'a, P> {
scan(path, options).filter_map(|project| {
let project = project.ok()?;
let (artifact_size, last_modified) = project_size_and_mtime(&project, options);
if artifact_size == 0 {
return None;
}
Some(ProjectAnalysis {
project,
artifact_size,
last_modified,
})
})
}
pub fn clean(project_path: &Path) -> Result<(), Box<dyn error::Error>> {
let project_types = detect_project_types(project_path)?;
if project_types.is_empty() {
return Ok(());
}
let project = Project {
project_types,
path: project_path.to_path_buf(),
};
project.clean()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
analyze, detect_project_types, dir_size, scan, Project, ProjectType, ScanOptions,
};
use std::fs;
use std::path::{Path, PathBuf};
fn opts() -> ScanOptions {
ScanOptions {
follow_symlinks: false,
same_file_system: false,
}
}
fn fresh_root() -> (tempfile::TempDir, PathBuf) {
let parent = tempfile::tempdir().unwrap();
let root = parent.path().join("kondo-test-root");
fs::create_dir_all(&root).unwrap();
(parent, root)
}
fn touch<P: AsRef<Path>>(dir: P, name: &str, contents: &str) {
let path = dir.as_ref().join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
#[test]
fn detect_cargo_only() {
let dir = tempfile::tempdir().unwrap();
touch(dir.path(), "Cargo.toml", "");
let types = detect_project_types(dir.path()).unwrap();
assert_eq!(types, vec![ProjectType::Cargo]);
}
#[test]
fn detect_mixed_cargo_node() {
let dir = tempfile::tempdir().unwrap();
touch(dir.path(), "Cargo.toml", "");
touch(dir.path(), "package.json", "");
let types = detect_project_types(dir.path()).unwrap();
assert_eq!(types, vec![ProjectType::Cargo, ProjectType::Node]);
}
#[test]
fn detect_node_and_turborepo() {
let dir = tempfile::tempdir().unwrap();
touch(dir.path(), "package.json", "");
touch(dir.path(), "turbo.json", "");
let types = detect_project_types(dir.path()).unwrap();
assert_eq!(types, vec![ProjectType::Node, ProjectType::Turborepo]);
}
#[test]
fn detect_csproj_with_godot_is_godot4() {
let dir = tempfile::tempdir().unwrap();
touch(dir.path(), "something.csproj", "");
touch(dir.path(), "project.godot", "");
let types = detect_project_types(dir.path()).unwrap();
assert_eq!(types, vec![ProjectType::Godot4]);
}
#[test]
fn detect_assembly_csharp_is_unity() {
let dir = tempfile::tempdir().unwrap();
touch(dir.path(), "Assembly-CSharp.csproj", "");
let types = detect_project_types(dir.path()).unwrap();
assert_eq!(types, vec![ProjectType::Unity]);
}
#[test]
fn detect_plain_csproj_is_dotnet() {
let dir = tempfile::tempdir().unwrap();
touch(dir.path(), "app.csproj", "");
let types = detect_project_types(dir.path()).unwrap();
assert_eq!(types, vec![ProjectType::Dotnet]);
}
#[test]
fn detect_empty_directory_is_empty_vec() {
let dir = tempfile::tempdir().unwrap();
let types = detect_project_types(dir.path()).unwrap();
assert!(types.is_empty());
}
fn project_with(types: Vec<ProjectType>, path: &Path) -> Project {
Project {
project_types: types,
path: path.to_path_buf(),
}
}
#[test]
fn artifact_dirs_cargo_includes_target_and_xwin() {
let dir = tempfile::tempdir().unwrap();
let p = project_with(vec![ProjectType::Cargo], dir.path());
let dirs = p.artifact_dirs();
assert!(dirs.contains(&"target"));
assert!(dirs.contains(&".xwin-cache"));
}
#[test]
fn artifact_dirs_cargo_node_union() {
let dir = tempfile::tempdir().unwrap();
let p = project_with(vec![ProjectType::Cargo, ProjectType::Node], dir.path());
let dirs = p.artifact_dirs();
assert!(dirs.contains(&"target"));
assert!(dirs.contains(&"node_modules"));
assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
}
#[test]
fn artifact_dirs_sbt_maven_target_deduped() {
let dir = tempfile::tempdir().unwrap();
let p = project_with(vec![ProjectType::SBT, ProjectType::Maven], dir.path());
let dirs = p.artifact_dirs();
assert_eq!(dirs.iter().filter(|&&d| d == "target").count(), 1);
}
#[test]
fn scan_finds_nested_subcrate_projects() {
let (_keep, root) = fresh_root();
touch(&root, "Cargo.toml", "");
touch(&root, "target/keep.o", "x");
let sub = root.join("crates").join("sub");
touch(&sub, "Cargo.toml", "");
touch(&sub, "target/keep.o", "y");
let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
assert_eq!(projects.len(), 2);
assert!(projects.iter().any(|p| p.path == root));
assert!(projects.iter().any(|p| p.path == sub));
}
#[test]
fn scan_prunes_artifact_directories() {
let (_keep, root) = fresh_root();
touch(root.join("target"), "Cargo.toml", "");
let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
assert!(projects.is_empty());
}
#[test]
fn scan_skips_hidden_directories() {
let (_keep, root) = fresh_root();
let hidden = root.join(".hidden");
fs::create_dir_all(&hidden).unwrap();
touch(&hidden, "Cargo.toml", "");
let projects: Vec<Project> = scan(&root, &opts()).filter_map(Result::ok).collect();
assert!(projects.is_empty());
}
#[test]
fn clean_removes_target() {
let dir = tempfile::tempdir().unwrap();
touch(dir.path(), "Cargo.toml", "");
touch(dir.path(), "target/keep.o", "x");
assert!(dir.path().join("target").exists());
let p = project_with(vec![ProjectType::Cargo], dir.path());
p.clean().unwrap();
assert!(!dir.path().join("target").exists());
}
#[test]
fn dir_size_counts_file_contents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();
touch(path, "a.txt", "hello world");
let size = dir_size(&path, &opts());
assert!(size > 0);
}
#[test]
fn analyze_skips_zero_artifact_projects() {
let (_keep, root) = fresh_root();
let a = root.join("a");
fs::create_dir_all(&a).unwrap();
touch(&a, "Cargo.toml", "");
touch(&a, "target/build.o", "data");
let b = root.join("b");
fs::create_dir_all(&b).unwrap();
touch(&b, "Cargo.toml", "");
let analyses: Vec<_> = analyze(&root, &opts()).collect();
assert_eq!(analyses.len(), 1);
assert!(analyses[0].artifact_size > 0);
assert!(analyses[0].project.path.ends_with(a.file_name().unwrap()));
}
#[test]
fn analyze_reports_size_and_mtime_for_populated_project() {
let (_keep, root) = fresh_root();
let a = root.join("a");
fs::create_dir_all(&a).unwrap();
touch(&a, "Cargo.toml", "");
touch(&a, "target/build.o", "12345");
let analyses: Vec<_> = analyze(&root, &opts()).collect();
assert_eq!(analyses.len(), 1);
let analysis = &analyses[0];
assert_eq!(analysis.artifact_size, 5);
assert!(analysis.last_modified.is_some());
}
#[test]
fn analyze_reports_nested_subcrate_separately() {
let (_keep, root) = fresh_root();
touch(&root, "Cargo.toml", "");
touch(&root, "target/root.o", "RRRR"); let sub = root.join("crates").join("sub");
touch(&sub, "Cargo.toml", "");
touch(&sub, "target/sub.o", "SSSSSS");
let analyses: Vec<_> = analyze(&root, &opts()).collect();
assert_eq!(analyses.len(), 2);
let size_of = |p: &Path| {
analyses
.iter()
.find(|a| a.project.path == p)
.map(|a| a.artifact_size)
};
assert_eq!(size_of(&root), Some(4));
assert_eq!(size_of(&sub), Some(6));
}
}