diff --git a/Cargo.lock b/Cargo.lock index fd2fcd7..c7d7212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -939,8 +958,10 @@ dependencies = [ name = "dustfril-core" version = "0.1.0" dependencies = [ + "rayon", "serde", "tempfile", + "walkdir", ] [[package]] @@ -961,6 +982,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "embed-resource" version = "3.0.9" @@ -2785,6 +2812,26 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/apps/dustfril-cli/src/cli.rs b/apps/dustfril-cli/src/cli.rs index b6def28..ee824aa 100644 --- a/apps/dustfril-cli/src/cli.rs +++ b/apps/dustfril-cli/src/cli.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; +use dustfril_core::models::Ecosystem; /// DustFril CLI #[derive(Parser)] @@ -27,9 +28,34 @@ pub enum Commands { pub struct PathArgs { pub path: Option, - /// Scan the entire system instead of a specific workspace. #[arg(long)] - pub global: bool, + pub rust: bool, + + #[arg(long)] + pub node: bool, + + #[arg(long)] + pub java: bool, +} + +impl PathArgs { + pub fn ecosystems(&self) -> Vec { + let mut ecosystems = Vec::new(); + + if self.rust { + ecosystems.push(Ecosystem::Rust); + } + + if self.node { + ecosystems.push(Ecosystem::Node); + } + + if self.java { + ecosystems.push(Ecosystem::Java); + } + + ecosystems + } } #[derive(Args)] @@ -41,3 +67,9 @@ pub struct CleanArgs { #[arg(long)] pub dry_run: bool, } + +impl CleanArgs { + pub fn ecosystems(&self) -> Vec { + self.path_args.ecosystems() + } +} diff --git a/apps/dustfril-cli/src/commands/analyze.rs b/apps/dustfril-cli/src/commands/analyze.rs index 0d93d4b..b178c51 100644 --- a/apps/dustfril-cli/src/commands/analyze.rs +++ b/apps/dustfril-cli/src/commands/analyze.rs @@ -18,7 +18,7 @@ fn print_summary(analysis: &AnalysisResult) { keep += 1; } - CleanupRecommendation::Review => { + CleanupRecommendation::NeedsReview => { review += 1; review_size += artifact.size_bytes; } @@ -64,7 +64,9 @@ pub fn execute(args: PathArgs) { return; } - let scan_result = match api::scan(&path, args.global) { + let ecosystems = args.ecosystems(); + + let scan_result = match api::scan(&path, &ecosystems) { Ok(res) => res, Err(e) => { eprintln!("Scan failed: {}", e); @@ -81,30 +83,30 @@ pub fn execute(args: PathArgs) { }; if analysis_result.artifacts.is_empty() { - println!("No Rust artifacts found."); + println!("No artifacts found."); return; } println!("Found {} artifact(s)\n", analysis_result.artifacts.len()); for artifact in &analysis_result.artifacts { - println!("[{}]", artifact.artifact.artifact_type); - println!(" Path: {}", artifact.artifact.path.display()); - println!(" Size: {}", format::format_size(artifact.size_bytes)); - - println!( - " Modified: {}", - format::format_modified(artifact.last_modified) - ); - let age_display = artifact .age_days .map(|d| format!("{d} days")) .unwrap_or_else(|| "Unknown".to_string()); - println!(" Age: {}", age_display); - - println!(" Recommendation: {}\n", artifact.recommendation); + println!("[{}]", artifact.artifact.ecosystem); + println!(" Path: {}", artifact.artifact.path.display()); + println!( + " Size: {}", + format::format_size(artifact.size_bytes) + ); + println!( + " Modified: {}", + format::format_modified(artifact.last_modified) + ); + println!(" Age: {}", age_display); + println!(" Recommendation: {}", artifact.recommendation); } print_summary(&analysis_result); diff --git a/apps/dustfril-cli/src/commands/clean.rs b/apps/dustfril-cli/src/commands/clean.rs index aaeb354..3af16b3 100644 --- a/apps/dustfril-cli/src/commands/clean.rs +++ b/apps/dustfril-cli/src/commands/clean.rs @@ -1,16 +1,22 @@ +use std::io::{self, Write}; + use dustfril_core::{ api, error::DustError, models::{CleanupPlan, CleanupResult}, }; -use crate::{cli::CleanArgs, format, shared::path::resolve_path}; +use crate::{ + cli::CleanArgs, + format, + shared::path::{resolve_path, validate_path}, +}; pub fn dry_run(args: &CleanArgs) { let plan = match build_cleanup_plan(args) { Ok(plan) => plan, Err(e) => { - eprintln!("Scan failed: {}", e); + eprintln!("Cleanup preview failed: {}", e); return; } }; @@ -24,20 +30,56 @@ pub fn dry_run(args: &CleanArgs) { println!("No files were deleted."); } -use std::io::{self, Write}; +pub fn execute(args: &CleanArgs) { + let plan = match build_cleanup_plan(args) { + Ok(plan) => plan, + Err(e) => { + eprintln!("Cleanup preparation failed: {}", e); + return; + } + }; + + if plan.candidates.is_empty() { + println!("No cleanup candidates found."); + return; + } + + print_cleanup_plan(&plan); + + if !confirm_cleanup() { + println!("Cleanup cancelled."); + return; + } + + let result = match api::clean::execute(&plan) { + Ok(res) => res, + Err(e) => { + eprintln!("Cleanup failed: {}", e); + return; + } + }; + + print_cleanup_result(&result); +} fn build_cleanup_plan(args: &CleanArgs) -> Result { let path = resolve_path(&args.path_args.path); - let scan = api::scan(&path, args.path_args.global)?; + if !validate_path(&path) { + return Err(DustError::InvalidPath(path)); + } + + let ecosystems = args.ecosystems(); + + let scan = api::scan(&path, &ecosystems)?; let plan = api::clean::build_plan(scan)?; Ok(plan) } + fn confirm_cleanup() -> bool { print!("Continue? (y/N): "); - // Flush stdout to ensure the prompt is displayed before reading input io::stdout().flush().expect("Failed to flush stdout"); let mut input = String::new(); @@ -53,65 +95,40 @@ fn print_cleanup_plan(plan: &CleanupPlan) { println!("Cleanup Preview\n"); for candidate in &plan.candidates { - println!("[{}]", candidate.artifact_type); - + println!("[{}]", candidate.ecosystem); println!(" Path: {}", candidate.path.display()); + println!(" Size: {}", format::format_size(candidate.size_bytes)); - println!(" Size: {}\n", format::format_size(candidate.size_bytes)); - } + if let Some(age_days) = candidate.age_days { + println!(" Age: {} day(s)", age_days); + } - println!("Total Reclaimable Space\n"); + println!(); + } + println!("Total Reclaimable Space"); println!(" {}\n", format::format_size(plan.reclaimable_size_bytes())); } fn print_cleanup_result(result: &CleanupResult) { println!("Cleanup completed."); - println!("Deleted: {}", result.deleted_paths.len()); - println!("Failed: {}", result.failed_paths.len()); - println!("Freed: {}", format::format_size(result.freed_size_bytes)); if !result.deleted_paths.is_empty() { - println!("Deleted\n"); + println!("\nDeleted"); for path in &result.deleted_paths { println!(" {}", path.display()); } - - println!(); - } -} -pub fn execute(args: &CleanArgs) { - let plan = match build_cleanup_plan(args) { - Ok(plan) => plan, - Err(e) => { - eprintln!("Scan failed: {}", e); - return; - } - }; - - if plan.candidates.is_empty() { - println!("No cleanup candidates found."); - return; } - print_cleanup_plan(&plan); - - if !confirm_cleanup() { - println!("Cleanup cancelled."); - return; - } + if !result.failed_paths.is_empty() { + println!("\nFailed"); - let result = match api::clean::execute(&plan) { - Ok(res) => res, - Err(e) => { - eprintln!("Cleanup failed: {}", e); - return; + for path in &result.failed_paths { + println!(" {}", path.display()); } - }; - - print_cleanup_result(&result); + } } diff --git a/apps/dustfril-cli/src/commands/scan.rs b/apps/dustfril-cli/src/commands/scan.rs index e2dea0a..72d00f0 100644 --- a/apps/dustfril-cli/src/commands/scan.rs +++ b/apps/dustfril-cli/src/commands/scan.rs @@ -13,7 +13,9 @@ pub fn execute(args: PathArgs) { return; } - let result = match api::scan(&path, args.global) { + let ecosystems = args.ecosystems(); + + let result = match api::scan(&path, &ecosystems) { Ok(res) => res, Err(e) => { eprintln!("Scan failed: {}", e); @@ -22,15 +24,13 @@ pub fn execute(args: PathArgs) { }; if result.artifacts.is_empty() { - println!("No Rust artifacts found."); + println!("No artifacts found."); return; } println!("Found {} artifact(s)\n", result.artifacts.len()); for artifact in result.artifacts { - println!("[{}]", artifact.artifact_type); - - println!(" {}\n", artifact.path.display()); + println!(" {:?}\n", artifact.path); } } diff --git a/crates/dustfril-core/Cargo.toml b/crates/dustfril-core/Cargo.toml index 721b557..fd05091 100644 --- a/crates/dustfril-core/Cargo.toml +++ b/crates/dustfril-core/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +rayon = "1.12.0" serde = { version = "1", features = ["derive"] } +walkdir = "2.5.0" [dev-dependencies] tempfile = "3.0" diff --git a/crates/dustfril-core/src/analyzer/age.rs b/crates/dustfril-core/src/analyzer/age.rs deleted file mode 100644 index 62e72ad..0000000 --- a/crates/dustfril-core/src/analyzer/age.rs +++ /dev/null @@ -1,11 +0,0 @@ -use std::time::SystemTime; - -pub fn calculate_age_days(modified: Option) -> Option { - let modified = modified?; - - let now = SystemTime::now(); - - let duration = now.duration_since(modified).ok()?; - - Some(duration.as_secs() / 86_400) -} diff --git a/crates/dustfril-core/src/analyzer/analyze.rs b/crates/dustfril-core/src/analyzer/analyze.rs index 38ce052..b445432 100644 --- a/crates/dustfril-core/src/analyzer/analyze.rs +++ b/crates/dustfril-core/src/analyzer/analyze.rs @@ -1,34 +1,121 @@ -use crate::{ - analyzer::{ - calculate_age_days, calculate_directory_size, find_latest_modified, recommend_cleanup, - }, - error::DustResult, - models::{AnalysisResult, ArtifactAnalysis, ScanResult}, +use std::fs; +use std::path::Path; +use std::time::SystemTime; + +use crate::error::DustResult; +use crate::models::{ + AnalysisResult, Artifact, ArtifactAnalysis, CleanupRecommendation, ScanResult, }; +use rayon::prelude::*; + +pub struct Analyzer; + +impl Analyzer { + pub fn analyze(scan_result: ScanResult) -> DustResult { + let artifacts: Vec = scan_result + .artifacts + .into_par_iter() + .map(Self::analyze_artifact) + .collect(); + + let total_size_bytes = artifacts.iter().map(|a| a.size_bytes).sum(); -pub fn analyze(scan_result: ScanResult) -> DustResult { - let mut result = AnalysisResult::default(); + Ok(AnalysisResult { + artifacts, + total_size_bytes, + }) + } - for artifact in scan_result.artifacts { + fn analyze_artifact(artifact: Artifact) -> ArtifactAnalysis { let size_bytes = calculate_directory_size(&artifact.path); let last_modified = find_latest_modified(&artifact.path); let age_days = calculate_age_days(last_modified); let recommendation = recommend_cleanup(age_days); - result.total_size_bytes += size_bytes; - - result.artifacts.push(ArtifactAnalysis { + ArtifactAnalysis { artifact, size_bytes, last_modified, age_days, recommendation, - }); + } } +} + +fn calculate_age_days(modified: Option) -> Option { + let modified = modified?; + let duration = SystemTime::now().duration_since(modified).ok()?; - result - .artifacts - .sort_by_key(|b| std::cmp::Reverse(b.size_bytes)); + const SECONDS_PER_DAY: u64 = 60 * 60 * 24; + + Some(duration.as_secs() / SECONDS_PER_DAY) +} + +fn recommend_cleanup(age_days: Option) -> CleanupRecommendation { + let Some(days) = age_days else { + return CleanupRecommendation::NeedsReview; + }; + + const KEEP_DAYS: u64 = 30; + const REVIEW_DAYS: u64 = 90; + + if days <= KEEP_DAYS { + CleanupRecommendation::Keep + } else if days <= REVIEW_DAYS { + CleanupRecommendation::NeedsReview + } else { + CleanupRecommendation::SafeToClean + } +} + +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 +} + +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); + } + } - Ok(result) + latest } diff --git a/crates/dustfril-core/src/analyzer/metadata.rs b/crates/dustfril-core/src/analyzer/metadata.rs deleted file mode 100644 index 1838a18..0000000 --- a/crates/dustfril-core/src/analyzer/metadata.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::{fs, path::Path, time::SystemTime}; - -pub 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 metadata = entry.metadata().ok(); - - let current = match metadata { - Some(metadata) if metadata.is_dir() => find_latest_modified(&path), - - Some(metadata) => metadata.modified().ok(), - - None => None, - }; - - if let Some(current) = current { - match latest { - Some(existing) if current <= existing => {} - - _ => { - latest = Some(current); - } - } - } - } - - latest -} diff --git a/crates/dustfril-core/src/analyzer/mod.rs b/crates/dustfril-core/src/analyzer/mod.rs index 198e79b..59beb5c 100644 --- a/crates/dustfril-core/src/analyzer/mod.rs +++ b/crates/dustfril-core/src/analyzer/mod.rs @@ -1,15 +1,8 @@ //! Analyzer module. -mod age; + mod analyze; -mod metadata; -mod recommendation; -mod size; #[cfg(test)] mod tests; -pub use age::calculate_age_days; -pub use analyze::analyze; -pub use metadata::find_latest_modified; -pub use recommendation::recommend_cleanup; -pub use size::calculate_directory_size; +pub use analyze::Analyzer; diff --git a/crates/dustfril-core/src/analyzer/recommendation.rs b/crates/dustfril-core/src/analyzer/recommendation.rs deleted file mode 100644 index 42ff2e4..0000000 --- a/crates/dustfril-core/src/analyzer/recommendation.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::models::CleanupRecommendation; - -pub fn recommend_cleanup(age_days: Option) -> CleanupRecommendation { - let Some(days) = age_days else { - return CleanupRecommendation::Review; - }; - - match days { - 0..=30 => CleanupRecommendation::Keep, - - 31..=90 => CleanupRecommendation::Review, - - _ => CleanupRecommendation::SafeToClean, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn keep_when_recent() { - assert_eq!(recommend_cleanup(Some(10)), CleanupRecommendation::Keep); - } - - #[test] - fn review_when_middle_age() { - assert_eq!(recommend_cleanup(Some(60)), CleanupRecommendation::Review); - } - - #[test] - fn safe_to_clean_when_old() { - assert_eq!( - recommend_cleanup(Some(180)), - CleanupRecommendation::SafeToClean - ); - } -} diff --git a/crates/dustfril-core/src/analyzer/size.rs b/crates/dustfril-core/src/analyzer/size.rs deleted file mode 100644 index c01b0fe..0000000 --- a/crates/dustfril-core/src/analyzer/size.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::{fs, path::Path}; - -pub 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 -} diff --git a/crates/dustfril-core/src/analyzer/tests.rs b/crates/dustfril-core/src/analyzer/tests.rs index 0e7c08f..21bff3e 100644 --- a/crates/dustfril-core/src/analyzer/tests.rs +++ b/crates/dustfril-core/src/analyzer/tests.rs @@ -1,101 +1,123 @@ -use std::fs; +use std::{fs, path::PathBuf}; + use tempfile::TempDir; -use crate::analyzer::{calculate_age_days, calculate_directory_size, find_latest_modified}; -use crate::{ - analyzer::analyze, - models::{Artifact, ArtifactType, ScanResult}, -}; +use crate::{analyzer::Analyzer, models::*}; + +fn scan_result(path: PathBuf, ecosystem: Ecosystem) -> ScanResult { + ScanResult { + artifacts: vec![Artifact { path, ecosystem }], + } +} #[test] fn analyze_empty_scan_result() { - let result = analyze(ScanResult::default()).unwrap(); - - assert_eq!(result.total_size_bytes, 0); + let analysis = Analyzer::analyze(ScanResult::default()).unwrap(); - assert!(result.artifacts.is_empty()); + assert!(analysis.artifacts.is_empty()); + assert_eq!(analysis.total_size_bytes, 0); } #[test] -fn calculate_directory_size_returns_total_size() { - let temp_dir = TempDir::new().unwrap(); +fn analyze_calculates_directory_size() { + let dir = TempDir::new().unwrap(); - fs::write(temp_dir.path().join("a.txt"), vec![0_u8; 100]).unwrap(); + fs::write(dir.path().join("a.txt"), b"hello").unwrap(); + fs::write(dir.path().join("b.txt"), b"world").unwrap(); - fs::write(temp_dir.path().join("b.txt"), vec![0_u8; 200]).unwrap(); + let analysis = + Analyzer::analyze(scan_result(dir.path().to_path_buf(), Ecosystem::Rust)).unwrap(); - let size = calculate_directory_size(temp_dir.path()); + assert_eq!(analysis.artifacts.len(), 1); - assert_eq!(size, 300); + assert_eq!(analysis.total_size_bytes, 10); + + assert_eq!(analysis.artifacts[0].size_bytes, 10); } #[test] -fn analyze_returns_total_size() { - let temp_dir = TempDir::new().unwrap(); +fn analyze_sets_cleanup_recommendation() { + let dir = TempDir::new().unwrap(); + + fs::write(dir.path().join("file.txt"), b"hello").unwrap(); - fs::write(temp_dir.path().join("a.txt"), vec![0_u8; 100]).unwrap(); + let analysis = + Analyzer::analyze(scan_result(dir.path().to_path_buf(), Ecosystem::Rust)).unwrap(); - let artifact = Artifact { - path: temp_dir.path().to_path_buf(), - artifact_type: ArtifactType::Target, - }; + assert!(matches!( + analysis.artifacts[0].recommendation, + CleanupRecommendation::Keep + | CleanupRecommendation::NeedsReview + | CleanupRecommendation::SafeToClean + )); +} - let scan_result = ScanResult { - artifacts: vec![artifact], - }; +#[test] +fn analyze_sets_last_modified() { + let dir = TempDir::new().unwrap(); - let result = analyze(scan_result).unwrap(); + fs::write(dir.path().join("file.txt"), b"hello").unwrap(); - assert_eq!(result.total_size_bytes, 100); + let analysis = + Analyzer::analyze(scan_result(dir.path().to_path_buf(), Ecosystem::Rust)).unwrap(); - assert_eq!(result.artifacts.len(), 1); + assert!(analysis.artifacts[0].last_modified.is_some()); } #[test] -fn find_latest_modified_returns_some() { - let temp_dir = TempDir::new().unwrap(); +fn analyze_sets_age_days() { + let dir = TempDir::new().unwrap(); + + fs::write(dir.path().join("file.txt"), b"hello").unwrap(); - let modified = find_latest_modified(temp_dir.path()); + let analysis = + Analyzer::analyze(scan_result(dir.path().to_path_buf(), Ecosystem::Rust)).unwrap(); - assert!(modified.is_some()); + assert!(analysis.artifacts[0].age_days.is_some()); } #[test] -fn analyze_sorts_by_size_descending() { - let small = TempDir::new().unwrap(); - let large = TempDir::new().unwrap(); +fn analyze_multiple_artifacts() { + let root = TempDir::new().unwrap(); - fs::write(small.path().join("small.bin"), vec![0_u8; 100]).unwrap(); + let rust = root.path().join("rust"); + let node = root.path().join("node"); - fs::write(large.path().join("large.bin"), vec![0_u8; 200]).unwrap(); + fs::create_dir_all(&rust).unwrap(); + fs::create_dir_all(&node).unwrap(); - let scan_result = ScanResult { + fs::write(rust.join("a.txt"), b"12345").unwrap(); + fs::write(node.join("b.txt"), b"1234567890").unwrap(); + + let analysis = Analyzer::analyze(ScanResult { artifacts: vec![ Artifact { - path: small.path().to_path_buf(), - artifact_type: ArtifactType::Target, + path: rust, + ecosystem: Ecosystem::Rust, }, Artifact { - path: large.path().to_path_buf(), - artifact_type: ArtifactType::CargoRegistry, + path: node, + ecosystem: Ecosystem::Node, }, ], - }; - - let result = analyze(scan_result).unwrap(); + }) + .unwrap(); - assert_eq!(result.artifacts[0].size_bytes, 200); + assert_eq!(analysis.artifacts.len(), 2); - assert_eq!(result.artifacts[1].size_bytes, 100); + assert_eq!(analysis.total_size_bytes, 15); } -use std::time::{Duration, SystemTime}; - #[test] -fn calculate_age_days_returns_correct_days() { - let modified = SystemTime::now() - Duration::from_secs(10 * 86_400); +fn analyze_preserves_artifact_metadata() { + let dir = TempDir::new().unwrap(); + + let analysis = + Analyzer::analyze(scan_result(dir.path().to_path_buf(), Ecosystem::Node)).unwrap(); + + let artifact = &analysis.artifacts[0]; - let age = calculate_age_days(Some(modified)); + assert_eq!(artifact.artifact.ecosystem, Ecosystem::Node); - assert_eq!(age, Some(10),); + assert_eq!(artifact.artifact.path, dir.path()); } diff --git a/crates/dustfril-core/src/api/analyze.rs b/crates/dustfril-core/src/api/analyze.rs index 8d2bd39..30a4d53 100644 --- a/crates/dustfril-core/src/api/analyze.rs +++ b/crates/dustfril-core/src/api/analyze.rs @@ -5,5 +5,5 @@ use crate::{ }; pub fn analyze(scan_result: ScanResult) -> DustResult { - analyzer::analyze(scan_result) + analyzer::Analyzer::analyze(scan_result) } diff --git a/crates/dustfril-core/src/api/clean.rs b/crates/dustfril-core/src/api/clean.rs index b5c4979..0e14c5c 100644 --- a/crates/dustfril-core/src/api/clean.rs +++ b/crates/dustfril-core/src/api/clean.rs @@ -5,7 +5,7 @@ use crate::{ }; pub fn build_plan(scan: ScanResult) -> DustResult { - let analysis = analyzer::analyze(scan)?; + let analysis = analyzer::Analyzer::analyze(scan)?; cleaner::create_cleanup_plan(analysis) } diff --git a/crates/dustfril-core/src/api/scan.rs b/crates/dustfril-core/src/api/scan.rs index 66c90cd..a63d9e0 100644 --- a/crates/dustfril-core/src/api/scan.rs +++ b/crates/dustfril-core/src/api/scan.rs @@ -1,11 +1,11 @@ use std::path::Path; -use crate::{detector, error::DustResult, models::ScanResult}; +use crate::{ + error::DustResult, + models::{Ecosystem, ScanResult}, + scanner, +}; -pub fn scan(root: &Path, global: bool) -> DustResult { - if global { - detector::scan_global() - } else { - detector::scan_workspace(root) - } +pub fn scan(root: &Path, ecosystems: &[Ecosystem]) -> DustResult { + scanner::scan(root, ecosystems) } diff --git a/crates/dustfril-core/src/cleaner/executor.rs b/crates/dustfril-core/src/cleaner/executor.rs index 73ceb6b..b10a79f 100644 --- a/crates/dustfril-core/src/cleaner/executor.rs +++ b/crates/dustfril-core/src/cleaner/executor.rs @@ -2,26 +2,29 @@ use std::fs; use crate::{ error::DustResult, - models::{ArtifactType, CleanupPlan, CleanupResult}, + models::{CleanupPlan, CleanupResult}, }; pub fn execute_cleanup(plan: &CleanupPlan) -> DustResult { let mut result = CleanupResult { - deleted_paths: vec![], - failed_paths: vec![], + deleted_paths: Vec::new(), + failed_paths: Vec::new(), freed_size_bytes: 0, }; for candidate in &plan.candidates { - match candidate.artifact_type { - ArtifactType::Target | ArtifactType::CargoRegistry | ArtifactType::CargoGit => { - if fs::remove_dir_all(&candidate.path).is_ok() { - result.deleted_paths.push(candidate.path.clone()); + match if candidate.path.is_dir() { + fs::remove_dir_all(&candidate.path) + } else { + fs::remove_file(&candidate.path) + } { + Ok(_) => { + result.deleted_paths.push(candidate.path.clone()); + result.freed_size_bytes += candidate.size_bytes; + } - result.freed_size_bytes += candidate.size_bytes; - } else { - result.failed_paths.push(candidate.path.clone()); - } + Err(_) => { + result.failed_paths.push(candidate.path.clone()); } } } diff --git a/crates/dustfril-core/src/cleaner/plan.rs b/crates/dustfril-core/src/cleaner/plan.rs index 1ed8e70..75a0af0 100644 --- a/crates/dustfril-core/src/cleaner/plan.rs +++ b/crates/dustfril-core/src/cleaner/plan.rs @@ -6,21 +6,17 @@ use crate::{ pub fn create_cleanup_plan(analysis: AnalysisResult) -> DustResult { let mut plan = CleanupPlan::default(); - for artifact_analysis in analysis.artifacts { - if artifact_analysis.recommendation == CleanupRecommendation::SafeToClean { - // Flatten the analysis into a cleanup candidate - plan.candidates.push(CleanupCandidate { - path: artifact_analysis.artifact.path.clone(), - - artifact_type: artifact_analysis.artifact.artifact_type, - - size_bytes: artifact_analysis.size_bytes, - - age_days: artifact_analysis.age_days, - - recommendation: artifact_analysis.recommendation, - }); + for artifact in analysis.artifacts { + if artifact.recommendation != CleanupRecommendation::SafeToClean { + continue; } + + plan.candidates.push(CleanupCandidate { + path: artifact.artifact.path, + ecosystem: artifact.artifact.ecosystem, + size_bytes: artifact.size_bytes, + age_days: artifact.age_days, + }); } Ok(plan) diff --git a/crates/dustfril-core/src/cleaner/tests.rs b/crates/dustfril-core/src/cleaner/tests.rs index e57065a..d7e6b10 100644 --- a/crates/dustfril-core/src/cleaner/tests.rs +++ b/crates/dustfril-core/src/cleaner/tests.rs @@ -7,13 +7,46 @@ use crate::{ models::*, }; +fn artifact(recommendation: CleanupRecommendation) -> ArtifactAnalysis { + ArtifactAnalysis { + artifact: Artifact { + path: PathBuf::from("target"), + ecosystem: Ecosystem::Rust, + }, + size_bytes: 100, + last_modified: None, + age_days: Some(100), + recommendation, + } +} + #[test] fn create_empty_cleanup_plan() { let plan = create_cleanup_plan(AnalysisResult::default()).unwrap(); assert!(plan.candidates.is_empty()); +} + +#[test] +fn reclaimable_size_bytes_returns_sum() { + let plan = CleanupPlan { + candidates: vec![ + CleanupCandidate { + path: PathBuf::from("a"), + ecosystem: Ecosystem::Rust, + size_bytes: 10, + age_days: None, + }, + CleanupCandidate { + path: PathBuf::from("b"), + ecosystem: Ecosystem::Node, + size_bytes: 20, + age_days: None, + }, + ], + }; - assert_eq!(plan.reclaimable_size_bytes(), 0); + assert_eq!(plan.reclaimable_size_bytes(), 30); } #[test] @@ -21,16 +54,11 @@ fn safe_to_clean_becomes_candidate() { let artifact = ArtifactAnalysis { artifact: Artifact { path: PathBuf::from("target"), - - artifact_type: ArtifactType::Target, + ecosystem: Ecosystem::Rust, }, - size_bytes: 100, - last_modified: None, - age_days: Some(200), - recommendation: CleanupRecommendation::SafeToClean, }; @@ -52,22 +80,16 @@ fn keep_is_not_candidate() { let artifact = ArtifactAnalysis { artifact: Artifact { path: PathBuf::from("target"), - - artifact_type: ArtifactType::Target, + ecosystem: Ecosystem::Rust, }, - size_bytes: 100, - last_modified: None, - age_days: Some(5), - recommendation: CleanupRecommendation::Keep, }; let analysis = AnalysisResult { artifacts: vec![artifact], - total_size_bytes: 100, }; @@ -90,14 +112,9 @@ fn execute_cleanup_removes_target_directory() { let candidate = CleanupCandidate { path: target_dir.clone(), - - artifact_type: ArtifactType::Target, - + ecosystem: Ecosystem::Rust, size_bytes: 5, - age_days: Some(100), - - recommendation: CleanupRecommendation::SafeToClean, }; let plan = CleanupPlan { @@ -106,12 +123,25 @@ fn execute_cleanup_removes_target_directory() { let result = execute_cleanup(&plan).unwrap(); - let size_bytes = CleanupPlan::reclaimable_size_bytes(&plan); - assert!(!target_dir.exists()); assert_eq!(result.deleted_paths.len(), 1); assert_eq!(result.failed_paths.len(), 0); - assert_eq!(size_bytes, 5); +} + +#[test] +fn create_cleanup_plan_filters_safe_to_clean() { + let analysis = AnalysisResult { + artifacts: vec![ + artifact(CleanupRecommendation::SafeToClean), + artifact(CleanupRecommendation::Keep), + artifact(CleanupRecommendation::NeedsReview), + ], + total_size_bytes: 300, + }; + + let plan = create_cleanup_plan(analysis).unwrap(); + + assert_eq!(plan.candidates.len(), 1); } #[test] @@ -121,23 +151,17 @@ fn cleanup_reports_failed_path() { let candidate = CleanupCandidate { path: missing, - artifact_type: ArtifactType::Target, + ecosystem: Ecosystem::Rust, size_bytes: 100, age_days: None, - recommendation: CleanupRecommendation::SafeToClean, }; let plan = CleanupPlan { candidates: vec![candidate], }; - let result = execute_cleanup(&plan).unwrap(); - assert_eq!(result.deleted_paths.len(), 0); - assert_eq!(result.failed_paths.len(), 1); - assert_eq!(result.freed_size_bytes, 0); - assert_eq!(plan.reclaimable_size_bytes(), 100); } diff --git a/crates/dustfril-core/src/detector/java/build.rs b/crates/dustfril-core/src/detector/java/build.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/dustfril-core/src/detector/java/gradle.rs b/crates/dustfril-core/src/detector/java/gradle.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/dustfril-core/src/detector/java/maven.rs b/crates/dustfril-core/src/detector/java/maven.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/dustfril-core/src/detector/java/mod.rs b/crates/dustfril-core/src/detector/java/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/dustfril-core/src/detector/mod.rs b/crates/dustfril-core/src/detector/mod.rs deleted file mode 100644 index 9beb060..0000000 --- a/crates/dustfril-core/src/detector/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -mod project; -mod rust; -mod scan; - -#[cfg(test)] -mod tests; - -pub use scan::*; diff --git a/crates/dustfril-core/src/detector/node/mod.rs b/crates/dustfril-core/src/detector/node/mod.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/dustfril-core/src/detector/node/node_modules.rs b/crates/dustfril-core/src/detector/node/node_modules.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/dustfril-core/src/detector/node/package_json.rs b/crates/dustfril-core/src/detector/node/package_json.rs deleted file mode 100644 index e69de29..0000000 diff --git a/crates/dustfril-core/src/detector/project.rs b/crates/dustfril-core/src/detector/project.rs deleted file mode 100644 index b981870..0000000 --- a/crates/dustfril-core/src/detector/project.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::{ - fs, - path::{Path, PathBuf}, -}; - -use serde::{Deserialize, Serialize}; - -use crate::models::Ecosystem; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Project { - pub root: PathBuf, - - pub ecosystem: Ecosystem, -} - -pub fn find_projects(root: &Path) -> Vec { - let mut projects = Vec::new(); - - visit(root, &mut projects); - - projects -} - -/// Cargo project detection and artifact scanning. -pub fn is_cargo_project(root: &Path) -> bool { - root.join("Cargo.toml").is_file() -} - -fn visit(dir: &Path, projects: &mut Vec) { - if is_cargo_project(dir) { - projects.push(Project { - root: dir.to_path_buf(), - ecosystem: Ecosystem::Rust, - }); - - return; - } - - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - - if path.is_dir() { - visit(&path, projects); - } - } -} diff --git a/crates/dustfril-core/src/detector/rust/git.rs b/crates/dustfril-core/src/detector/rust/git.rs deleted file mode 100644 index 697a8dc..0000000 --- a/crates/dustfril-core/src/detector/rust/git.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::{env, path::Path}; - -use crate::models::{Artifact, ArtifactType}; - -pub fn detect() -> Option { - // TODO: - // Replace HOME lookup with dirs::home_dir() - // for cross-platform support. - let Ok(home_dir) = env::var("HOME") else { - return None; - }; - - let git_path = Path::new(&home_dir).join(".cargo").join("git"); - - if !git_path.exists() { - return None; - } - - Some(Artifact { - path: git_path, - artifact_type: ArtifactType::CargoGit, - }) -} diff --git a/crates/dustfril-core/src/detector/rust/mod.rs b/crates/dustfril-core/src/detector/rust/mod.rs deleted file mode 100644 index 0b8da89..0000000 --- a/crates/dustfril-core/src/detector/rust/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod git; -pub mod registry; -pub mod target; diff --git a/crates/dustfril-core/src/detector/rust/registry.rs b/crates/dustfril-core/src/detector/rust/registry.rs deleted file mode 100644 index df24143..0000000 --- a/crates/dustfril-core/src/detector/rust/registry.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::{env, path::Path}; - -use crate::models::{Artifact, ArtifactType}; - -pub fn detect() -> Option { - // TODO: - // Replace HOME lookup with dirs::home_dir() - // for cross-platform support. - let Ok(home_dir) = env::var("HOME") else { - return None; - }; - - let registry_path = Path::new(&home_dir).join(".cargo").join("registry"); - - if !registry_path.exists() { - return None; - } - - Some(Artifact { - path: registry_path, - artifact_type: ArtifactType::CargoRegistry, - }) -} diff --git a/crates/dustfril-core/src/detector/rust/target.rs b/crates/dustfril-core/src/detector/rust/target.rs deleted file mode 100644 index 052baff..0000000 --- a/crates/dustfril-core/src/detector/rust/target.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std::path::Path; - -use crate::models::{Artifact, ArtifactType}; - -pub fn detect(root: &Path) -> Option { - let target_path = root.join("target"); - - if !target_path.exists() { - return None; - } - - Some(Artifact { - path: target_path, - artifact_type: ArtifactType::Target, - }) -} diff --git a/crates/dustfril-core/src/detector/scan.rs b/crates/dustfril-core/src/detector/scan.rs deleted file mode 100644 index a8a6f4d..0000000 --- a/crates/dustfril-core/src/detector/scan.rs +++ /dev/null @@ -1,61 +0,0 @@ -use std::path::Path; - -use crate::{error::DustResult, models::ScanResult}; - -use super::{ - project, - rust::{git, registry, target}, -}; - -// Scan a single Rust project for artifacts. -pub fn scan_project(root: &Path) -> DustResult { - let mut result = ScanResult::default(); - - if !project::is_cargo_project(root) { - return Ok(result); - } - - if let Some(target) = target::detect(root) { - result.artifacts.push(target); - } - - Ok(result) -} - -// Recursively scan for Rust projects and their artifacts. -pub fn scan_workspace(root: &Path) -> DustResult { - let mut result = ScanResult::default(); - - let projects = project::find_projects(root); - - for project in projects { - let project_result = scan_project(&project.root)?; - - result.artifacts.extend(project_result.artifacts); - } - - Ok(result) -} - -// Global artifacts that are not tied to a specific project, like Cargo registry and Git repositories. -pub fn scan_global() -> DustResult { - let mut result = ScanResult::default(); - - if let Some(registry) = registry::detect() { - result.artifacts.push(registry); - } - - if let Some(git) = git::detect() { - result.artifacts.push(git); - } - - Ok(result) -} - -// pub fn scan(root: &Path) -> ScanResult { -// let mut result = scan_workspace(root); - -// result.artifacts.extend(scan_global().artifacts); - -// result -// } diff --git a/crates/dustfril-core/src/detector/tests.rs b/crates/dustfril-core/src/detector/tests.rs deleted file mode 100644 index 2333475..0000000 --- a/crates/dustfril-core/src/detector/tests.rs +++ /dev/null @@ -1,98 +0,0 @@ -use tempfile::TempDir; - -use crate::{ - detector::{project::find_projects, scan::scan_workspace, scan_project}, - models::ArtifactType, -}; - -#[test] -fn scan_returns_empty_when_not_cargo_project() { - let temp_dir = TempDir::new().unwrap(); - - let result = scan_project(temp_dir.path()).unwrap(); - - assert!(result.artifacts.is_empty()); -} - -#[test] -fn cargo_project_without_target_returns_empty() { - let temp_dir = TempDir::new().unwrap(); - - std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]").unwrap(); - - let result = scan_project(temp_dir.path()).unwrap(); - - assert!(result.artifacts.is_empty()); -} - -#[test] -fn detects_target_directory() { - let temp_dir = TempDir::new().unwrap(); - - std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]").unwrap(); - - std::fs::create_dir(temp_dir.path().join("target")).unwrap(); - - let result = scan_project(temp_dir.path()).unwrap(); - - assert_eq!(result.artifacts.len(), 1); - - assert_eq!(result.artifacts[0].artifact_type, ArtifactType::Target); -} - -#[test] -fn finds_projects_recursively() { - let temp_dir = TempDir::new().unwrap(); - - let dustfril = temp_dir.path().join("dustfril"); - - let roguekit = temp_dir.path().join("roguekit"); - - std::fs::create_dir_all(&dustfril).unwrap(); - std::fs::create_dir_all(&roguekit).unwrap(); - - std::fs::write(dustfril.join("Cargo.toml"), "[package]").unwrap(); - std::fs::write(roguekit.join("Cargo.toml"), "[package]").unwrap(); - - let projects = find_projects(temp_dir.path()); - - assert_eq!(projects.len(), 2,); - - assert!(projects.iter().any(|p| p.root == dustfril)); - assert!(projects.iter().any(|p| p.root == roguekit)); -} - -#[test] -fn scan_workspace_finds_targets_from_multiple_projects() { - let temp_dir = TempDir::new().unwrap(); - - let project_a = temp_dir.path().join("project_a"); - let project_b = temp_dir.path().join("project_b"); - - std::fs::create_dir_all(&project_a).unwrap(); - std::fs::create_dir_all(&project_b).unwrap(); - - std::fs::write(project_a.join("Cargo.toml"), "[package]").unwrap(); - std::fs::write(project_b.join("Cargo.toml"), "[package]").unwrap(); - - std::fs::create_dir(project_a.join("target")).unwrap(); - std::fs::create_dir(project_b.join("target")).unwrap(); - - let result = scan_workspace(temp_dir.path()).unwrap(); - - assert_eq!(result.artifacts.len(), 2); - - assert!( - result - .artifacts - .iter() - .any(|a| a.path == project_a.join("target") && a.artifact_type == ArtifactType::Target) - ); - - assert!( - result - .artifacts - .iter() - .any(|a| a.path == project_b.join("target") && a.artifact_type == ArtifactType::Target) - ); -} diff --git a/crates/dustfril-core/src/error.rs b/crates/dustfril-core/src/error.rs index 63a3cd8..f586ae8 100644 --- a/crates/dustfril-core/src/error.rs +++ b/crates/dustfril-core/src/error.rs @@ -3,7 +3,7 @@ use std::fmt; #[derive(Debug)] pub enum DustError { Io(std::io::Error), - InvalidPath, + InvalidPath(std::path::PathBuf), ScanFailed, AnalysisFailed, CleanupFailed, @@ -17,8 +17,8 @@ impl fmt::Display for DustError { DustError::Io(error) => { write!(f, "I/O error: {error}") } - DustError::InvalidPath => { - write!(f, "Invalid path") + DustError::InvalidPath(path) => { + write!(f, "Invalid path: {}", path.display()) } DustError::ScanFailed => { write!(f, "Scan failed") diff --git a/crates/dustfril-core/src/fs/mod.rs b/crates/dustfril-core/src/fs/mod.rs new file mode 100644 index 0000000..bf410c2 --- /dev/null +++ b/crates/dustfril-core/src/fs/mod.rs @@ -0,0 +1,3 @@ +mod walk; + +pub use walk::*; diff --git a/crates/dustfril-core/src/fs/walk.rs b/crates/dustfril-core/src/fs/walk.rs new file mode 100644 index 0000000..2bd869a --- /dev/null +++ b/crates/dustfril-core/src/fs/walk.rs @@ -0,0 +1,13 @@ +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +/// Collect all directories in filesystem tree. +/// No logic, no filtering. +pub fn walk_dirs(root: &Path) -> Vec { + WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_dir()) + .map(|e| e.path().to_path_buf()) + .collect() +} diff --git a/crates/dustfril-core/src/lib.rs b/crates/dustfril-core/src/lib.rs index de31272..66b7658 100644 --- a/crates/dustfril-core/src/lib.rs +++ b/crates/dustfril-core/src/lib.rs @@ -4,4 +4,5 @@ pub mod models; mod analyzer; mod cleaner; -mod detector; +mod fs; +mod scanner; diff --git a/crates/dustfril-core/src/models/artifact_analysis.rs b/crates/dustfril-core/src/models/analysis.rs similarity index 72% rename from crates/dustfril-core/src/models/artifact_analysis.rs rename to crates/dustfril-core/src/models/analysis.rs index 7e7959e..72fd879 100644 --- a/crates/dustfril-core/src/models/artifact_analysis.rs +++ b/crates/dustfril-core/src/models/analysis.rs @@ -1,7 +1,8 @@ +use std::time::SystemTime; + use serde::{Deserialize, Serialize}; use crate::models::{Artifact, CleanupRecommendation}; -use std::time::SystemTime; /// Analyzed artifact information #[derive(Debug, Serialize, Deserialize)] @@ -11,6 +12,12 @@ pub struct ArtifactAnalysis { // permission denied, broken symlink, network filesystem failed to detect pub last_modified: Option, pub age_days: Option, - pub recommendation: CleanupRecommendation, } + +/// Total Analysis Results +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct AnalysisResult { + pub artifacts: Vec, + pub total_size_bytes: u64, +} diff --git a/crates/dustfril-core/src/models/analysis_result.rs b/crates/dustfril-core/src/models/analysis_result.rs deleted file mode 100644 index a884a35..0000000 --- a/crates/dustfril-core/src/models/analysis_result.rs +++ /dev/null @@ -1,10 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::ArtifactAnalysis; - -/// Total Analysis Results -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct AnalysisResult { - pub artifacts: Vec, - pub total_size_bytes: u64, -} diff --git a/crates/dustfril-core/src/models/artifact.rs b/crates/dustfril-core/src/models/artifact.rs deleted file mode 100644 index 409bc2a..0000000 --- a/crates/dustfril-core/src/models/artifact.rs +++ /dev/null @@ -1,19 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -use super::ArtifactType; - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Artifact { - pub path: PathBuf, - pub artifact_type: ArtifactType, -} - -impl Artifact { - pub fn new(path: PathBuf, artifact_type: ArtifactType) -> Self { - Self { - path, - artifact_type, - } - } -} diff --git a/crates/dustfril-core/src/models/artifact_type.rs b/crates/dustfril-core/src/models/artifact_type.rs deleted file mode 100644 index ffe780e..0000000 --- a/crates/dustfril-core/src/models/artifact_type.rs +++ /dev/null @@ -1,25 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ArtifactType { - Target, - CargoRegistry, - CargoGit, -} - -impl fmt::Display for ArtifactType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ArtifactType::Target => { - write!(f, "TARGET") - } - ArtifactType::CargoRegistry => { - write!(f, "CARGO REGISTRY") - } - ArtifactType::CargoGit => { - write!(f, "CARGO GIT") - } - } - } -} diff --git a/crates/dustfril-core/src/models/cleanup.rs b/crates/dustfril-core/src/models/cleanup.rs new file mode 100644 index 0000000..e525ad0 --- /dev/null +++ b/crates/dustfril-core/src/models/cleanup.rs @@ -0,0 +1,65 @@ +use core::fmt; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::models::{ArtifactAnalysis, Ecosystem}; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CleanupPlan { + pub candidates: Vec, +} + +impl CleanupPlan { + pub fn reclaimable_size_bytes(&self) -> u64 { + self.candidates + .iter() + .map(|candidate| candidate.size_bytes) + .sum() + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CleanupResult { + pub deleted_paths: Vec, + pub failed_paths: Vec, + pub freed_size_bytes: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CleanupRecommendation { + Keep, + NeedsReview, + SafeToClean, +} + +impl fmt::Display for CleanupRecommendation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Keep => write!(f, "Keep"), + + Self::NeedsReview => write!(f, "NeedsReview"), + + Self::SafeToClean => write!(f, "SafeToClean"), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CleanupCandidate { + pub path: PathBuf, + pub ecosystem: Ecosystem, + pub size_bytes: u64, + pub age_days: Option, +} + +impl From for CleanupCandidate { + fn from(analysis: ArtifactAnalysis) -> Self { + Self { + path: analysis.artifact.path, + ecosystem: analysis.artifact.ecosystem, + size_bytes: analysis.size_bytes, + age_days: analysis.age_days, + } + } +} diff --git a/crates/dustfril-core/src/models/cleanup_candidate.rs b/crates/dustfril-core/src/models/cleanup_candidate.rs deleted file mode 100644 index 0cfaa0d..0000000 --- a/crates/dustfril-core/src/models/cleanup_candidate.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -use crate::models::{ArtifactType, CleanupRecommendation}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct CleanupCandidate { - pub path: PathBuf, - - pub artifact_type: ArtifactType, - - pub size_bytes: u64, - - pub age_days: Option, - - pub recommendation: CleanupRecommendation, -} diff --git a/crates/dustfril-core/src/models/cleanup_plan.rs b/crates/dustfril-core/src/models/cleanup_plan.rs deleted file mode 100644 index 54f2a20..0000000 --- a/crates/dustfril-core/src/models/cleanup_plan.rs +++ /dev/null @@ -1,17 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::models::CleanupCandidate; - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct CleanupPlan { - pub candidates: Vec, -} - -impl CleanupPlan { - pub fn reclaimable_size_bytes(&self) -> u64 { - self.candidates - .iter() - .map(|candidate| candidate.size_bytes) - .sum() - } -} diff --git a/crates/dustfril-core/src/models/cleanup_recommendation.rs b/crates/dustfril-core/src/models/cleanup_recommendation.rs deleted file mode 100644 index f66c78c..0000000 --- a/crates/dustfril-core/src/models/cleanup_recommendation.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CleanupRecommendation { - Keep, - Review, - SafeToClean, -} - -impl fmt::Display for CleanupRecommendation { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - CleanupRecommendation::Keep => { - write!(f, "Keep") - } - - CleanupRecommendation::Review => { - write!(f, "Review") - } - - CleanupRecommendation::SafeToClean => { - write!(f, "Safe To Clean") - } - } - } -} diff --git a/crates/dustfril-core/src/models/cleanup_result.rs b/crates/dustfril-core/src/models/cleanup_result.rs deleted file mode 100644 index f27b15e..0000000 --- a/crates/dustfril-core/src/models/cleanup_result.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct CleanupResult { - pub deleted_paths: Vec, - - pub failed_paths: Vec, - - pub freed_size_bytes: u64, -} diff --git a/crates/dustfril-core/src/models/ecosystem.rs b/crates/dustfril-core/src/models/ecosystem.rs deleted file mode 100644 index a44798d..0000000 --- a/crates/dustfril-core/src/models/ecosystem.rs +++ /dev/null @@ -1,8 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum Ecosystem { - Rust, - Node, - Java, -} diff --git a/crates/dustfril-core/src/models/mod.rs b/crates/dustfril-core/src/models/mod.rs index 555e098..7308452 100644 --- a/crates/dustfril-core/src/models/mod.rs +++ b/crates/dustfril-core/src/models/mod.rs @@ -1,34 +1,8 @@ //! Shared models. -// scan -mod artifact; -mod artifact_type; -mod ecosystem; -mod scan_result; +mod analysis; +mod cleanup; +mod scan; -// analysis -mod analysis_result; -mod artifact_analysis; -// analysis - recommendation -mod cleanup_recommendation; - -// cleanup -mod cleanup_candidate; -mod cleanup_plan; -mod cleanup_result; - -// scan -pub use artifact::*; -pub use artifact_type::*; -pub use ecosystem::*; -pub use scan_result::*; - -// analysis -pub use analysis_result::*; -pub use artifact_analysis::*; -// analysis - recommendation -pub use cleanup_recommendation::*; - -// cleanup -pub use cleanup_candidate::*; -pub use cleanup_plan::*; -pub use cleanup_result::*; +pub use analysis::*; +pub use cleanup::*; +pub use scan::*; diff --git a/crates/dustfril-core/src/models/scan.rs b/crates/dustfril-core/src/models/scan.rs new file mode 100644 index 0000000..b75cfb8 --- /dev/null +++ b/crates/dustfril-core/src/models/scan.rs @@ -0,0 +1,40 @@ +use core::fmt; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Ecosystem { + Rust, + Node, + Java, +} + +impl fmt::Display for Ecosystem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Rust => write!(f, "Rust"), + + Self::Node => write!(f, "Node"), + + Self::Java => write!(f, "Java"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ScanResult { + pub artifacts: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Artifact { + pub path: PathBuf, + pub ecosystem: Ecosystem, +} + +impl Artifact { + pub fn new(path: PathBuf, ecosystem: Ecosystem) -> Self { + Self { path, ecosystem } + } +} diff --git a/crates/dustfril-core/src/models/scan_result.rs b/crates/dustfril-core/src/models/scan_result.rs deleted file mode 100644 index 3e3cd89..0000000 --- a/crates/dustfril-core/src/models/scan_result.rs +++ /dev/null @@ -1,8 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::models::Artifact; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ScanResult { - pub artifacts: Vec, -} diff --git a/crates/dustfril-core/src/scanner/detector.rs b/crates/dustfril-core/src/scanner/detector.rs new file mode 100644 index 0000000..aa8ac46 --- /dev/null +++ b/crates/dustfril-core/src/scanner/detector.rs @@ -0,0 +1,59 @@ +use std::path::Path; + +use crate::models::Ecosystem; + +pub static DETECTORS: &[&dyn Detector] = &[&RustDetector, &NodeDetector, &JavaDetector]; + +pub trait Detector: Sync { + fn detect(&self, root: &Path) -> bool; + + fn ecosystem(&self) -> Ecosystem; +} + +pub fn detectors(ecosystems: &[Ecosystem]) -> Vec<&'static dyn Detector> { + if ecosystems.is_empty() { + return DETECTORS.to_vec(); + } + + DETECTORS + .iter() + .copied() + .filter(|detector| ecosystems.contains(&detector.ecosystem())) + .collect() +} + +pub struct RustDetector; + +impl Detector for RustDetector { + fn detect(&self, root: &Path) -> bool { + root.join("Cargo.toml").is_file() + } + + fn ecosystem(&self) -> Ecosystem { + Ecosystem::Rust + } +} +pub struct NodeDetector; + +impl Detector for NodeDetector { + fn detect(&self, root: &Path) -> bool { + root.join("package.json").is_file() + } + + fn ecosystem(&self) -> Ecosystem { + Ecosystem::Node + } +} +pub struct JavaDetector; + +impl Detector for JavaDetector { + fn detect(&self, root: &Path) -> bool { + root.join("pom.xml").is_file() + || root.join("build.gradle").is_file() + || root.join("build.gradle.kts").is_file() + } + + fn ecosystem(&self) -> Ecosystem { + Ecosystem::Java + } +} diff --git a/crates/dustfril-core/src/scanner/mod.rs b/crates/dustfril-core/src/scanner/mod.rs new file mode 100644 index 0000000..ad88c49 --- /dev/null +++ b/crates/dustfril-core/src/scanner/mod.rs @@ -0,0 +1,7 @@ +mod detector; +mod scan; + +#[cfg(test)] +mod tests; + +pub use scan::scan; diff --git a/crates/dustfril-core/src/scanner/scan.rs b/crates/dustfril-core/src/scanner/scan.rs new file mode 100644 index 0000000..2b9478b --- /dev/null +++ b/crates/dustfril-core/src/scanner/scan.rs @@ -0,0 +1,27 @@ +use std::path::Path; + +use crate::{ + error::DustResult, + fs::walk_dirs, + models::{Artifact, Ecosystem, ScanResult}, + scanner::detector::{self}, +}; + +pub fn scan(root: &Path, ecosystems: &[Ecosystem]) -> DustResult { + let detectors = detector::detectors(ecosystems); + + let mut result = ScanResult::default(); + + for dir in walk_dirs(root) { + for detector in &detectors { + if detector.detect(&dir) { + result + .artifacts + .push(Artifact::new(dir.clone(), detector.ecosystem())); + break; + } + } + } + + Ok(result) +} diff --git a/crates/dustfril-core/src/scanner/tests.rs b/crates/dustfril-core/src/scanner/tests.rs new file mode 100644 index 0000000..0e60064 --- /dev/null +++ b/crates/dustfril-core/src/scanner/tests.rs @@ -0,0 +1,133 @@ +use tempfile::TempDir; + +use crate::{models::Ecosystem, scanner::scan}; + +#[test] +fn scan_returns_empty_when_no_projects() { + let temp_dir = TempDir::new().unwrap(); + + let result = scan(temp_dir.path(), &[]).unwrap(); + + assert!(result.artifacts.is_empty()); +} + +#[test] +fn scan_detects_rust_project() { + let temp_dir = TempDir::new().unwrap(); + + std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]").unwrap(); + + let result = scan(temp_dir.path(), &[]).unwrap(); + + assert_eq!(result.artifacts.len(), 1); + + let artifact = &result.artifacts[0]; + + assert_eq!(artifact.ecosystem, Ecosystem::Rust); + assert_eq!(artifact.path, temp_dir.path()); +} + +#[test] +fn scan_detects_node_project() { + let temp_dir = TempDir::new().unwrap(); + + std::fs::write(temp_dir.path().join("package.json"), "{}").unwrap(); + + let result = scan(temp_dir.path(), &[]).unwrap(); + + assert_eq!(result.artifacts.len(), 1); + assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Node); +} + +#[test] +fn scan_detects_java_project() { + let temp_dir = TempDir::new().unwrap(); + + std::fs::write(temp_dir.path().join("pom.xml"), "").unwrap(); + + let result = scan(temp_dir.path(), &[]).unwrap(); + + assert_eq!(result.artifacts.len(), 1); + assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Java); +} + +#[test] +fn scan_detects_multiple_projects() { + let temp_dir = TempDir::new().unwrap(); + + let rust = temp_dir.path().join("rust"); + let node = temp_dir.path().join("node"); + + 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 result = scan(temp_dir.path(), &[]).unwrap(); + + assert_eq!(result.artifacts.len(), 2); + + assert!( + result + .artifacts + .iter() + .any(|a| a.ecosystem == Ecosystem::Rust) + ); + + assert!( + result + .artifacts + .iter() + .any(|a| a.ecosystem == Ecosystem::Node) + ); +} + +#[test] +fn scan_filters_rust_only() { + let temp_dir = TempDir::new().unwrap(); + + let rust = temp_dir.path().join("rust"); + let node = temp_dir.path().join("node"); + + 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 result = scan(temp_dir.path(), &[Ecosystem::Rust]).unwrap(); + + assert_eq!(result.artifacts.len(), 1); + assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Rust); +} + +#[test] +fn scan_filters_node_only() { + let temp_dir = TempDir::new().unwrap(); + + let rust = temp_dir.path().join("rust"); + let node = temp_dir.path().join("node"); + + 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 result = scan(temp_dir.path(), &[Ecosystem::Node]).unwrap(); + + assert_eq!(result.artifacts.len(), 1); + assert_eq!(result.artifacts[0].ecosystem, Ecosystem::Node); +} + +#[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(); + + let result = scan(temp_dir.path(), &[Ecosystem::Java]).unwrap(); + + assert!(result.artifacts.is_empty()); +}