cargo-reclaim 0.2.1

Safe Cargo cleanup for target directories, stale artifacts, and Cargo home caches
Documentation
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use crate::config::{BackgroundMode, ReclaimConfig, WholeTargetConfig};
use crate::disk::disk_free_space;
use crate::inventory::InventoryOptions;
use crate::planner::{PlannerOptions, WholeTargetMode};
use crate::policy::PolicyKind;
use crate::scanner::{ScanItem, ScannerOptions, TargetCandidateKind, scan_roots};
use crate::scheduler::{SchedulerError, SchedulerMode};
use crate::watcher::{
    WatcherDecision, WatcherDecisionInput, WatcherDecisionState, WatcherMode,
    WatcherObservedTarget, WatcherThresholds, decide_watcher_thresholds,
};

use super::super::{BackgroundRunRequest, BackgroundRunTrigger};
use super::model::{BackgroundServiceError, BackgroundServiceResult};

#[derive(Debug, Clone)]
pub(crate) struct BackgroundCycleRequestContext {
    config_path: PathBuf,
    config_version: u16,
    roots: Vec<PathBuf>,
    policy: PolicyKind,
    scanner_options: ScannerOptions,
    inventory_options: InventoryOptions,
    planner_options: PlannerOptions,
    mode: SchedulerMode,
    allow_apply: bool,
    background_enabled: bool,
    background_mode: Option<BackgroundMode>,
    max_target_size_bytes: Option<u64>,
    disk_free_below_basis_points: Option<u16>,
    min_free_disk_bytes: Option<u64>,
}

impl BackgroundCycleRequestContext {
    pub(crate) fn from_config(
        config: &ReclaimConfig,
        config_path: &Path,
        mode: SchedulerMode,
    ) -> BackgroundServiceResult<Self> {
        let policy = effective_run_policy(mode, config)?;
        let allow_apply = config.scheduler.allow_unattended_cleanup.unwrap_or(false);
        validate_run_apply_policy(mode, allow_apply, policy, config)?;
        validate_whole_target_policy(policy, config)?;

        Ok(Self {
            config_path: config_path.to_path_buf(),
            config_version: config.version,
            roots: run_roots(config),
            policy,
            scanner_options: scanner_options_from_config(config),
            inventory_options: inventory_options_from_config(config),
            planner_options: planner_options_from_config(config),
            mode,
            allow_apply,
            background_enabled: config.background.enabled.unwrap_or(true),
            background_mode: config.background.mode,
            max_target_size_bytes: config.policy_thresholds.max_target_size_bytes,
            disk_free_below_basis_points: config.background.only_when_disk_free_below_basis_points,
            min_free_disk_bytes: config.background.min_free_disk_bytes,
        })
    }

    pub(crate) fn request(
        &self,
        run_id: String,
        log_path: PathBuf,
        plan_path: PathBuf,
        now: SystemTime,
    ) -> BackgroundServiceResult<BackgroundRunRequest> {
        Ok(BackgroundRunRequest {
            run_id,
            log_path,
            plan_path,
            roots: self.roots.clone(),
            policy: self.policy,
            scanner_options: self.scanner_options.clone(),
            inventory_options: self.inventory_options.clone(),
            planner_options: self.planner_options.clone(),
            trigger: BackgroundRunTrigger::Decision(self.run_decision()?),
            config_path: Some(self.config_path.clone()),
            config_version: Some(self.config_version),
            created_at: now,
            now,
            expires_at: now + Duration::from_secs(60 * 60),
        })
    }

    fn run_decision(&self) -> BackgroundServiceResult<WatcherDecision> {
        if !self.background_enabled {
            return Ok(WatcherDecision {
                state: WatcherDecisionState::Inactive,
                reasons: Vec::new(),
            });
        }

        if self.background_mode == Some(BackgroundMode::Threshold) {
            let observed_targets = observed_targets_from_roots(
                &self.roots,
                &self.scanner_options,
                &self.inventory_options,
            )?;
            let disk_free_space = self.observed_disk_free_space()?;
            return Ok(decide_watcher_thresholds(WatcherDecisionInput {
                enabled: self.background_enabled,
                mode: WatcherMode::Threshold,
                thresholds: WatcherThresholds {
                    max_target_size_bytes: self.max_target_size_bytes,
                    disk_free_below_basis_points: self.disk_free_below_basis_points,
                    min_free_disk_bytes: self.min_free_disk_bytes,
                },
                observed_targets,
                disk_free_basis_points: disk_free_space.and_then(|space| space.free_basis_points()),
                disk_free_bytes: disk_free_space.map(|space| space.available_bytes),
                selected_policy: self.policy,
                unattended_allowed: self.mode == SchedulerMode::Cleanup && self.allow_apply,
            }));
        }

        Ok(WatcherDecision {
            state: if self.mode == SchedulerMode::Cleanup
                && self.allow_apply
                && self.policy != PolicyKind::Observe
            {
                WatcherDecisionState::TriggeredPlanAndApply
            } else {
                WatcherDecisionState::TriggeredPlanOnly
            },
            reasons: Vec::new(),
        })
    }

    fn observed_disk_free_space(
        &self,
    ) -> BackgroundServiceResult<Option<crate::disk::DiskFreeSpace>> {
        if self.disk_free_below_basis_points.is_none() && self.min_free_disk_bytes.is_none() {
            return Ok(None);
        }
        let root = self
            .roots
            .first()
            .map(PathBuf::as_path)
            .unwrap_or_else(|| Path::new("."));
        disk_free_space(root)
            .map(Some)
            .map_err(BackgroundServiceError::from)
    }
}

pub(crate) fn scheduler_mode_from_config(
    config: &ReclaimConfig,
) -> BackgroundServiceResult<SchedulerMode> {
    match config.scheduler.mode.as_deref() {
        Some("observe") | None => Ok(SchedulerMode::Observe),
        Some("cleanup") => Ok(SchedulerMode::Cleanup),
        Some(value) => Err(BackgroundServiceError::Config(format!(
            "unknown scheduler mode `{value}`; expected observe or cleanup"
        ))),
    }
}

fn effective_run_policy(
    mode: SchedulerMode,
    config: &ReclaimConfig,
) -> BackgroundServiceResult<PolicyKind> {
    let policy = config
        .scheduler
        .policy
        .as_deref()
        .or(config.policy.as_deref())
        .map(parse_policy)
        .transpose()?;
    Ok(policy.unwrap_or(match mode {
        SchedulerMode::Observe => PolicyKind::Observe,
        SchedulerMode::Cleanup => PolicyKind::Conservative,
    }))
}

fn validate_run_apply_policy(
    mode: SchedulerMode,
    allow_apply: bool,
    policy: PolicyKind,
    config: &ReclaimConfig,
) -> BackgroundServiceResult<()> {
    if mode == SchedulerMode::Cleanup && !allow_apply {
        return Err(BackgroundServiceError::Scheduler(
            SchedulerError::CleanupNotAllowed,
        ));
    }
    if mode == SchedulerMode::Cleanup
        && allow_apply
        && matches!(
            policy,
            PolicyKind::Balanced | PolicyKind::Aggressive | PolicyKind::Custom
        )
        && !config
            .scheduler
            .allow_unattended_high_policy
            .unwrap_or(false)
    {
        return Err(BackgroundServiceError::Scheduler(
            SchedulerError::HighPolicyNotAllowed(policy),
        ));
    }
    Ok(())
}

fn validate_whole_target_policy(
    policy: PolicyKind,
    config: &ReclaimConfig,
) -> BackgroundServiceResult<()> {
    if config.whole_target != Some(WholeTargetConfig::Delete) {
        return Ok(());
    }

    if policy != PolicyKind::Aggressive {
        return Err(BackgroundServiceError::Config(
            "config whole_target = \"delete\" requires aggressive policy".to_owned(),
        ));
    }
    if !config.allow_unattended_whole_target_delete.unwrap_or(false) {
        return Err(BackgroundServiceError::Config(
            "config whole_target = \"delete\" requires allow_unattended_whole_target_delete = true"
                .to_owned(),
        ));
    }

    Ok(())
}

fn parse_policy(value: &str) -> BackgroundServiceResult<PolicyKind> {
    match value {
        "observe" => Ok(PolicyKind::Observe),
        "conservative" => Ok(PolicyKind::Conservative),
        "balanced" => Ok(PolicyKind::Balanced),
        "aggressive" => Ok(PolicyKind::Aggressive),
        "custom" => Ok(PolicyKind::Custom),
        _ => Err(BackgroundServiceError::Config(format!(
            "unknown policy `{value}`; expected observe, conservative, balanced, aggressive, or custom"
        ))),
    }
}

fn run_roots(config: &ReclaimConfig) -> Vec<PathBuf> {
    if config.roots.is_empty() {
        vec![PathBuf::from(".")]
    } else {
        config.roots.clone()
    }
}

fn scanner_options_from_config(config: &ReclaimConfig) -> ScannerOptions {
    ScannerOptions {
        ignored_paths: config.ignored_paths.clone(),
        skipped_paths: config.skipped_paths.clone(),
        follow_symlinks: config.scanner.follow_symlinks.unwrap_or(false),
        allow_name_only_targets: config.scanner.allow_name_only_targets.unwrap_or(false),
        cross_filesystems: config.scanner.cross_filesystems.unwrap_or(false),
    }
}

fn inventory_options_from_config(config: &ReclaimConfig) -> InventoryOptions {
    InventoryOptions {
        follow_symlinks: config.scanner.follow_symlinks.unwrap_or(false),
        skipped_paths: config.skipped_paths.clone(),
        deep_target_scan: false,
        deep_directory_measurement: true,
    }
}

fn planner_options_from_config(config: &ReclaimConfig) -> PlannerOptions {
    PlannerOptions {
        recent_write_keep_window: config.recent_write_keep_window,
        keep_size_bytes: config.keep_size_bytes,
        target_size_goal_bytes: config.policy_thresholds.target_size_goal_bytes,
        target_free_disk_bytes: config.background.target_free_disk_bytes,
        minimum_reclaim_bytes: None,
        keep_rustc_hashes: config.keep_rustc_hashes.clone(),
        keep_installed_toolchains: config.keep_installed_toolchains,
        keep_toolchains: config.keep_toolchains.clone(),
        whole_target_mode: config
            .whole_target
            .map(whole_target_mode_from_config)
            .unwrap_or_default(),
    }
}

fn whole_target_mode_from_config(value: WholeTargetConfig) -> WholeTargetMode {
    match value {
        WholeTargetConfig::Off => WholeTargetMode::Off,
        WholeTargetConfig::Confirm => WholeTargetMode::Confirm,
        WholeTargetConfig::Delete => WholeTargetMode::DeleteConfirmed,
    }
}

fn observed_targets_from_roots(
    roots: &[PathBuf],
    scanner_options: &ScannerOptions,
    inventory_options: &InventoryOptions,
) -> BackgroundServiceResult<Vec<WatcherObservedTarget>> {
    let items = scan_roots(roots.iter().cloned(), scanner_options)
        .map_err(|error| BackgroundServiceError::Config(error.to_string()))?;
    let mut observed_targets = Vec::new();

    for item in items {
        let ScanItem::TargetCandidate(candidate) = item else {
            continue;
        };
        if candidate.kind != TargetCandidateKind::CargoTargetDir {
            continue;
        }

        let snapshot = crate::inventory::snapshot_path(&candidate.path, inventory_options)
            .map_err(|error| BackgroundServiceError::Config(error.to_string()))?;
        observed_targets.push(WatcherObservedTarget {
            path: candidate.path,
            size_bytes: snapshot.size_bytes,
        });
    }

    Ok(observed_targets)
}