cargo-reclaim 0.2.2

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

use serde::{Deserialize, Serialize};

use crate::persistence::{
    PersistedTimestamp, PlanId, PlanPersistenceError, PlanPersistenceResult,
    deserialize_plan_document, fingerprint_path,
};

use super::model::{
    CARGO_HOME_PLAN_SCHEMA_VERSION, CargoHomeClass, CargoHomePathKind, CargoHomePlan,
    CargoHomePlanAction, CargoHomePlanEntry, CargoHomeSource,
};

pub const CARGO_HOME_PERSISTED_PLAN_SCHEMA_VERSION: u16 = 1;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedCargoHomePlan {
    pub schema_version: u16,
    pub id: PlanId,
    #[serde(flatten)]
    pub body: PersistedCargoHomePlanBody,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedCargoHomePlanBody {
    pub created_at: PersistedTimestamp,
    pub expires_at: PersistedTimestamp,
    pub command: String,
    pub policy: String,
    pub plan: PersistedCargoHomePlanSnapshot,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SaveCargoHomePlanOptions {
    pub created_at: SystemTime,
    pub expires_at: SystemTime,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedCargoHomePlanSnapshot {
    pub schema_version: u16,
    pub input: PersistedCargoHomeInput,
    pub entries: Vec<PersistedCargoHomePlanEntry>,
    pub totals: PersistedCargoHomePlanTotals,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedCargoHomeInput {
    pub root: String,
    pub source: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedCargoHomePlanEntry {
    pub path: String,
    pub relative_path: String,
    pub class: String,
    pub action: String,
    pub path_kind: String,
    pub size_bytes: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub modified: Option<PersistedTimestamp>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content_fingerprint: Option<String>,
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PersistedCargoHomePlanTotals {
    pub entry_count: usize,
    pub total_bytes: u64,
    pub delete_candidate_count: usize,
    pub delete_candidate_bytes: u64,
    pub preserved_count: usize,
    pub preserved_bytes: u64,
    pub skipped_count: usize,
    pub problem_count: usize,
}

pub fn persist_cargo_home_plan(
    plan: &CargoHomePlan,
    options: SaveCargoHomePlanOptions,
) -> PlanPersistenceResult<PersistedCargoHomePlan> {
    if options.expires_at <= options.created_at {
        return Err(PlanPersistenceError::InvalidTimeRange);
    }

    let body = PersistedCargoHomePlanBody {
        created_at: PersistedTimestamp::from_system_time(options.created_at)?,
        expires_at: PersistedTimestamp::from_system_time(options.expires_at)?,
        command: "cargo-home plan".to_string(),
        policy: policy_label(plan.policy).to_string(),
        plan: PersistedCargoHomePlanSnapshot::from_plan(plan)?,
    };
    let id = PlanId::from_body(&body)?;

    Ok(PersistedCargoHomePlan {
        schema_version: CARGO_HOME_PERSISTED_PLAN_SCHEMA_VERSION,
        id,
        body,
    })
}

pub fn ensure_cargo_home_plan_usable(
    document: &PersistedCargoHomePlan,
    now: SystemTime,
) -> PlanPersistenceResult<()> {
    if document.schema_version != CARGO_HOME_PERSISTED_PLAN_SCHEMA_VERSION {
        return Err(PlanPersistenceError::PersistenceSchemaMismatch {
            found: document.schema_version,
            expected: CARGO_HOME_PERSISTED_PLAN_SCHEMA_VERSION,
        });
    }

    if document.body.plan.schema_version != CARGO_HOME_PLAN_SCHEMA_VERSION {
        return Err(PlanPersistenceError::PlanSchemaMismatch {
            found: document.body.plan.schema_version,
            expected: CARGO_HOME_PLAN_SCHEMA_VERSION,
        });
    }

    if document.body.command != "cargo-home plan" {
        return Err(PlanPersistenceError::InvalidPlan {
            message: format!(
                "persisted Cargo home plan command mismatch: expected cargo-home plan, found {}",
                document.body.command
            ),
        });
    }

    if !is_known_policy_label(&document.body.policy) {
        return Err(PlanPersistenceError::InvalidPlan {
            message: format!(
                "persisted Cargo home plan policy is not recognized: {}",
                document.body.policy
            ),
        });
    }

    let root = Path::new(&document.body.plan.input.root);
    if !root.is_absolute() {
        return Err(PlanPersistenceError::InvalidPlan {
            message: "persisted Cargo home root must be absolute".to_string(),
        });
    }

    let expected_id = PlanId::from_body(&document.body)?;
    if expected_id != document.id {
        return Err(PlanPersistenceError::PlanIdMismatch {
            expected: expected_id.0,
            found: document.id.0.clone(),
        });
    }

    if now >= document.body.expires_at.to_system_time() {
        return Err(PlanPersistenceError::PlanExpired);
    }

    Ok(())
}

pub fn save_cargo_home_plan_to_path(
    path: impl AsRef<Path>,
    document: &PersistedCargoHomePlan,
) -> PlanPersistenceResult<()> {
    let path = path.as_ref();
    let temp_path = temp_sibling_path(path);
    let bytes = serde_json::to_vec_pretty(document)?;

    fs::write(&temp_path, bytes).map_err(|error| io_error(&temp_path, error))?;
    fs::rename(&temp_path, path).map_err(|error| io_error(path, error))?;
    Ok(())
}

pub fn load_cargo_home_plan_from_path(
    path: impl AsRef<Path>,
) -> PlanPersistenceResult<PersistedCargoHomePlan> {
    let path = path.as_ref();
    let bytes = fs::read(path).map_err(|error| io_error(path, error))?;
    deserialize_plan_document(path, &bytes, "cargo-home plan")
}

impl PersistedCargoHomePlanSnapshot {
    fn from_plan(plan: &CargoHomePlan) -> PlanPersistenceResult<Self> {
        let root = canonical_cargo_home_root(&plan.input.root)?;
        Ok(Self {
            schema_version: plan.schema_version,
            input: PersistedCargoHomeInput {
                root: path_string(&root),
                source: source_label(plan.input.source).to_string(),
            },
            entries: plan
                .entries
                .iter()
                .map(|entry| PersistedCargoHomePlanEntry::from_entry(entry, &root))
                .collect::<PlanPersistenceResult<Vec<_>>>()?,
            totals: PersistedCargoHomePlanTotals {
                entry_count: plan.totals.entry_count,
                total_bytes: plan.totals.total_bytes,
                delete_candidate_count: plan.totals.delete_candidate_count,
                delete_candidate_bytes: plan.totals.delete_candidate_bytes,
                preserved_count: plan.totals.preserved_count,
                preserved_bytes: plan.totals.preserved_bytes,
                skipped_count: plan.totals.skipped_count,
                problem_count: plan.totals.problem_count,
            },
        })
    }
}

impl PersistedCargoHomePlanEntry {
    fn from_entry(entry: &CargoHomePlanEntry, root: &Path) -> PlanPersistenceResult<Self> {
        let path = root.join(&entry.relative_path);
        let metadata = fs::symlink_metadata(&path).map_err(|error| io_error(&path, error))?;
        let modified = metadata
            .modified()
            .ok()
            .and_then(|modified| PersistedTimestamp::from_system_time(modified).ok());

        Ok(Self {
            path: path_string(&path),
            relative_path: path_string(&entry.relative_path),
            class: class_label(entry.class).to_string(),
            action: action_label(entry.action).to_string(),
            path_kind: path_kind_label(entry.path_kind).to_string(),
            size_bytes: entry.size_bytes,
            modified,
            content_fingerprint: if entry.action == CargoHomePlanAction::DeleteCandidate {
                Some(fingerprint_path(&path, &metadata)?)
            } else {
                None
            },
            reason: entry.reason.clone(),
        })
    }
}

fn canonical_cargo_home_root(root: &Path) -> PlanPersistenceResult<PathBuf> {
    let metadata = fs::symlink_metadata(root).map_err(|error| io_error(root, error))?;
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        return Err(PlanPersistenceError::InvalidPlan {
            message: format!(
                "Cargo home root must be a real directory for persisted plans: {}",
                root.display()
            ),
        });
    }
    root.canonicalize().map_err(|error| io_error(root, error))
}

fn temp_sibling_path(path: &Path) -> PathBuf {
    let file_name = path
        .file_name()
        .map(|file_name| file_name.to_string_lossy().to_string())
        .unwrap_or_else(|| "cargo-home-plan".to_string());
    path.with_file_name(format!(".{file_name}.tmp"))
}

fn io_error(path: &Path, error: std::io::Error) -> PlanPersistenceError {
    PlanPersistenceError::Io {
        path: path.to_path_buf(),
        message: error.to_string(),
    }
}

fn path_string(path: impl AsRef<Path>) -> String {
    path.as_ref().display().to_string()
}

pub(crate) fn policy_label(policy: crate::PolicyKind) -> &'static str {
    match policy {
        crate::PolicyKind::Observe => "observe",
        crate::PolicyKind::Conservative => "conservative",
        crate::PolicyKind::Balanced => "balanced",
        crate::PolicyKind::Aggressive => "aggressive",
        crate::PolicyKind::Custom => "custom",
    }
}

fn is_known_policy_label(policy: &str) -> bool {
    matches!(
        policy,
        "observe" | "conservative" | "balanced" | "aggressive" | "custom"
    )
}

pub(crate) fn source_label(source: CargoHomeSource) -> &'static str {
    match source {
        CargoHomeSource::Explicit => "explicit",
        CargoHomeSource::CargoHomeEnv => "cargo_home_env",
        CargoHomeSource::HomeDefault => "home_default",
    }
}

pub(crate) fn class_label(class: CargoHomeClass) -> &'static str {
    match class {
        CargoHomeClass::RegistryIndex => "registry_index",
        CargoHomeClass::RegistryCache => "registry_cache",
        CargoHomeClass::RegistrySource => "registry_source",
        CargoHomeClass::GitDatabase => "git_database",
        CargoHomeClass::GitCheckouts => "git_checkouts",
        CargoHomeClass::Config => "config",
        CargoHomeClass::Credentials => "credentials",
        CargoHomeClass::InstalledBinaries => "installed_binaries",
        CargoHomeClass::InstallMetadata => "install_metadata",
        CargoHomeClass::UnknownUserAuthored => "unknown_user_authored",
    }
}

pub(crate) fn action_label(action: CargoHomePlanAction) -> &'static str {
    match action {
        CargoHomePlanAction::DeleteCandidate => "delete_candidate",
        CargoHomePlanAction::Preserve => "preserve",
        CargoHomePlanAction::SkipProblem => "skip_problem",
    }
}

pub(crate) fn path_kind_label(kind: CargoHomePathKind) -> &'static str {
    match kind {
        CargoHomePathKind::File => "file",
        CargoHomePathKind::Directory => "directory",
        CargoHomePathKind::Symlink => "symlink",
        CargoHomePathKind::Other => "other",
    }
}