clean_dev_dirs/cleaner.rs
1//! Build directory cleanup functionality.
2//!
3//! This module provides the core cleanup logic for removing build directories
4//! from detected development projects. It handles parallel processing, progress
5//! reporting, error handling, and provides detailed statistics about the
6//! cleanup operation.
7
8use anyhow::Result;
9use colored::Colorize;
10use humansize::{DECIMAL, format_size};
11use indicatif::{ProgressBar, ProgressStyle};
12use rayon::prelude::*;
13use std::fs;
14use std::sync::{Arc, Mutex};
15
16use crate::executables;
17use crate::project::{Project, Projects};
18
19/// Strategy for removing build directories.
20#[derive(Clone, Copy, Debug)]
21pub enum RemovalStrategy {
22 /// Permanently delete the directory (default, uses `fs::remove_dir_all`).
23 Permanent,
24
25 /// Move the directory to the system trash (recoverable deletion).
26 Trash,
27}
28
29impl RemovalStrategy {
30 /// Create a removal strategy from the `use_trash` boolean flag.
31 #[must_use]
32 pub const fn from_use_trash(use_trash: bool) -> Self {
33 if use_trash {
34 Self::Trash
35 } else {
36 Self::Permanent
37 }
38 }
39}
40
41/// Structured result returned after a cleanup operation.
42///
43/// Contains all the data needed to render either human-readable or JSON output.
44#[derive(Debug)]
45pub struct CleanResult {
46 /// Number of projects successfully cleaned.
47 pub success_count: usize,
48
49 /// Total bytes actually freed during cleanup.
50 pub total_freed: u64,
51
52 /// Estimated total size before cleanup (from cached scan data).
53 pub estimated_size: u64,
54
55 /// Error messages for projects that failed to clean.
56 pub errors: Vec<String>,
57}
58
59/// Handles the cleanup of build directories from development projects.
60///
61/// The `Cleaner` struct provides methods for removing build directories
62/// (such as `target/` for Rust projects and `node_modules/` for Node.js projects)
63/// with parallel processing, progress reporting, and comprehensive error handling.
64#[derive(Debug)]
65pub struct Cleaner;
66
67impl Cleaner {
68 /// Create a new cleaner instance.
69 ///
70 /// # Returns
71 ///
72 /// A new `Cleaner` instance ready to perform cleanup operations.
73 ///
74 /// # Examples
75 ///
76 /// ```
77 /// # use crate::Cleaner;
78 /// let cleaner = Cleaner::new();
79 /// ```
80 #[must_use]
81 pub const fn new() -> Self {
82 Self
83 }
84
85 /// Clean build directories from a collection of projects.
86 ///
87 /// This method performs the main cleanup operation by:
88 /// 1. Setting up a progress bar for user feedback (unless `quiet`)
89 /// 2. Processing projects in parallel for efficiency
90 /// 3. Collecting and reporting any errors that occur
91 /// 4. Returning a [`CleanResult`] with detailed statistics
92 ///
93 /// # Arguments
94 ///
95 /// * `projects` - A collection of projects to clean
96 /// * `keep_executables` - Whether to preserve compiled executables before cleaning
97 /// * `quiet` - When `true`, suppresses all human-readable output (progress bars, messages).
98 /// Used by the `--json` flag so that only the final JSON is printed.
99 /// * `removal_strategy` - Whether to permanently delete or move to system trash
100 ///
101 /// # Panics
102 ///
103 /// This method may panic if the progress bar template string is invalid,
104 /// though this should not occur under normal circumstances as the template
105 /// is hardcoded and valid.
106 ///
107 /// # Returns
108 ///
109 /// A [`CleanResult`] containing success/failure counts, total freed bytes,
110 /// and any error messages.
111 ///
112 /// # Performance
113 ///
114 /// This method uses parallel processing to clean multiple projects
115 /// simultaneously, which can significantly reduce cleanup time for
116 /// large numbers of projects.
117 ///
118 /// # Error Handling
119 ///
120 /// Individual project cleanup failures do not stop the overall process.
121 /// All errors are collected and reported in the returned [`CleanResult`],
122 /// allowing the cleanup to proceed for projects that can be successfully processed.
123 #[must_use]
124 pub fn clean_projects(
125 projects: Projects,
126 keep_executables: bool,
127 quiet: bool,
128 removal_strategy: RemovalStrategy,
129 ) -> CleanResult {
130 let total_projects = projects.len();
131 let total_size: u64 = projects.get_total_size();
132
133 let progress = if quiet {
134 ProgressBar::hidden()
135 } else {
136 let action = match removal_strategy {
137 RemovalStrategy::Permanent => "Starting cleanup...",
138 RemovalStrategy::Trash => "Moving to trash...",
139 };
140 println!("\n{}", action.cyan());
141
142 let pb = ProgressBar::new(total_projects as u64);
143 if let Ok(style) = ProgressStyle::default_bar()
144 .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")
145 {
146 pb.set_style(style.progress_chars("█▉▊▋▌▍▎▏ "));
147 }
148 pb
149 };
150
151 let cleaned_size = Arc::new(Mutex::new(0u64));
152 let errors = Arc::new(Mutex::new(Vec::new()));
153
154 // Clean projects in parallel
155 projects.into_par_iter().for_each(|project| {
156 let result = clean_single_project(&project, keep_executables, removal_strategy);
157
158 let action = match removal_strategy {
159 RemovalStrategy::Permanent => "Cleaned",
160 RemovalStrategy::Trash => "Trashed",
161 };
162
163 match result {
164 Ok(freed_size) => {
165 if let Ok(mut size) = cleaned_size.lock() {
166 *size += freed_size;
167 }
168
169 progress.set_message(format!(
170 "{action} {} ({})",
171 project
172 .root_path
173 .file_name()
174 .and_then(|n| n.to_str())
175 .unwrap_or("unknown"),
176 format_size(freed_size, DECIMAL)
177 ));
178 }
179 Err(e) => {
180 if let Ok(mut errs) = errors.lock() {
181 errs.push(format!(
182 "Failed to clean {}: {e}",
183 project.root_path.display()
184 ));
185 }
186 }
187 }
188
189 progress.inc(1);
190 });
191
192 let finish_msg = match removal_strategy {
193 RemovalStrategy::Permanent => "[OK] Cleanup complete",
194 RemovalStrategy::Trash => "[OK] Moved to trash",
195 };
196 progress.finish_with_message(finish_msg);
197
198 let final_cleaned_size = cleaned_size.lock().map_or(0, |s| *s);
199 let errors = Arc::try_unwrap(errors)
200 .unwrap_or_else(|arc| {
201 arc.lock()
202 .map_or_else(|_| Mutex::new(Vec::new()), |g| Mutex::new(g.clone()))
203 })
204 .into_inner()
205 .unwrap_or_default();
206
207 let success_count = total_projects - errors.len();
208
209 CleanResult {
210 success_count,
211 total_freed: final_cleaned_size,
212 estimated_size: total_size,
213 errors,
214 }
215 }
216
217 /// Print a human-readable cleanup summary to stdout.
218 ///
219 /// This is called from `main` when `--json` is **not** active.
220 pub fn print_summary(result: &CleanResult) {
221 if !result.errors.is_empty() {
222 println!("\n{}", "[!] Some errors occurred during cleanup:".yellow());
223 for error in &result.errors {
224 eprintln!(" {}", error.red());
225 }
226 }
227
228 println!("\n{}", "Cleanup Summary:".bold());
229 println!(
230 " [OK] Successfully cleaned: {} projects",
231 result.success_count.to_string().green()
232 );
233
234 if !result.errors.is_empty() {
235 println!(
236 " [FAIL] Failed to clean: {} projects",
237 result.errors.len().to_string().red()
238 );
239 }
240
241 println!(
242 " Total space freed: {}",
243 format_size(result.total_freed, DECIMAL)
244 .bright_green()
245 .bold()
246 );
247
248 if result.total_freed != result.estimated_size {
249 let difference = result.estimated_size.abs_diff(result.total_freed);
250 println!(
251 " Difference from estimate: {}",
252 format_size(difference, DECIMAL).yellow()
253 );
254 }
255 }
256}
257
258/// Clean the build directory for a single project.
259///
260/// This function handles the cleanup of an individual project's build directory.
261/// It calculates the actual size before deletion and then removes the entire
262/// directory tree, either permanently or by moving it to the system trash.
263///
264/// # Arguments
265///
266/// * `project` - The project whose build directory should be cleaned
267/// * `keep_executables` - Whether to preserve compiled executables before cleaning
268/// * `removal_strategy` - Whether to permanently delete or move to system trash
269///
270/// # Returns
271///
272/// - `Ok(u64)` - The number of bytes freed by the cleanup
273/// - `Err(anyhow::Error)` - If the cleanup operation failed
274///
275/// # Behavior
276///
277/// 1. Checks if the build directory exists (returns 0 if not)
278/// 2. Optionally preserves compiled executables
279/// 3. Calculates the actual size of the directory before deletion
280/// 4. Removes the directory (permanently or via trash, based on `removal_strategy`)
281/// 5. Returns the amount of space freed
282///
283/// # Error Conditions
284///
285/// This function can fail if:
286/// - The build directory cannot be removed due to permission issues
287/// - Files within the directory are locked or in use by other processes
288/// - The file system encounters I/O errors during deletion
289/// - The system trash is not available (when using [`RemovalStrategy::Trash`])
290fn clean_single_project(
291 project: &Project,
292 keep_executables: bool,
293 removal_strategy: RemovalStrategy,
294) -> Result<u64> {
295 // Preserve executables before deletion if requested
296 if keep_executables {
297 match executables::preserve_executables(project) {
298 Ok(preserved) => {
299 if !preserved.is_empty() {
300 eprintln!(
301 " Preserved {} executable(s) from {}",
302 preserved.len(),
303 project
304 .root_path
305 .file_name()
306 .and_then(|n| n.to_str())
307 .unwrap_or("unknown")
308 );
309 }
310 }
311 Err(e) => {
312 eprintln!(
313 " Warning: failed to preserve executables for {}: {e}",
314 project.root_path.display()
315 );
316 }
317 }
318 }
319
320 let mut total_freed = 0u64;
321
322 for artifact in &project.build_arts {
323 let build_dir = &artifact.path;
324
325 if !build_dir.exists() {
326 continue;
327 }
328
329 // Get the actual size before deletion (might be different from the cached size)
330 total_freed += crate::utils::calculate_dir_size(build_dir);
331
332 // Remove the build directory using the chosen strategy
333 match removal_strategy {
334 RemovalStrategy::Permanent => fs::remove_dir_all(build_dir)?,
335 RemovalStrategy::Trash => {
336 trash::delete(build_dir)
337 .map_err(|e| anyhow::anyhow!("failed to move to trash: {e}"))?;
338 }
339 }
340 }
341
342 Ok(total_freed)
343}
344
345impl Default for Cleaner {
346 /// Create a default cleaner instance.
347 ///
348 /// This implementation allows `Cleaner::default()` to be used as an
349 /// alternative to `Cleaner::new()` for creating cleaner instances.
350 ///
351 /// # Returns
352 ///
353 /// A new `Cleaner` instance with default settings.
354 fn default() -> Self {
355 Self::new()
356 }
357}