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::config::ExecutionOptions;
17use crate::project::{Project, ProjectType, Projects};
18
19/// Handles the cleanup of build directories from development projects.
20///
21/// The `Cleaner` struct provides methods for removing build directories
22/// (such as `target/` for Rust projects and `node_modules/` for Node.js projects)
23/// with parallel processing, progress reporting, and comprehensive error handling.
24pub struct Cleaner;
25
26impl Cleaner {
27 /// Create a new cleaner instance.
28 ///
29 /// # Returns
30 ///
31 /// A new `Cleaner` instance ready to perform cleanup operations.
32 ///
33 /// # Examples
34 ///
35 /// ```
36 /// # use crate::Cleaner;
37 /// let cleaner = Cleaner::new();
38 /// ```
39 #[must_use]
40 pub fn new() -> Self {
41 Self
42 }
43
44 /// Clean build directories from a collection of projects.
45 ///
46 /// This method performs the main cleanup operation by:
47 /// 1. Setting up a progress bar for user feedback
48 /// 2. Processing projects in parallel for efficiency
49 /// 3. Collecting and reporting any errors that occur
50 /// 4. Providing detailed statistics about the cleanup results
51 ///
52 /// # Arguments
53 ///
54 /// * `projects` - A collection of projects to clean
55 /// * `execution_options` - Configuration options for the cleanup operation
56 ///
57 /// # Panics
58 ///
59 /// This method may panic if the progress bar template string is invalid,
60 /// though this should not occur under normal circumstances as the template
61 /// is hardcoded and valid.
62 ///
63 /// # Output
64 ///
65 /// This method prints progress information and final statistics to stdout,
66 /// including
67 /// - Real-time progress during cleanup
68 /// - Number of successfully cleaned projects
69 /// - Number of failed projects (if any)
70 /// - Total disk space freed
71 /// - Difference between estimated and actual space freed
72 ///
73 /// # Examples
74 ///
75 /// ```
76 /// # use crate::{Cleaner, Projects, ExecutionOptions};
77 /// let projects = Projects::from(vec![/* project instances */]);
78 /// let options = ExecutionOptions {
79 /// dry_run: false,
80 /// interactive: false,
81 /// keep_executables: true,
82 /// };
83 /// Cleaner::clean_projects(projects, &options);
84 /// ```
85 ///
86 /// # Performance
87 ///
88 /// This method uses parallel processing to clean multiple projects
89 /// simultaneously, which can significantly reduce cleanup time for
90 /// large numbers of projects.
91 ///
92 /// # Error Handling
93 ///
94 /// Individual project cleanup failures do not stop the overall process.
95 /// All errors are collected and reported at the end, allowing the
96 /// cleanup to proceed for projects that can be successfully processed.
97 pub fn clean_projects(projects: Projects, execution_options: &ExecutionOptions) {
98 let total_projects = projects.len();
99 let total_size: u64 = projects.get_total_size();
100
101 println!("\n{}", "๐งน Starting cleanup...".cyan());
102
103 // Create a progress bar
104 let progress = ProgressBar::new(total_projects as u64);
105 progress.set_style(
106 ProgressStyle::default_bar()
107 .template("[{elapsed_precise}] {bar:40.cyan/blue} {pos}/{len} {msg}")
108 .unwrap()
109 .progress_chars("โโโโโโโโ "),
110 );
111
112 let cleaned_size = Arc::new(Mutex::new(0u64));
113 let errors = Arc::new(Mutex::new(Vec::new()));
114
115 // Clean projects in parallel
116 projects.into_par_iter().for_each(|project| {
117 let result = clean_single_project(&project, execution_options.keep_executables);
118
119 match result {
120 Ok(freed_size) => {
121 let mut total_cleaned = cleaned_size.lock().unwrap();
122 *total_cleaned += freed_size;
123
124 progress.set_message(format!(
125 "Cleaned {} ({})",
126 project
127 .root_path
128 .file_name()
129 .and_then(|n| n.to_str())
130 .unwrap_or("unknown"),
131 format_size(freed_size, DECIMAL)
132 ));
133 }
134 Err(e) => {
135 let mut errors = errors.lock().unwrap();
136 errors.push(format!(
137 "Failed to clean {}: {e}",
138 project.build_arts.path.display()
139 ));
140 }
141 }
142
143 progress.inc(1);
144 });
145
146 progress.finish_with_message("โ
Cleanup complete");
147
148 // Report results
149 let final_cleaned_size = *cleaned_size.lock().unwrap();
150 let errors = errors.lock().unwrap();
151
152 if !errors.is_empty() {
153 println!("\n{}", "โ ๏ธ Some errors occurred during cleanup:".yellow());
154 for error in errors.iter() {
155 eprintln!(" {}", error.red());
156 }
157 }
158
159 let success_count = total_projects - errors.len();
160 println!("\n{}", "๐ Cleanup Summary:".bold());
161 println!(
162 " โ
Successfully cleaned: {} projects",
163 success_count.to_string().green()
164 );
165
166 if !errors.is_empty() {
167 println!(
168 " โ Failed to clean: {} projects",
169 errors.len().to_string().red()
170 );
171 }
172
173 println!(
174 " ๐พ Total space freed: {}",
175 format_size(final_cleaned_size, DECIMAL)
176 .bright_green()
177 .bold()
178 );
179
180 if final_cleaned_size != total_size {
181 let difference = total_size.abs_diff(final_cleaned_size);
182 println!(
183 " ๐ Difference from estimate: {}",
184 format_size(difference, DECIMAL).yellow()
185 );
186 }
187 }
188}
189
190/// Clean a build directory while preserving executable binaries.
191///
192/// This function implements selective deletion for Rust and Go projects,
193/// preserving final executable binaries while removing intermediate build artifacts.
194///
195/// # Arguments
196///
197/// * `project` - The project to clean
198///
199/// # Returns
200///
201/// - `Ok(())` - If the cleanup succeeded
202/// - `Err(anyhow::Error)` - If the cleanup operation failed
203///
204/// # Behavior by Project Type
205///
206/// **Rust Projects:**
207/// - Preserves executables in `target/debug/` and `target/release/` directories
208/// - Removes all other files and directories within `target/`
209///
210/// **Go Projects:**
211/// - Preserves executables in `bin/` directory if it exists within the build dir
212/// - Removes all other files and directories
213///
214/// **Other Project Types:**
215/// - Falls back to complete removal (no executables to preserve)
216fn clean_with_executable_preservation(project: &Project) -> Result<()> {
217 match project.kind {
218 ProjectType::Rust => clean_rust_with_executables(project),
219 ProjectType::Go => clean_go_with_executables(project),
220 _ => {
221 // For Node.js and Python, there are no compiled executables to preserve
222 fs::remove_dir_all(&project.build_arts.path)?;
223 Ok(())
224 }
225 }
226}
227
228/// Clean a Rust project's target directory while preserving executables.
229///
230/// This function identifies executable files in the `target/debug/` and `target/release/`
231/// directories and preserves them while removing all other build artifacts.
232///
233/// # Arguments
234///
235/// * `project` - The Rust project to clean
236///
237/// # Returns
238///
239/// - `Ok(())` - If the cleanup succeeded
240/// - `Err(anyhow::Error)` - If the cleanup operation failed
241///
242/// # Implementation
243///
244/// 1. Scans `target/debug/` and `target/release/` for executable files
245/// 2. Backs up found executables to a temporary location
246/// 3. Removes the entire `target/` directory
247/// 4. Recreates `target/debug/` and `target/release/` directories
248/// 5. Restores the executables to their original locations
249fn clean_rust_with_executables(project: &Project) -> Result<()> {
250 let target_dir = &project.build_arts.path;
251 let debug_dir = target_dir.join("debug");
252 let release_dir = target_dir.join("release");
253
254 // Find executables in debug and release directories
255 let mut executables = Vec::new();
256
257 for dir in [&debug_dir, &release_dir] {
258 if dir.exists()
259 && let Ok(entries) = fs::read_dir(dir)
260 {
261 for entry in entries.flatten() {
262 let path = entry.path();
263 if path.is_file() && is_executable(&path) {
264 executables.push(path);
265 }
266 }
267 }
268 }
269
270 // If no executables found, just remove everything
271 if executables.is_empty() {
272 fs::remove_dir_all(target_dir)?;
273 return Ok(());
274 }
275
276 // Create temporary directory for backup
277 let temp_dir =
278 std::env::temp_dir().join(format!("clean-dev-dirs-backup-{}", std::process::id()));
279 fs::create_dir_all(&temp_dir)?;
280
281 // Backup executables
282 let mut backed_up = Vec::new();
283 for exe in &executables {
284 if let Some(file_name) = exe.file_name() {
285 let backup_path = temp_dir.join(file_name);
286 if fs::copy(exe, &backup_path).is_ok() {
287 backed_up.push((backup_path, exe.clone()));
288 }
289 }
290 }
291
292 // Remove the target directory
293 fs::remove_dir_all(target_dir)?;
294
295 // Recreate debug and release directories and restore executables
296 for (backup_path, original_path) in backed_up {
297 if let Some(parent) = original_path.parent() {
298 fs::create_dir_all(parent)?;
299 let _ = fs::copy(&backup_path, &original_path);
300 }
301 }
302
303 // Clean up temporary directory
304 let _ = fs::remove_dir_all(&temp_dir);
305
306 Ok(())
307}
308
309/// Clean a Go project's vendor directory while preserving executables.
310///
311/// This function preserves executable files in the `bin/` directory within
312/// the vendor directory, if it exists.
313///
314/// # Arguments
315///
316/// * `project` - The Go project to clean
317///
318/// # Returns
319///
320/// - `Ok(())` - If the cleanup succeeded
321/// - `Err(anyhow::Error)` - If the cleanup operation failed
322fn clean_go_with_executables(project: &Project) -> Result<()> {
323 let vendor_dir = &project.build_arts.path;
324 let bin_dir = vendor_dir.join("bin");
325
326 // Check if there's a bin directory with executables
327 let mut executables = Vec::new();
328 if bin_dir.exists()
329 && let Ok(entries) = fs::read_dir(&bin_dir)
330 {
331 for entry in entries.flatten() {
332 let path = entry.path();
333 if path.is_file() && is_executable(&path) {
334 executables.push(path);
335 }
336 }
337 }
338
339 // If no executables found, just remove everything
340 if executables.is_empty() {
341 fs::remove_dir_all(vendor_dir)?;
342 return Ok(());
343 }
344
345 // Create temporary directory for backup
346 let temp_dir =
347 std::env::temp_dir().join(format!("clean-dev-dirs-backup-{}", std::process::id()));
348 fs::create_dir_all(&temp_dir)?;
349
350 // Backup executables
351 let mut backed_up = Vec::new();
352 for exe in &executables {
353 if let Some(file_name) = exe.file_name() {
354 let backup_path = temp_dir.join(file_name);
355 if fs::copy(exe, &backup_path).is_ok() {
356 backed_up.push((backup_path, exe.clone()));
357 }
358 }
359 }
360
361 // Remove the vendor directory
362 fs::remove_dir_all(vendor_dir)?;
363
364 // Recreate bin directory and restore executables
365 if !backed_up.is_empty() {
366 fs::create_dir_all(&bin_dir)?;
367 for (backup_path, original_path) in backed_up {
368 let _ = fs::copy(&backup_path, &original_path);
369 }
370 }
371
372 // Clean up temporary directory
373 let _ = fs::remove_dir_all(&temp_dir);
374
375 Ok(())
376}
377
378/// Check if a file is executable.
379///
380/// On Unix systems, checks the executable permission bit.
381/// On Windows, checks for common executable extensions (.exe, .dll, .com).
382///
383/// # Arguments
384///
385/// * `path` - Path to the file to check
386///
387/// # Returns
388///
389/// `true` if the file is likely an executable, `false` otherwise
390fn is_executable(path: &std::path::Path) -> bool {
391 #[cfg(unix)]
392 {
393 use std::os::unix::fs::PermissionsExt;
394 if let Ok(metadata) = fs::metadata(path) {
395 let permissions = metadata.permissions();
396 // Check if any execute bit is set
397 return permissions.mode() & 0o111 != 0;
398 }
399 false
400 }
401
402 #[cfg(windows)]
403 {
404 if let Some(ext) = path.extension() {
405 let ext_str = ext.to_string_lossy().to_lowercase();
406 return matches!(ext_str.as_str(), "exe" | "dll" | "com");
407 }
408 false
409 }
410
411 #[cfg(not(any(unix, windows)))]
412 {
413 // Fallback for other platforms
414 false
415 }
416}
417
418/// Clean the build directory for a single project.
419///
420/// This function handles the cleanup of an individual project's build directory.
421/// It calculates the actual size before deletion and then removes the entire
422/// directory tree, optionally preserving executable binaries.
423///
424/// # Arguments
425///
426/// * `project` - The project whose build directory should be cleaned
427/// * `keep_executables` - Whether to preserve compiled executables
428///
429/// # Returns
430///
431/// - `Ok(u64)` - The number of bytes freed by the cleanup
432/// - `Err(anyhow::Error)` - If the cleanup operation failed
433///
434/// # Behavior
435///
436/// 1. Checks if the build directory exists (returns 0 if not)
437/// 2. Calculates the actual size of the directory before deletion
438/// 3. If `keep_executables` is true and the project is Rust or Go:
439/// - Preserves executable binaries in appropriate locations
440/// - Removes only intermediate build artifacts
441/// 4. Otherwise, removes the entire directory tree
442/// 5. Returns the amount of space freed
443///
444/// # Error Conditions
445///
446/// This function can fail if:
447/// - The build directory cannot be removed due to permission issues
448/// - Files within the directory are locked or in use by other processes
449/// - The file system encounters I/O errors during deletion
450///
451/// # Examples
452///
453/// ```
454/// # use crate::{Project, clean_single_project};
455/// # use anyhow::Result;
456/// let result = clean_single_project(&project, true);
457/// match result {
458/// Ok(freed_bytes) => println!("Freed {} bytes", freed_bytes),
459/// Err(e) => eprintln!("Cleanup failed: {}", e),
460/// }
461/// ```
462fn clean_single_project(project: &Project, keep_executables: bool) -> Result<u64> {
463 let build_dir = &project.build_arts.path;
464
465 if !build_dir.exists() {
466 return Ok(0);
467 }
468
469 // Get the actual size before deletion (might be different from the cached size)
470 let actual_size = calculate_directory_size(build_dir);
471
472 // Remove the build directory with optional executable preservation
473 if keep_executables {
474 clean_with_executable_preservation(project)?;
475 } else {
476 fs::remove_dir_all(build_dir)?;
477 }
478
479 Ok(actual_size)
480}
481
482/// Calculate the total size of a directory and all its contents.
483///
484/// This function recursively traverses a directory tree and sums up the sizes
485/// of all files within it. It handles errors gracefully by skipping files
486/// that cannot be accessed.
487///
488/// # Arguments
489///
490/// * `path` - The directory path to measure
491///
492/// # Returns
493///
494/// The total size of all files in the directory tree, in bytes.
495///
496/// # Error Handling
497///
498/// This function is designed to be robust and will continue processing even
499/// if individual files cannot be accessed. It silently skips:
500/// - Files that cannot be read due to permission issues
501/// - Broken symbolic links
502/// - Files that are deleted while the scan is in progress
503///
504/// # Performance
505///
506/// This function can be I/O intensive for large directories with many files.
507/// It processes files sequentially within each directory but may be called
508/// in parallel for different directories by the cleanup process.
509///
510/// # Examples
511///
512/// ```
513/// # use std::path::Path;
514/// # use crate::calculate_directory_size;
515/// let size = calculate_directory_size(Path::new("/path/to/directory"));
516/// println!("Directory size: {} bytes", size);
517/// ```
518fn calculate_directory_size(path: &std::path::Path) -> u64 {
519 let mut total_size = 0u64;
520
521 for entry in walkdir::WalkDir::new(path) {
522 if let Ok(entry) = entry {
523 if entry.file_type().is_file()
524 && let Ok(metadata) = entry.metadata()
525 {
526 total_size += metadata.len();
527 }
528 } else {
529 // Skip errors for individual files
530 }
531 }
532
533 total_size
534}
535
536impl Default for Cleaner {
537 /// Create a default cleaner instance.
538 ///
539 /// This implementation allows `Cleaner::default()` to be used as an
540 /// alternative to `Cleaner::new()` for creating cleaner instances.
541 ///
542 /// # Returns
543 ///
544 /// A new `Cleaner` instance with default settings.
545 fn default() -> Self {
546 Self::new()
547 }
548}