diff --git a/apps/dustfril-cli/src/cli.rs b/apps/dustfril-cli/src/cli.rs index be8c1b0..bc6f522 100644 --- a/apps/dustfril-cli/src/cli.rs +++ b/apps/dustfril-cli/src/cli.rs @@ -1,7 +1,7 @@ -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; /// DustFril CLI -#[derive(Parser, Debug)] +#[derive(Parser)] #[command(name = "dfr", version, about = "Rust artifact analyzer and cleaner")] pub struct Cli { #[command(subcommand)] @@ -9,7 +9,7 @@ pub struct Cli { } /// Available commands -#[derive(Subcommand, Debug)] +#[derive(Subcommand)] pub enum Commands { /// Scan Rust artifacts Scan, @@ -18,5 +18,12 @@ pub enum Commands { Analyze, /// Clean artifacts - Clean, + Clean(CleanArgs), +} + +#[derive(Args)] +pub struct CleanArgs { + // Preview cleanup operations without deleting files. + #[arg(long)] + pub dry_run: bool, } diff --git a/apps/dustfril-cli/src/commands/clean.rs b/apps/dustfril-cli/src/commands/clean.rs index d85d8e6..15a0b69 100644 --- a/apps/dustfril-cli/src/commands/clean.rs +++ b/apps/dustfril-cli/src/commands/clean.rs @@ -1,3 +1,105 @@ +use std::path::Path; + +use dustfril_core::{ + analyzer, cleaner, detector, + models::{CleanupPlan, CleanupResult}, +}; + +// dry-run +pub fn dry_run() { + let plan = build_cleanup_plan(); + + if plan.candidates.is_empty() { + println!("No cleanup candidates found."); + + return; + } + + print_cleanup_plan(&plan); + + println!("No files were deleted."); +} + +use std::io::{self, Write}; + +fn build_cleanup_plan() -> CleanupPlan { + let scan_result = detector::scan(Path::new(".")); + + let analysis = analyzer::analyze(scan_result); + + cleaner::create_cleanup_plan(analysis) +} + +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(); + + io::stdin() + .read_line(&mut input) + .expect("Failed to read input"); + + matches!(input.trim(), "y" | "Y") +} + +fn print_cleanup_plan(plan: &CleanupPlan) { + println!("Cleanup Preview\n"); + + for candidate in &plan.candidates { + println!("[{}]", candidate.artifact_type); + + println!(" Path: {}", candidate.path.display()); + + println!(" Size: {}\n", analyzer::format_size(candidate.size_bytes)); + } + + println!("Total Reclaimable Space\n"); + + println!( + " {}\n", + analyzer::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: {}", analyzer::format_size(result.freed_size_bytes,)); + + if !result.deleted_paths.is_empty() { + println!("Deleted\n"); + + for path in &result.deleted_paths { + println!(" {}", path.display()); + } + + println!(); + } +} + pub fn execute() { - println!("Clean command is not implemented yet."); + let plan = build_cleanup_plan(); + + if plan.candidates.is_empty() { + println!("No cleanup candidates found."); + return; + } + + print_cleanup_plan(&plan); + + if !confirm_cleanup() { + println!("Cleanup cancelled."); + return; + } + + let result = cleaner::execute_cleanup(&plan); + + print_cleanup_result(&result); } diff --git a/apps/dustfril-cli/src/main.rs b/apps/dustfril-cli/src/main.rs index 2f739b7..39224bb 100644 --- a/apps/dustfril-cli/src/main.rs +++ b/apps/dustfril-cli/src/main.rs @@ -17,8 +17,12 @@ fn main() { commands::analyze::execute(); } - Commands::Clean => { - commands::clean::execute(); + Commands::Clean(args) => { + if args.dry_run { + commands::clean::dry_run(); + } else { + commands::clean::execute(); + } } } } diff --git a/crates/dustfril-core/src/cleaner/executor.rs b/crates/dustfril-core/src/cleaner/executor.rs new file mode 100644 index 0000000..9311fea --- /dev/null +++ b/crates/dustfril-core/src/cleaner/executor.rs @@ -0,0 +1,27 @@ +use std::fs; + +use crate::models::{ArtifactType, CleanupPlan, CleanupResult}; + +pub fn execute_cleanup(plan: &CleanupPlan) -> CleanupResult { + let mut result = CleanupResult { + deleted_paths: vec![], + failed_paths: vec![], + 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()); + + result.freed_size_bytes += candidate.size_bytes; + } else { + result.failed_paths.push(candidate.path.clone()); + } + } + } + } + + result +} diff --git a/crates/dustfril-core/src/cleaner/mod.rs b/crates/dustfril-core/src/cleaner/mod.rs index 4606860..efa101c 100644 --- a/crates/dustfril-core/src/cleaner/mod.rs +++ b/crates/dustfril-core/src/cleaner/mod.rs @@ -1 +1,9 @@ //! Cleaner module. +mod executor; +mod plan; + +#[cfg(test)] +mod tests; + +pub use executor::execute_cleanup; +pub use plan::create_cleanup_plan; diff --git a/crates/dustfril-core/src/cleaner/plan.rs b/crates/dustfril-core/src/cleaner/plan.rs new file mode 100644 index 0000000..236c914 --- /dev/null +++ b/crates/dustfril-core/src/cleaner/plan.rs @@ -0,0 +1,24 @@ +use crate::models::{AnalysisResult, CleanupCandidate, CleanupPlan, CleanupRecommendation}; + +pub fn create_cleanup_plan(analysis: AnalysisResult) -> CleanupPlan { + let mut plan = CleanupPlan::default(); + + for artifact in analysis.artifacts { + if artifact.recommendation == CleanupRecommendation::SafeToClean { + // Flatten the analysis into a cleanup candidate + plan.candidates.push(CleanupCandidate { + path: artifact.artifact.path.clone(), + + artifact_type: artifact.artifact.artifact_type.clone(), + + size_bytes: artifact.size_bytes, + + age_days: artifact.age_days, + + recommendation: artifact.recommendation, + }); + } + } + + plan +} diff --git a/crates/dustfril-core/src/cleaner/tests.rs b/crates/dustfril-core/src/cleaner/tests.rs new file mode 100644 index 0000000..ef95fb5 --- /dev/null +++ b/crates/dustfril-core/src/cleaner/tests.rs @@ -0,0 +1,143 @@ +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +use crate::{ + cleaner::{create_cleanup_plan, execute_cleanup}, + models::*, +}; + +#[test] +fn create_empty_cleanup_plan() { + let plan = create_cleanup_plan(AnalysisResult::default()); + + assert!(plan.candidates.is_empty()); + + assert_eq!(plan.reclaimable_size_bytes(), 0); +} + +#[test] +fn safe_to_clean_becomes_candidate() { + let artifact = ArtifactAnalysis { + artifact: ArtifactLocation { + path: PathBuf::from("target"), + + artifact_type: ArtifactType::Target, + }, + + size_bytes: 100, + + last_modified: None, + + age_days: Some(200), + + recommendation: CleanupRecommendation::SafeToClean, + }; + + let analysis = AnalysisResult { + artifacts: vec![artifact], + + total_size_bytes: 100, + }; + + let plan = create_cleanup_plan(analysis); + + assert_eq!(plan.candidates.len(), 1,); + + assert_eq!(plan.reclaimable_size_bytes(), 100,); +} + +#[test] +fn keep_is_not_candidate() { + let artifact = ArtifactAnalysis { + artifact: ArtifactLocation { + path: PathBuf::from("target"), + + artifact_type: ArtifactType::Target, + }, + + size_bytes: 100, + + last_modified: None, + + age_days: Some(5), + + recommendation: CleanupRecommendation::Keep, + }; + + let analysis = AnalysisResult { + artifacts: vec![artifact], + + total_size_bytes: 100, + }; + + let plan = create_cleanup_plan(analysis); + + assert!(plan.candidates.is_empty()); +} + +#[test] +fn execute_cleanup_removes_target_directory() { + let temp_dir = TempDir::new().unwrap(); + + let target_dir = temp_dir.path().join("target"); + + fs::create_dir_all(&target_dir).unwrap(); + + fs::write(target_dir.join("test.bin"), b"hello").unwrap(); + + assert!(target_dir.exists()); + + let candidate = CleanupCandidate { + path: target_dir.clone(), + + artifact_type: ArtifactType::Target, + + size_bytes: 5, + + age_days: Some(100), + + recommendation: CleanupRecommendation::SafeToClean, + }; + + let plan = CleanupPlan { + candidates: vec![candidate], + }; + + let result = execute_cleanup(&plan); + + 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 cleanup_reports_failed_path() { + let temp_dir = TempDir::new().unwrap(); + let missing = temp_dir.path().join("missing"); + + let candidate = CleanupCandidate { + path: missing, + artifact_type: ArtifactType::Target, + size_bytes: 100, + age_days: None, + recommendation: CleanupRecommendation::SafeToClean, + }; + + let plan = CleanupPlan { + candidates: vec![candidate], + }; + + let result = execute_cleanup(&plan); + + 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/models/cleanup_candidate.rs b/crates/dustfril-core/src/models/cleanup_candidate.rs new file mode 100644 index 0000000..6b4e6ce --- /dev/null +++ b/crates/dustfril-core/src/models/cleanup_candidate.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +use crate::models::{ArtifactType, CleanupRecommendation}; + +#[derive(Debug)] +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 new file mode 100644 index 0000000..227f808 --- /dev/null +++ b/crates/dustfril-core/src/models/cleanup_plan.rs @@ -0,0 +1,15 @@ +use crate::models::CleanupCandidate; + +#[derive(Debug, Default)] +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_result.rs b/crates/dustfril-core/src/models/cleanup_result.rs new file mode 100644 index 0000000..3539be3 --- /dev/null +++ b/crates/dustfril-core/src/models/cleanup_result.rs @@ -0,0 +1,10 @@ +use std::path::PathBuf; + +#[derive(Debug)] +pub struct CleanupResult { + pub deleted_paths: Vec, + + pub failed_paths: Vec, + + pub freed_size_bytes: u64, +} diff --git a/crates/dustfril-core/src/models/mod.rs b/crates/dustfril-core/src/models/mod.rs index b8597fc..9ffad72 100644 --- a/crates/dustfril-core/src/models/mod.rs +++ b/crates/dustfril-core/src/models/mod.rs @@ -1,18 +1,32 @@ //! Shared models. +// scan mod artifact_location; mod artifact_type; mod scan_result; +// 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_location::*; pub use artifact_type::*; 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::*;