diff --git a/README.ko.md b/README.ko.md index 8926b20..2c031e2 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,10 +1,10 @@ # DustFril -Rust 개발자를 위한 산출물(Artifact) 분석 및 정리 도구입니다. +개발 산출물(Artifact) 분석 및 정리 도구입니다. -DustFril은 Cargo와 Rust 도구들이 생성하는 빌드 산출물과 캐시를 탐지하고, 용량을 분석하며, 안전하게 정리할 수 있도록 돕는 것을 목표로 합니다. +DustFril은 Rust, Node, Java 프로젝트에서 생성되는 산출물을 탐지하고, 용량을 분석하며, 안전하게 정리할 수 있도록 돕는 것을 목표로 합니다. -Rust 프로젝트를 오래 관리하다 보면 `target/`, Cargo 캐시 등의 디렉터리가 수 GB에서 수십 GB까지 증가할 수 있습니다. +프로젝트를 오래 관리하다 보면 빌드 결과물과 의존성 디렉터리가 수 GB에서 수십 GB까지 증가할 수 있습니다. DustFril은 이러한 생성 파일들을 쉽고 안전하게 관리할 수 있는 CLI 도구를 지향합니다. @@ -12,26 +12,31 @@ DustFril은 이러한 생성 파일들을 쉽고 안전하게 관리할 수 있 ### 현재 목표 -- Rust 산출물 탐지 +- 지원 생태계의 삭제 가능한 산출물 탐지 - 디스크 사용량 분석 -- Cargo 캐시 분석 +- CLI 기반 생태계 필터링 - 안전한 정리 기능 -### 지원 예정 +### 현재 탐지 대상 - `target/` -- `~/.cargo/registry` -- `~/.cargo/git` +- `node_modules/` +- `build/` + +### 지원 예정 + +- Cargo 홈 캐시 +- 추가 생태계별 캐시 ## 사용 예시 -프로젝트 스캔: +산출물 스캔: ```bash dfr scan ``` -용량 분석: +산출물 용량 분석: ```bash dfr analyze @@ -51,28 +56,14 @@ dfr clean ## 로드맵 -### Phase 1 - -- Cargo 프로젝트 탐지 -- Rust 산출물 탐지 -- 기본 CLI 구현 - -### Phase 2 - +- 다중 생태계 산출물 탐지 - 디스크 사용량 분석 - Dry Run 지원 - 안전 삭제 기능 - -### Phase 3 - - 인터랙티브 터미널 UI - 설정 파일 지원 - 고급 필터링 - -### Phase 4 - - 데스크톱 애플리케이션 -- 다중 언어 생태계 지원 ## 철학 diff --git a/README.md b/README.md index fdf4f54..1152991 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,42 @@ # DustFril -A Rust development artifact analyzer and cleaner. +A development artifact analyzer and cleaner. 🚧 Early development stage -DustFril helps Rust developers discover, analyze, and safely manage generated files created by Cargo and Rust tooling. +DustFril helps developers discover, analyze, and safely manage generated files from Rust, Node, and Java projects. -Over time, Rust projects accumulate build artifacts and caches that consume significant disk space. DustFril aims to provide a simple and transparent way to inspect and clean those artifacts. +Over time, build outputs and dependency directories consume significant disk space. DustFril aims to provide a simple and transparent way to inspect and clean those artifacts. ## Features ### Current Focus -- Detect Rust build artifacts +- Detect removable build artifacts across supported ecosystems - Analyze disk usage -- Inspect Cargo caches +- Filter by ecosystem from the CLI - Safe cleanup workflow -### Planned Support +### Currently Detected Artifacts - `target/` -- `~/.cargo/registry` -- `~/.cargo/git` +- `node_modules/` +- `build/` + +### Planned Support + +- Cargo home caches +- Additional ecosystem-specific caches ## Example -Scan Rust artifacts: +Scan artifacts: ```bash dfr scan ``` -Analyze disk usage: +Analyze artifact disk usage: ```bash dfr analyze @@ -51,28 +56,14 @@ dfr clean ## Project Goals -### Phase 1 - -- Cargo project scanning -- Rust artifact detection -- Basic CLI - -### Phase 2 - +- Multi-ecosystem artifact detection - Disk usage analysis - Dry-run support - Safe cleanup operations - -### Phase 3 - - Interactive terminal interface - Configuration support - Advanced filtering - -### Phase 4 - - Desktop application -- Multi-language ecosystem support ## Philosophy diff --git a/apps/dustfril-cli/src/cli.rs b/apps/dustfril-cli/src/cli.rs index ee824aa..8028e34 100644 --- a/apps/dustfril-cli/src/cli.rs +++ b/apps/dustfril-cli/src/cli.rs @@ -5,7 +5,11 @@ use dustfril_core::models::Ecosystem; /// DustFril CLI #[derive(Parser)] -#[command(name = "dfr", version, about = "Rust artifact analyzer and cleaner")] +#[command( + name = "dfr", + version, + about = "Development artifact analyzer and cleaner" +)] pub struct Cli { #[command(subcommand)] pub command: Commands, @@ -14,13 +18,13 @@ pub struct Cli { /// Available commands #[derive(Subcommand)] pub enum Commands { - /// Scan Rust artifacts + /// Scan build artifacts Scan(PathArgs), - /// Analyze disk usage + /// Analyze artifact disk usage Analyze(PathArgs), - /// Clean artifacts + /// Clean detected artifacts Clean(CleanArgs), } @@ -39,6 +43,9 @@ pub struct PathArgs { } impl PathArgs { + /// Returns the selected ecosystem filters in CLI flag order. + /// + /// An empty result means all ecosystems should be scanned. pub fn ecosystems(&self) -> Vec { let mut ecosystems = Vec::new(); @@ -69,7 +76,55 @@ pub struct CleanArgs { } impl CleanArgs { + /// Returns the selected cleanup ecosystem filters. pub fn ecosystems(&self) -> Vec { self.path_args.ecosystems() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_args_ecosystems_returns_empty_when_no_flags_are_set() { + let args = PathArgs { + path: None, + rust: false, + node: false, + java: false, + }; + + assert!(args.ecosystems().is_empty()); + } + + #[test] + fn path_args_ecosystems_preserves_flag_order() { + let args = PathArgs { + path: None, + rust: true, + node: true, + java: true, + }; + + assert_eq!( + args.ecosystems(), + vec![Ecosystem::Rust, Ecosystem::Node, Ecosystem::Java] + ); + } + + #[test] + fn clean_args_ecosystems_delegates_to_path_args() { + let args = CleanArgs { + path_args: PathArgs { + path: None, + rust: false, + node: true, + java: true, + }, + dry_run: true, + }; + + assert_eq!(args.ecosystems(), vec![Ecosystem::Node, Ecosystem::Java]); + } +} diff --git a/apps/dustfril-cli/src/commands/scan.rs b/apps/dustfril-cli/src/commands/scan.rs index 72d00f0..9a4b0de 100644 --- a/apps/dustfril-cli/src/commands/scan.rs +++ b/apps/dustfril-cli/src/commands/scan.rs @@ -31,6 +31,6 @@ pub fn execute(args: PathArgs) { println!("Found {} artifact(s)\n", result.artifacts.len()); for artifact in result.artifacts { - println!(" {:?}\n", artifact.path); + println!(" [{}] {}", artifact.ecosystem, artifact.path.display()); } } diff --git a/apps/dustfril-cli/src/format/size_format.rs b/apps/dustfril-cli/src/format/size_format.rs index 6fada0f..fcb5883 100644 --- a/apps/dustfril-cli/src/format/size_format.rs +++ b/apps/dustfril-cli/src/format/size_format.rs @@ -1,3 +1,4 @@ +/// Formats a byte count using binary size units for human-readable CLI output. pub fn format_size(bytes: u64) -> String { const KB: f64 = 1024.0; const MB: f64 = KB * 1024.0; @@ -39,3 +40,8 @@ fn format_size_megabytes() { fn format_size_gigabytes() { assert_eq!(format_size(1024 * 1024 * 1024), "1.00 GB"); } + +#[test] +fn format_size_terabytes() { + assert_eq!(format_size(1024_u64.pow(4)), "1.00 TB"); +} diff --git a/apps/dustfril-cli/src/format/time_format.rs b/apps/dustfril-cli/src/format/time_format.rs index 0288b01..5aff4e8 100644 --- a/apps/dustfril-cli/src/format/time_format.rs +++ b/apps/dustfril-cli/src/format/time_format.rs @@ -1,6 +1,7 @@ use chrono::{DateTime, Local}; use std::time::SystemTime; +/// Formats a filesystem timestamp for display in the local timezone. pub fn format_modified(modified: Option) -> String { let Some(modified) = modified else { return "Unknown".to_string(); @@ -10,3 +11,25 @@ pub fn format_modified(modified: Option) -> String { datetime.format("%Y-%m-%d %H:%M:%S").to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_modified_returns_unknown_for_missing_timestamp() { + assert_eq!(format_modified(None), "Unknown"); + } + + #[test] + fn format_modified_returns_timestamp_string() { + let formatted = format_modified(Some(SystemTime::UNIX_EPOCH)); + + assert_eq!(formatted.len(), 19); + assert_eq!(&formatted[4..5], "-"); + assert_eq!(&formatted[7..8], "-"); + assert_eq!(&formatted[10..11], " "); + assert_eq!(&formatted[13..14], ":"); + assert_eq!(&formatted[16..17], ":"); + } +} diff --git a/apps/dustfril-cli/src/shared/path.rs b/apps/dustfril-cli/src/shared/path.rs index e62b4ef..b34a178 100644 --- a/apps/dustfril-cli/src/shared/path.rs +++ b/apps/dustfril-cli/src/shared/path.rs @@ -1,5 +1,6 @@ use std::path::{Path, PathBuf}; +/// Returns `true` when the given path exists on disk. pub fn validate_path(path: &Path) -> bool { if !path.exists() { eprintln!("Path does not exist: {}", path.display()); @@ -10,7 +11,37 @@ pub fn validate_path(path: &Path) -> bool { true } +/// Resolves an optional CLI path to an explicit filesystem path. +/// +/// When no path is provided, the current working directory is used. pub fn resolve_path(path: &Option) -> PathBuf { path.clone() .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_path_returns_true_for_existing_path() { + assert!(validate_path(std::env::current_dir().unwrap().as_path())); + } + + #[test] + fn validate_path_returns_false_for_missing_path() { + let missing = std::env::temp_dir().join("dustfril-cli-missing-path"); + assert!(!validate_path(&missing)); + } + + #[test] + fn resolve_path_returns_explicit_path_when_provided() { + let path = PathBuf::from("/tmp/dustfril-explicit"); + assert_eq!(resolve_path(&Some(path.clone())), path); + } + + #[test] + fn resolve_path_uses_current_dir_when_not_provided() { + assert_eq!(resolve_path(&None), std::env::current_dir().unwrap()); + } +} diff --git a/crates/dustfril-core/src/analyzer/analyze.rs b/crates/dustfril-core/src/analyzer/analyze.rs index b445432..d2b38eb 100644 --- a/crates/dustfril-core/src/analyzer/analyze.rs +++ b/crates/dustfril-core/src/analyzer/analyze.rs @@ -1,4 +1,3 @@ -use std::fs; use std::path::Path; use std::time::SystemTime; @@ -7,17 +6,21 @@ use crate::models::{ AnalysisResult, Artifact, ArtifactAnalysis, CleanupRecommendation, ScanResult, }; use rayon::prelude::*; +use walkdir::WalkDir; pub struct Analyzer; impl Analyzer { + /// Computes per-artifact size and freshness metadata for a scan result. pub fn analyze(scan_result: ScanResult) -> DustResult { - let artifacts: Vec = scan_result + let mut artifacts: Vec = scan_result .artifacts .into_par_iter() .map(Self::analyze_artifact) .collect(); + artifacts.sort_by_key(|artifact| std::cmp::Reverse(artifact.size_bytes)); + let total_size_bytes = artifacts.iter().map(|a| a.size_bytes).sum(); Ok(AnalysisResult { @@ -69,53 +72,23 @@ fn recommend_cleanup(age_days: Option) -> CleanupRecommendation { } fn calculate_directory_size(path: &Path) -> u64 { - let mut total_size = 0; - - let Ok(entries) = fs::read_dir(path) else { - return 0; - }; - - for entry in entries.flatten() { - let Ok(metadata) = entry.metadata() else { - continue; - }; - - if metadata.is_file() { - total_size += metadata.len(); - } else if metadata.is_dir() { - total_size += calculate_directory_size(&entry.path()); - } - } - - total_size + WalkDir::new(path) + .into_iter() + .filter_map(Result::ok) + .filter_map(|entry| { + entry + .metadata() + .ok() + .filter(|metadata| metadata.is_file()) + .map(|metadata| metadata.len()) + }) + .sum() } fn find_latest_modified(path: &Path) -> Option { - let mut latest = fs::metadata(path).ok()?.modified().ok(); - - let Ok(entries) = fs::read_dir(path) else { - return latest; - }; - - for entry in entries.flatten() { - let path = entry.path(); - - let Ok(metadata) = entry.metadata() else { - continue; - }; - - let current = if metadata.is_dir() { - find_latest_modified(&path) - } else { - metadata.modified().ok() - }; - - if let Some(current) = current - && latest.is_none_or(|existing| current > existing) - { - latest = Some(current); - } - } - - latest + WalkDir::new(path) + .into_iter() + .filter_map(Result::ok) + .filter_map(|entry| entry.metadata().ok()?.modified().ok()) + .max() } diff --git a/crates/dustfril-core/src/api/analyze.rs b/crates/dustfril-core/src/api/analyze.rs index 30a4d53..35d981a 100644 --- a/crates/dustfril-core/src/api/analyze.rs +++ b/crates/dustfril-core/src/api/analyze.rs @@ -4,6 +4,32 @@ use crate::{ models::{AnalysisResult, ScanResult}, }; +/// Analyzes scanned artifacts and returns size, age, and cleanup hints. pub fn analyze(scan_result: ScanResult) -> DustResult { analyzer::Analyzer::analyze(scan_result) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{Artifact, Ecosystem}; + + #[test] + fn analyze_aggregates_size_from_scan_result() { + let temp_dir = tempfile::TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("artifact.bin"), b"hello").unwrap(); + + let scan_result = ScanResult { + artifacts: vec![Artifact::new( + temp_dir.path().to_path_buf(), + Ecosystem::Rust, + )], + }; + + let result = analyze(scan_result).unwrap(); + + assert_eq!(result.artifacts.len(), 1); + assert_eq!(result.total_size_bytes, 5); + assert_eq!(result.artifacts[0].artifact.path, temp_dir.path()); + } +} diff --git a/crates/dustfril-core/src/api/clean.rs b/crates/dustfril-core/src/api/clean.rs index 0e14c5c..cc6aa0a 100644 --- a/crates/dustfril-core/src/api/clean.rs +++ b/crates/dustfril-core/src/api/clean.rs @@ -4,11 +4,62 @@ use crate::{ models::{CleanupPlan, CleanupResult, ScanResult}, }; +/// Builds a cleanup plan from scanned artifacts using analyzer recommendations. pub fn build_plan(scan: ScanResult) -> DustResult { let analysis = analyzer::Analyzer::analyze(scan)?; cleaner::create_cleanup_plan(analysis) } +/// Executes a cleanup plan and reports deleted and failed paths. pub fn execute(plan: &CleanupPlan) -> DustResult { cleaner::execute_cleanup(plan) } + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::models::{Artifact, CleanupCandidate, Ecosystem}; + + #[test] + fn build_plan_returns_empty_when_artifact_is_recent() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("artifact.bin"), b"hello").unwrap(); + + let scan = ScanResult { + artifacts: vec![Artifact::new( + temp_dir.path().to_path_buf(), + Ecosystem::Rust, + )], + }; + + let plan = build_plan(scan).unwrap(); + + assert!(plan.candidates.is_empty()); + } + + #[test] + fn execute_removes_candidate_path() { + let temp_dir = TempDir::new().unwrap(); + let target = temp_dir.path().join("target"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("artifact.bin"), b"hello").unwrap(); + + let plan = CleanupPlan { + candidates: vec![CleanupCandidate { + path: target.clone(), + ecosystem: Ecosystem::Rust, + size_bytes: 5, + age_days: Some(120), + }], + }; + + let result = execute(&plan).unwrap(); + + assert!(!target.exists()); + assert_eq!(result.deleted_paths, vec![target]); + assert!(result.failed_paths.is_empty()); + assert_eq!(result.freed_size_bytes, 5); + } +} diff --git a/crates/dustfril-core/src/api/scan.rs b/crates/dustfril-core/src/api/scan.rs index a63d9e0..694ff91 100644 --- a/crates/dustfril-core/src/api/scan.rs +++ b/crates/dustfril-core/src/api/scan.rs @@ -6,6 +6,30 @@ use crate::{ scanner, }; +/// Scans a filesystem tree for removable artifacts in supported ecosystems. +/// +/// When `ecosystems` is empty, all registered detectors are used. pub fn scan(root: &Path, ecosystems: &[Ecosystem]) -> DustResult { scanner::scan(root, ecosystems) } + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn scan_returns_detected_rust_artifact() { + let temp_dir = TempDir::new().unwrap(); + std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]").unwrap(); + let target = temp_dir.path().join("target"); + std::fs::create_dir_all(&target).unwrap(); + + let result = scan(temp_dir.path(), &[Ecosystem::Rust]).unwrap(); + + assert_eq!(result.artifacts.len(), 1); + assert_eq!(result.artifacts[0].path, target); + assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Rust); + } +} diff --git a/crates/dustfril-core/src/cleaner/executor.rs b/crates/dustfril-core/src/cleaner/executor.rs index b10a79f..1e353d6 100644 --- a/crates/dustfril-core/src/cleaner/executor.rs +++ b/crates/dustfril-core/src/cleaner/executor.rs @@ -5,6 +5,7 @@ use crate::{ models::{CleanupPlan, CleanupResult}, }; +/// Deletes all paths in a cleanup plan and summarizes reclaimed space. pub fn execute_cleanup(plan: &CleanupPlan) -> DustResult { let mut result = CleanupResult { deleted_paths: Vec::new(), diff --git a/crates/dustfril-core/src/cleaner/plan.rs b/crates/dustfril-core/src/cleaner/plan.rs index 75a0af0..ac4bd18 100644 --- a/crates/dustfril-core/src/cleaner/plan.rs +++ b/crates/dustfril-core/src/cleaner/plan.rs @@ -3,6 +3,7 @@ use crate::{ models::{AnalysisResult, CleanupCandidate, CleanupPlan, CleanupRecommendation}, }; +/// Converts analyzed artifacts into a cleanup plan by keeping only safe candidates. pub fn create_cleanup_plan(analysis: AnalysisResult) -> DustResult { let mut plan = CleanupPlan::default(); diff --git a/crates/dustfril-core/src/error.rs b/crates/dustfril-core/src/error.rs index f586ae8..d63fded 100644 --- a/crates/dustfril-core/src/error.rs +++ b/crates/dustfril-core/src/error.rs @@ -1,14 +1,21 @@ use std::fmt; +/// Errors returned by the core scanning, analysis, and cleanup APIs. #[derive(Debug)] pub enum DustError { + /// Wraps an underlying filesystem I/O error. Io(std::io::Error), + /// Indicates that a user-supplied path could not be used. InvalidPath(std::path::PathBuf), + /// Indicates an unrecoverable scan failure. ScanFailed, + /// Indicates an unrecoverable analysis failure. AnalysisFailed, + /// Indicates an unrecoverable cleanup failure. CleanupFailed, } +/// Standard result type used by the core crate. pub type DustResult = std::result::Result; impl fmt::Display for DustError { @@ -40,3 +47,30 @@ impl From for DustError { DustError::Io(error) } } + +#[cfg(test)] +mod tests { + use std::io; + use std::path::PathBuf; + + use super::*; + + #[test] + fn from_io_error_creates_io_variant() { + let error = DustError::from(io::Error::other("boom")); + + assert!(matches!(error, DustError::Io(_))); + } + + #[test] + fn display_formats_path_error() { + let error = DustError::InvalidPath(PathBuf::from("/tmp/missing")); + + assert_eq!(error.to_string(), "Invalid path: /tmp/missing"); + } + + #[test] + fn display_formats_cleanup_error() { + assert_eq!(DustError::CleanupFailed.to_string(), "Cleanup failed"); + } +} diff --git a/crates/dustfril-core/src/fs/walk.rs b/crates/dustfril-core/src/fs/walk.rs index 2bd869a..6a6b275 100644 --- a/crates/dustfril-core/src/fs/walk.rs +++ b/crates/dustfril-core/src/fs/walk.rs @@ -11,3 +11,23 @@ pub fn walk_dirs(root: &Path) -> Vec { .map(|e| e.path().to_path_buf()) .collect() } + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn walk_dirs_returns_root_and_nested_directories() { + let temp_dir = TempDir::new().unwrap(); + let nested = temp_dir.path().join("a").join("b"); + std::fs::create_dir_all(&nested).unwrap(); + + let dirs = walk_dirs(temp_dir.path()); + + assert!(dirs.iter().any(|path| path == temp_dir.path())); + assert!(dirs.iter().any(|path| path == &temp_dir.path().join("a"))); + assert!(dirs.iter().any(|path| path == &nested)); + } +} diff --git a/crates/dustfril-core/src/models/analysis.rs b/crates/dustfril-core/src/models/analysis.rs index 72fd879..881e244 100644 --- a/crates/dustfril-core/src/models/analysis.rs +++ b/crates/dustfril-core/src/models/analysis.rs @@ -4,20 +4,29 @@ use serde::{Deserialize, Serialize}; use crate::models::{Artifact, CleanupRecommendation}; -/// Analyzed artifact information +/// Detailed analysis for a single scanned artifact. #[derive(Debug, Serialize, Deserialize)] pub struct ArtifactAnalysis { + /// The original artifact found during scanning. pub artifact: Artifact, + /// Total size of all files contained by the artifact path. pub size_bytes: u64, - // permission denied, broken symlink, network filesystem failed to detect + /// Best-effort latest modification time. + /// + /// This may be `None` when metadata cannot be read, such as on permission + /// errors, broken symlinks, or unusual filesystems. pub last_modified: Option, + /// Age in days derived from `last_modified`. pub age_days: Option, + /// Cleanup recommendation derived from the observed age. pub recommendation: CleanupRecommendation, } -/// Total Analysis Results +/// Aggregate analysis output for all detected artifacts. #[derive(Debug, Default, Serialize, Deserialize)] pub struct AnalysisResult { + /// Per-artifact analysis records. pub artifacts: Vec, + /// Sum of all analyzed artifact sizes. pub total_size_bytes: u64, } diff --git a/crates/dustfril-core/src/models/cleanup.rs b/crates/dustfril-core/src/models/cleanup.rs index e525ad0..bfc522f 100644 --- a/crates/dustfril-core/src/models/cleanup.rs +++ b/crates/dustfril-core/src/models/cleanup.rs @@ -5,12 +5,15 @@ use serde::{Deserialize, Serialize}; use crate::models::{ArtifactAnalysis, Ecosystem}; +/// Plan containing artifact paths that are safe to remove. #[derive(Debug, Default, Serialize, Deserialize)] pub struct CleanupPlan { + /// Individual removal candidates. pub candidates: Vec, } impl CleanupPlan { + /// Returns the total number of bytes that can be reclaimed by this plan. pub fn reclaimable_size_bytes(&self) -> u64 { self.candidates .iter() @@ -19,13 +22,18 @@ impl CleanupPlan { } } +/// Summary of an attempted cleanup operation. #[derive(Debug, Serialize, Deserialize)] pub struct CleanupResult { + /// Paths successfully deleted. pub deleted_paths: Vec, + /// Paths that could not be deleted. pub failed_paths: Vec, + /// Total bytes reclaimed from deleted paths. pub freed_size_bytes: u64, } +/// Suggested user action for an analyzed artifact. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CleanupRecommendation { Keep, @@ -45,11 +53,16 @@ impl fmt::Display for CleanupRecommendation { } } +/// A single artifact selected for removal. #[derive(Debug, Serialize, Deserialize)] pub struct CleanupCandidate { + /// Filesystem path to remove. pub path: PathBuf, + /// Ecosystem that owns the artifact. pub ecosystem: Ecosystem, + /// Estimated reclaimable size for this candidate. pub size_bytes: u64, + /// Age in days when known. pub age_days: Option, } @@ -63,3 +76,42 @@ impl From for CleanupCandidate { } } } + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use super::*; + use crate::models::{Artifact, ArtifactAnalysis}; + + #[test] + fn cleanup_recommendation_display_is_stable() { + assert_eq!(CleanupRecommendation::Keep.to_string(), "Keep"); + assert_eq!( + CleanupRecommendation::NeedsReview.to_string(), + "NeedsReview" + ); + assert_eq!( + CleanupRecommendation::SafeToClean.to_string(), + "SafeToClean" + ); + } + + #[test] + fn cleanup_candidate_from_analysis_preserves_expected_fields() { + let analysis = ArtifactAnalysis { + artifact: Artifact::new(PathBuf::from("target"), Ecosystem::Rust), + size_bytes: 42, + last_modified: Some(SystemTime::UNIX_EPOCH), + age_days: Some(120), + recommendation: CleanupRecommendation::SafeToClean, + }; + + let candidate = CleanupCandidate::from(analysis); + + assert_eq!(candidate.path, PathBuf::from("target")); + assert_eq!(candidate.ecosystem, Ecosystem::Rust); + assert_eq!(candidate.size_bytes, 42); + assert_eq!(candidate.age_days, Some(120)); + } +} diff --git a/crates/dustfril-core/src/models/scan.rs b/crates/dustfril-core/src/models/scan.rs index b75cfb8..97ccdde 100644 --- a/crates/dustfril-core/src/models/scan.rs +++ b/crates/dustfril-core/src/models/scan.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +/// Supported project ecosystems. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Ecosystem { Rust, @@ -22,19 +23,45 @@ impl fmt::Display for Ecosystem { } } +/// Result of scanning a filesystem tree for removable artifacts. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ScanResult { + /// Artifact paths discovered during the scan. pub artifacts: Vec, } +/// A removable artifact discovered for a supported ecosystem. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Artifact { + /// Filesystem path to the removable artifact. pub path: PathBuf, + /// Ecosystem that owns the artifact. pub ecosystem: Ecosystem, } impl Artifact { + /// Creates a scanned artifact entry for the given path and ecosystem. pub fn new(path: PathBuf, ecosystem: Ecosystem) -> Self { Self { path, ecosystem } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ecosystem_display_matches_cli_labels() { + assert_eq!(Ecosystem::Rust.to_string(), "Rust"); + assert_eq!(Ecosystem::Node.to_string(), "Node"); + assert_eq!(Ecosystem::Java.to_string(), "Java"); + } + + #[test] + fn artifact_new_preserves_fields() { + let artifact = Artifact::new(PathBuf::from("target"), Ecosystem::Rust); + + assert_eq!(artifact.path, PathBuf::from("target")); + assert_eq!(artifact.ecosystem, Ecosystem::Rust); + } +} diff --git a/crates/dustfril-core/src/scanner/detector.rs b/crates/dustfril-core/src/scanner/detector.rs index aa8ac46..90777f3 100644 --- a/crates/dustfril-core/src/scanner/detector.rs +++ b/crates/dustfril-core/src/scanner/detector.rs @@ -1,15 +1,22 @@ use std::path::Path; -use crate::models::Ecosystem; +use crate::models::{Artifact, Ecosystem}; +/// Registered detectors for all supported ecosystems. pub static DETECTORS: &[&dyn Detector] = &[&RustDetector, &NodeDetector, &JavaDetector]; +/// Matches project roots and returns removable artifact directories. pub trait Detector: Sync { - fn detect(&self, root: &Path) -> bool; + /// Is this directory a project of this ecosystem? + fn matches(&self, root: &Path) -> bool; + + /// Find removable artifacts inside this project. + fn artifacts(&self, root: &Path) -> Vec; fn ecosystem(&self) -> Ecosystem; } +/// Returns the detector set matching the requested ecosystem filters. pub fn detectors(ecosystems: &[Ecosystem]) -> Vec<&'static dyn Detector> { if ecosystems.is_empty() { return DETECTORS.to_vec(); @@ -22,38 +29,117 @@ pub fn detectors(ecosystems: &[Ecosystem]) -> Vec<&'static dyn Detector> { .collect() } +/// Detects Cargo `target/` directories. pub struct RustDetector; impl Detector for RustDetector { - fn detect(&self, root: &Path) -> bool { - root.join("Cargo.toml").is_file() + fn matches(&self, root: &Path) -> bool { + root.join("Cargo.toml").is_file() && root.join("target").is_dir() + } + + fn artifacts(&self, root: &Path) -> Vec { + let mut artifacts = Vec::new(); + + let target = root.join("target"); + + if target.is_dir() { + artifacts.push(Artifact::new(target, Ecosystem::Rust)); + } + + artifacts } fn ecosystem(&self) -> Ecosystem { Ecosystem::Rust } } + +/// Detects `node_modules/` directories for Node projects. pub struct NodeDetector; impl Detector for NodeDetector { - fn detect(&self, root: &Path) -> bool { - root.join("package.json").is_file() + fn matches(&self, root: &Path) -> bool { + root.join("package.json").is_file() && root.join("node_modules").is_dir() } + fn artifacts(&self, root: &Path) -> Vec { + let mut artifacts = Vec::new(); + + let modules = root.join("node_modules"); + + if modules.is_dir() { + artifacts.push(Artifact::new(modules, Ecosystem::Node)); + } + + artifacts + } fn ecosystem(&self) -> Ecosystem { Ecosystem::Node } } + +/// Detects `build/` directories for Maven and Gradle projects. pub struct JavaDetector; impl Detector for JavaDetector { - fn detect(&self, root: &Path) -> bool { - root.join("pom.xml").is_file() + fn matches(&self, root: &Path) -> bool { + (root.join("pom.xml").is_file() || root.join("build.gradle").is_file() - || root.join("build.gradle.kts").is_file() + || root.join("build.gradle.kts").is_file()) + && root.join("build").is_dir() + } + + fn artifacts(&self, root: &Path) -> Vec { + let mut artifacts = Vec::new(); + let build = root.join("build"); + + if build.is_dir() + && (root.join("pom.xml").is_file() + || root.join("build.gradle").is_file() + || root.join("build.gradle.kts").is_file()) + { + artifacts.push(Artifact::new(build, Ecosystem::Java)); + } + + artifacts } fn ecosystem(&self) -> Ecosystem { Ecosystem::Java } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detectors_returns_all_when_filter_is_empty() { + let detectors = detectors(&[]); + + assert_eq!(detectors.len(), 3); + assert!( + detectors + .iter() + .any(|detector| detector.ecosystem() == Ecosystem::Rust) + ); + assert!( + detectors + .iter() + .any(|detector| detector.ecosystem() == Ecosystem::Node) + ); + assert!( + detectors + .iter() + .any(|detector| detector.ecosystem() == Ecosystem::Java) + ); + } + + #[test] + fn detectors_filters_to_requested_ecosystem() { + let detectors = detectors(&[Ecosystem::Node]); + + assert_eq!(detectors.len(), 1); + assert_eq!(detectors[0].ecosystem(), Ecosystem::Node); + } +} diff --git a/crates/dustfril-core/src/scanner/scan.rs b/crates/dustfril-core/src/scanner/scan.rs index 2b9478b..4c470d6 100644 --- a/crates/dustfril-core/src/scanner/scan.rs +++ b/crates/dustfril-core/src/scanner/scan.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::{ error::DustResult, fs::walk_dirs, - models::{Artifact, Ecosystem, ScanResult}, + models::{Ecosystem, ScanResult}, scanner::detector::{self}, }; @@ -14,12 +14,11 @@ pub fn scan(root: &Path, ecosystems: &[Ecosystem]) -> DustResult { for dir in walk_dirs(root) { for detector in &detectors { - if detector.detect(&dir) { - result - .artifacts - .push(Artifact::new(dir.clone(), detector.ecosystem())); - break; + if !detector.matches(&dir) { + continue; } + + result.artifacts.extend(detector.artifacts(&dir)); } } diff --git a/crates/dustfril-core/src/scanner/tests.rs b/crates/dustfril-core/src/scanner/tests.rs index 0e60064..5d105a1 100644 --- a/crates/dustfril-core/src/scanner/tests.rs +++ b/crates/dustfril-core/src/scanner/tests.rs @@ -2,6 +2,27 @@ use tempfile::TempDir; use crate::{models::Ecosystem, scanner::scan}; +fn create_rust_artifact(root: &std::path::Path) -> std::path::PathBuf { + std::fs::write(root.join("Cargo.toml"), "[package]").unwrap(); + let target = root.join("target"); + std::fs::create_dir_all(&target).unwrap(); + target +} + +fn create_node_artifact(root: &std::path::Path) -> std::path::PathBuf { + std::fs::write(root.join("package.json"), "{}").unwrap(); + let node_modules = root.join("node_modules"); + std::fs::create_dir_all(&node_modules).unwrap(); + node_modules +} + +fn create_java_artifact(root: &std::path::Path) -> std::path::PathBuf { + std::fs::write(root.join("pom.xml"), "").unwrap(); + let build = root.join("build"); + std::fs::create_dir_all(&build).unwrap(); + build +} + #[test] fn scan_returns_empty_when_no_projects() { let temp_dir = TempDir::new().unwrap(); @@ -15,7 +36,7 @@ fn scan_returns_empty_when_no_projects() { fn scan_detects_rust_project() { let temp_dir = TempDir::new().unwrap(); - std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]").unwrap(); + let target = create_rust_artifact(temp_dir.path()); let result = scan(temp_dir.path(), &[]).unwrap(); @@ -24,31 +45,33 @@ fn scan_detects_rust_project() { let artifact = &result.artifacts[0]; assert_eq!(artifact.ecosystem, Ecosystem::Rust); - assert_eq!(artifact.path, temp_dir.path()); + assert_eq!(artifact.path, target); } #[test] fn scan_detects_node_project() { let temp_dir = TempDir::new().unwrap(); - std::fs::write(temp_dir.path().join("package.json"), "{}").unwrap(); + let node_modules = create_node_artifact(temp_dir.path()); let result = scan(temp_dir.path(), &[]).unwrap(); assert_eq!(result.artifacts.len(), 1); assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Node); + assert_eq!(result.artifacts[0].path, node_modules); } #[test] fn scan_detects_java_project() { let temp_dir = TempDir::new().unwrap(); - std::fs::write(temp_dir.path().join("pom.xml"), "").unwrap(); + let build = create_java_artifact(temp_dir.path()); let result = scan(temp_dir.path(), &[]).unwrap(); assert_eq!(result.artifacts.len(), 1); assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Java); + assert_eq!(result.artifacts[0].path, build); } #[test] @@ -61,8 +84,8 @@ fn scan_detects_multiple_projects() { std::fs::create_dir_all(&rust).unwrap(); std::fs::create_dir_all(&node).unwrap(); - std::fs::write(rust.join("Cargo.toml"), "[package]").unwrap(); - std::fs::write(node.join("package.json"), "{}").unwrap(); + let rust_target = create_rust_artifact(&rust); + let node_modules = create_node_artifact(&node); let result = scan(temp_dir.path(), &[]).unwrap(); @@ -72,14 +95,14 @@ fn scan_detects_multiple_projects() { result .artifacts .iter() - .any(|a| a.ecosystem == Ecosystem::Rust) + .any(|a| a.ecosystem == Ecosystem::Rust && a.path == rust_target) ); assert!( result .artifacts .iter() - .any(|a| a.ecosystem == Ecosystem::Node) + .any(|a| a.ecosystem == Ecosystem::Node && a.path == node_modules) ); } @@ -93,13 +116,14 @@ fn scan_filters_rust_only() { std::fs::create_dir_all(&rust).unwrap(); std::fs::create_dir_all(&node).unwrap(); - std::fs::write(rust.join("Cargo.toml"), "[package]").unwrap(); - std::fs::write(node.join("package.json"), "{}").unwrap(); + let rust_target = create_rust_artifact(&rust); + create_node_artifact(&node); let result = scan(temp_dir.path(), &[Ecosystem::Rust]).unwrap(); assert_eq!(result.artifacts.len(), 1); assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Rust); + assert_eq!(result.artifacts[0].path, rust_target); } #[test] @@ -112,20 +136,21 @@ fn scan_filters_node_only() { std::fs::create_dir_all(&rust).unwrap(); std::fs::create_dir_all(&node).unwrap(); - std::fs::write(rust.join("Cargo.toml"), "[package]").unwrap(); - std::fs::write(node.join("package.json"), "{}").unwrap(); + create_rust_artifact(&rust); + let node_modules = create_node_artifact(&node); let result = scan(temp_dir.path(), &[Ecosystem::Node]).unwrap(); assert_eq!(result.artifacts.len(), 1); assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Node); + assert_eq!(result.artifacts[0].path, node_modules); } #[test] fn scan_with_unknown_filter_returns_empty() { let temp_dir = TempDir::new().unwrap(); - std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]").unwrap(); + create_rust_artifact(temp_dir.path()); let result = scan(temp_dir.path(), &[Ecosystem::Java]).unwrap();