cargo-reclaim 0.2.1

Safe Cargo cleanup for target directories, stale artifacts, and Cargo home caches
Documentation
use std::time::SystemTime;

use crate::ReclaimResult;
use crate::model::{ArtifactClass, PathSnapshot, PlanAction, PlanEntry, TargetEvidence};
use crate::policy::PolicyKind;

use super::{
    ActiveObservation, PlannerCandidate, PlannerOptions, ProcessView, TargetContext,
    WholeTargetMode,
};

pub(super) fn plan_candidate_for_policy(
    policy: PolicyKind,
    candidate: PlannerCandidate,
    options: &PlannerOptions,
    active_observation: &ActiveObservation,
    now: SystemTime,
) -> ReclaimResult<PlanEntry> {
    let PlannerCandidate {
        snapshot,
        artifact_class,
        evidence,
        target_context,
    } = candidate;

    if artifact_class == ArtifactClass::WholeTarget {
        return plan_whole_target_candidate(
            policy,
            snapshot,
            evidence,
            target_context,
            options,
            active_observation,
            now,
        );
    }

    if artifact_class == ArtifactClass::Unknown {
        return PlanEntry::new(
            snapshot,
            artifact_class,
            evidence,
            PlanAction::Unknown,
            "artifact class is unknown and needs classification before deletion",
            false,
        );
    }

    if policy == PolicyKind::Observe {
        return PlanEntry::preserved(
            snapshot,
            artifact_class,
            evidence,
            "observe policy preserves candidates without deleting",
        );
    }

    if PolicyKind::is_default_protected_output(artifact_class) {
        return PlanEntry::preserved(
            snapshot,
            artifact_class,
            evidence,
            "policy protects durable user-facing outputs by default",
        );
    }

    if !is_removable_for_policy(policy, artifact_class) {
        return PlanEntry::preserved(
            snapshot,
            artifact_class,
            evidence,
            "artifact class is not removable for the selected policy",
        );
    }

    if evidence.is_weak_name_only() {
        return PlanEntry::new(
            snapshot,
            artifact_class,
            evidence,
            PlanAction::RequiresConfirmation,
            "name-only evidence is below the default delete confidence threshold",
            true,
        );
    }

    if artifact_class == ArtifactClass::DepsOutput && options.recent_write_keep_window.is_none() {
        return PlanEntry::preserved(
            snapshot,
            artifact_class,
            evidence,
            "deps outputs require a recent-write keep window before automatic deletion",
        );
    }

    if !matches!(
        artifact_class,
        ArtifactClass::StaleDeps | ArtifactClass::StaleIncremental
    ) && is_recently_modified(&snapshot.modified, options, now)
    {
        return PlanEntry::new(
            snapshot,
            artifact_class,
            evidence,
            PlanAction::SkipActive,
            "recent target writes are inside the active-project keep window",
            false,
        );
    }

    if is_under_keep_size(&snapshot, options) {
        return PlanEntry::preserved(
            snapshot,
            artifact_class,
            evidence,
            "artifact size is inside the configured keep-size threshold",
        );
    }

    if let Some(reason) = active_skip_reason(target_context.as_ref(), active_observation) {
        return PlanEntry::new(
            snapshot,
            artifact_class,
            evidence,
            PlanAction::SkipActive,
            reason,
            false,
        );
    }

    if policy.allows_delete(&PlanAction::Delete, artifact_class, &evidence) {
        return PlanEntry::delete(
            snapshot,
            artifact_class,
            evidence,
            "policy permits deletion for this classified removable artifact",
            false,
        );
    }

    PlanEntry::preserved(
        snapshot,
        artifact_class,
        evidence,
        "artifact class is not removable for the selected policy",
    )
}

fn plan_whole_target_candidate(
    policy: PolicyKind,
    snapshot: PathSnapshot,
    evidence: TargetEvidence,
    target_context: Option<TargetContext>,
    options: &PlannerOptions,
    active_observation: &ActiveObservation,
    now: SystemTime,
) -> ReclaimResult<PlanEntry> {
    match options.whole_target_mode {
        WholeTargetMode::Off => PlanEntry::preserved(
            snapshot,
            ArtifactClass::WholeTarget,
            evidence,
            "whole-target planning is disabled",
        ),
        WholeTargetMode::Confirm => PlanEntry::new(
            snapshot,
            ArtifactClass::WholeTarget,
            evidence,
            PlanAction::RequiresConfirmation,
            "whole-target deletion requires explicit confirmation",
            true,
        ),
        WholeTargetMode::DeleteConfirmed => {
            if evidence.is_weak_name_only() {
                return PlanEntry::new(
                    snapshot,
                    ArtifactClass::WholeTarget,
                    evidence,
                    PlanAction::RequiresConfirmation,
                    "name-only evidence is below the whole-target delete confidence threshold",
                    true,
                );
            }

            if is_recently_modified(&snapshot.modified, options, now) {
                return PlanEntry::new(
                    snapshot,
                    ArtifactClass::WholeTarget,
                    evidence,
                    PlanAction::SkipActive,
                    "recent target writes are inside the active-project keep window",
                    false,
                );
            }

            if is_under_keep_size(&snapshot, options) {
                return PlanEntry::preserved(
                    snapshot,
                    ArtifactClass::WholeTarget,
                    evidence,
                    "artifact size is inside the configured keep-size threshold",
                );
            }

            if let Some(reason) = active_skip_reason(target_context.as_ref(), active_observation) {
                return PlanEntry::new(
                    snapshot,
                    ArtifactClass::WholeTarget,
                    evidence,
                    PlanAction::SkipActive,
                    reason,
                    false,
                );
            }

            if policy == PolicyKind::Aggressive {
                return PlanEntry::delete(
                    snapshot,
                    ArtifactClass::WholeTarget,
                    evidence,
                    "aggressive policy permits confirmed whole-target deletion",
                    false,
                );
            }

            PlanEntry::new(
                snapshot,
                ArtifactClass::WholeTarget,
                evidence,
                PlanAction::RequiresConfirmation,
                "whole-target deletion requires aggressive policy after confirmation",
                true,
            )
        }
    }
}

fn active_skip_reason(
    target_context: Option<&TargetContext>,
    active_observation: &ActiveObservation,
) -> Option<String> {
    let target_context = target_context?;

    match &active_observation.process_view {
        ProcessView::NotAttempted => None,
        ProcessView::PermissionLimited { reason } => Some(format!(
            "process inspection was permission-limited for active project detection: {}",
            non_empty_observation_reason(reason)
        )),
        ProcessView::Failed { reason } => Some(format!(
            "process inspection failed for active project detection: {}",
            non_empty_observation_reason(reason)
        )),
        ProcessView::Complete { processes } => processes
            .iter()
            .find_map(|process| target_context.active_match(process))
            .map(|active_match| match active_match {
                super::active::ActiveMatch::CwdUnderProject { cwd, project_root } => format!(
                    "observed Cargo or rustc process cwd {} is under project root {}",
                    cwd.display(),
                    project_root.display()
                ),
                super::active::ActiveMatch::ReferencedPathOverlapsRoot {
                    referenced_path,
                    root,
                } => format!(
                    "observed Cargo or rustc process path {} overlaps build root {}",
                    referenced_path.display(),
                    root.display()
                ),
            }),
    }
}

fn non_empty_observation_reason(reason: &str) -> &str {
    let reason = reason.trim();
    if reason.is_empty() {
        "reason unavailable"
    } else {
        reason
    }
}

fn is_recently_modified(
    modified: &Option<SystemTime>,
    options: &PlannerOptions,
    now: SystemTime,
) -> bool {
    let Some(keep_window) = options.recent_write_keep_window else {
        return false;
    };
    let Some(modified) = *modified else {
        return false;
    };

    now.duration_since(modified)
        .map(|age| age <= keep_window)
        .unwrap_or(true)
}

fn is_under_keep_size(snapshot: &PathSnapshot, options: &PlannerOptions) -> bool {
    options
        .keep_size_bytes
        .is_some_and(|keep_size_bytes| snapshot.size_bytes <= keep_size_bytes)
}

fn is_removable_for_policy(policy: PolicyKind, artifact_class: ArtifactClass) -> bool {
    match policy {
        PolicyKind::Observe => false,
        PolicyKind::Conservative => PolicyKind::is_conservative_removable_class(artifact_class),
        PolicyKind::Balanced | PolicyKind::Aggressive | PolicyKind::Custom => {
            PolicyKind::is_default_removable_class(artifact_class)
        }
    }
}