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}