cargo-hold 1.3.6

cargo-hold: A CI tool to ensure Cargo's incremental compilation is reliable by managing your caches intelligently
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use super::artifacts::{
    collect_crate_artifacts, rejuvenate_stale_artifact_mtimes, remove_crate_artifacts,
    select_artifacts_for_removal_with_stats,
};
use super::config::{Gc, GcStats};
use super::size::format_size;
use crate::error::{HoldError, Result};
use crate::logging::Logger;

/// Find all profile directories in the target directory
pub(crate) fn find_profile_directories(target_dir: &Path) -> Result<Vec<PathBuf>> {
    let mut profile_dirs = Vec::new();

    if !target_dir.exists() {
        return Ok(profile_dirs);
    }

    // Check if target_dir itself is a profile directory
    if is_profile_directory(target_dir) {
        profile_dirs.push(target_dir.to_path_buf());
        return Ok(profile_dirs);
    }

    // Look for profile directories in subdirectories
    let entries = fs::read_dir(target_dir).map_err(|source| HoldError::IoError {
        path: target_dir.to_path_buf(),
        source,
    })?;

    for entry in entries {
        let entry = entry.map_err(|source| HoldError::IoError {
            path: target_dir.to_path_buf(),
            source,
        })?;
        let path = entry.path();

        if path.is_dir() {
            // Skip special files
            if let Some(name) = path.file_name() {
                let name = name.to_string_lossy();
                if name == "CACHEDIR.TAG" || name == ".rustc_info.json" {
                    continue;
                }
            }

            if is_profile_directory(&path) {
                profile_dirs.push(path);
            } else {
                // Check subdirectories (for target triple directories)
                if let Ok(subdirs) = find_profile_directories(&path) {
                    profile_dirs.extend(subdirs);
                }
            }
        }
    }

    Ok(profile_dirs)
}

/// Check if a directory is a Cargo profile directory
fn is_profile_directory(path: &Path) -> bool {
    if !path.is_dir() {
        return false;
    }

    // Check for standard Cargo build artifacts
    let artifact_dirs = ["build", "deps", ".fingerprint"];
    artifact_dirs.iter().any(|&dir| path.join(dir).exists())
}

/// Clean a single profile directory
pub(crate) fn clean_profile_directory(
    profile_dir: &Path,
    config: &Gc,
    verbose: u8,
    global_stats: &GcStats,
) -> Result<GcStats> {
    let log = Logger::new(verbose, config.quiet());
    let mut stats = GcStats::default();

    // First, preserve binaries
    let binaries = preserve_binaries(profile_dir, verbose, config.quiet())?;
    stats.binaries_preserved = binaries.count;
    stats.preserved_binary_bytes = binaries.bytes;

    // Remove incremental compilation data
    let incremental_dir = profile_dir.join("incremental");
    if incremental_dir.exists() {
        log.verbose(1, "  Removing incremental compilation data");
        let size = calculate_directory_size(&incremental_dir)?;
        if !config.dry_run() {
            fs::remove_dir_all(&incremental_dir).map_err(|source| HoldError::IoError {
                path: incremental_dir,
                source,
            })?;
        }
        stats.bytes_freed += size;
    }

    // Collect and analyze crate artifacts
    let mut crate_artifacts = collect_crate_artifacts(profile_dir)?;

    log.verbose(
        2,
        format!("  Found {} crate artifacts", crate_artifacts.len()),
    );

    if config.rejuvenate_artifact_mtimes() {
        rejuvenate_stale_artifact_mtimes(
            &mut crate_artifacts,
            config.previous_build_mtime_nanos(),
            config.age_threshold_days(),
            config.dry_run(),
            verbose,
            config.quiet(),
        )?;
    }

    // Determine which crates to remove using combined logic
    // Calculate the current total size (initial - already freed globally)
    let current_total_size = global_stats
        .initial_size
        .saturating_sub(global_stats.bytes_freed + stats.bytes_freed);
    if !log.quiet() && (log.level() > 1 || config.debug()) {
        eprintln!(
            "  Initial: {}, Freed globally: {}, Freed locally: {}, Current total: {}",
            format_size(global_stats.initial_size),
            format_size(global_stats.bytes_freed),
            format_size(stats.bytes_freed),
            format_size(current_total_size)
        );
    }

    let artifact_bytes: u64 = crate_artifacts
        .iter()
        .map(|artifact| artifact.total_size)
        .sum();
    let selection = select_artifacts_for_removal_with_stats(
        &crate_artifacts,
        current_total_size,
        config.max_target_size(),
        config.age_threshold_days(),
        config.previous_build_mtime_nanos(),
        verbose,
        config.quiet(),
    );
    stats.protected_artifact_bytes = selection.protected_bytes;
    stats.eligible_artifact_bytes = selection.eligible_bytes;
    let selected_bytes: u64 = selection
        .to_remove
        .iter()
        .map(|artifact| artifact.total_size)
        .sum();
    stats.retained_artifact_bytes = if config.dry_run() {
        artifact_bytes
    } else {
        artifact_bytes.saturating_sub(selected_bytes)
    };

    if !log.quiet() && (log.level() > 1 || config.debug()) {
        eprintln!(
            "  Selected {} crates for removal",
            selection.to_remove.len()
        );
    }

    // Remove selected crates
    for crate_artifact in selection.to_remove {
        if !log.quiet() && log.level() > 1 {
            eprintln!(
                "  Removing {}-{} ({})",
                crate_artifact.name,
                crate_artifact.hash,
                format_size(crate_artifact.total_size)
            );
        }

        if !config.dry_run() {
            remove_crate_artifacts(crate_artifact)?;
        }

        stats.bytes_freed += crate_artifact.total_size;
        stats.artifacts_removed += crate_artifact.artifacts.len();
        stats.crates_cleaned += 1;
    }

    Ok(stats)
}

#[derive(Default)]
struct PreservedBinaries {
    count: usize,
    bytes: u64,
}

/// Account for binary files that GC deliberately preserves in the profile root.
fn preserve_binaries(profile_dir: &Path, verbose: u8, quiet: bool) -> Result<PreservedBinaries> {
    let log = Logger::new(verbose, quiet);
    let mut binaries = PreservedBinaries::default();

    let entries = fs::read_dir(profile_dir).map_err(|source| HoldError::IoError {
        path: profile_dir.to_path_buf(),
        source,
    })?;

    for entry in entries {
        let entry = entry.map_err(|source| HoldError::IoError {
            path: profile_dir.to_path_buf(),
            source,
        })?;
        let path = entry.path();

        if path.is_file() {
            // Check if file is executable
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                if let Ok(metadata) = path.metadata() {
                    let permissions = metadata.permissions();
                    let is_executable = permissions.mode() & 0o111 != 0;
                    let has_no_extension = path.extension().is_none();

                    if is_executable && has_no_extension {
                        log.verbose(
                            2,
                            format!(
                                "  Preserving binary: {:?}",
                                path.file_name().unwrap_or(path.as_os_str())
                            ),
                        );
                        binaries.count += 1;
                        binaries.bytes = binaries.bytes.saturating_add(metadata.len());
                    }
                }
            }

            #[cfg(not(unix))]
            {
                // On Windows, check for .exe extension
                if path.extension().is_some_and(|ext| ext == "exe") {
                    log.verbose(
                        2,
                        format!(
                            "  Preserving binary: {:?}",
                            path.file_name().unwrap_or(path.as_os_str())
                        ),
                    );
                    binaries.count += 1;
                    binaries.bytes = binaries
                        .bytes
                        .saturating_add(path.metadata().map_or(0, |metadata| metadata.len()));
                }
            }
        }
    }

    Ok(binaries)
}

/// Clean miscellaneous directories (doc, package, tmp)
pub(crate) fn clean_misc_directories(target_dir: &Path, config: &Gc, verbose: u8) -> Result<u64> {
    let mut bytes_freed = 0;
    let log = Logger::new(verbose, config.quiet());

    for dir_name in &["doc", "package", "tmp"] {
        let dir = target_dir.join(dir_name);
        if dir.exists() {
            log.verbose(1, format!("Removing directory: {}", dir.display()));

            let size = calculate_directory_size(&dir)?;
            if !config.dry_run() {
                fs::remove_dir_all(&dir)
                    .map_err(|source| HoldError::IoError { path: dir, source })?;
            }
            bytes_freed += size;
        }
    }

    Ok(bytes_freed)
}

/// Calculate the total size of a directory
pub(crate) fn calculate_directory_size(path: &Path) -> Result<u64> {
    if !path.exists() {
        return Ok(0);
    }

    let mut total_size = 0;

    if path.is_file() {
        let metadata = fs::metadata(path).map_err(|source| HoldError::IoError {
            path: path.to_path_buf(),
            source,
        })?;
        return Ok(metadata.len());
    }

    let entries = fs::read_dir(path).map_err(|source| HoldError::IoError {
        path: path.to_path_buf(),
        source,
    })?;

    for entry in entries {
        let entry = entry.map_err(|source| HoldError::IoError {
            path: path.to_path_buf(),
            source,
        })?;
        let entry_path = entry.path();

        if entry_path.is_dir() {
            total_size += calculate_directory_size(&entry_path)?;
        } else if entry_path.is_file() {
            let metadata = fs::metadata(&entry_path).map_err(|source| HoldError::IoError {
                path: entry_path.clone(),
                source,
            })?;
            total_size += metadata.len();
        }
    }

    Ok(total_size)
}