gha-cache-proof 1.0.1

GitHub Actions cache compatibility checker and local cache-store receipt tool for offline CI
Documentation
use camino::Utf8PathBuf;
use chrono::{DateTime, Utc};
use clap::ValueEnum;
use gha_expression_proof::EvaluationReceipt;
use serde::{Deserialize, Serialize};

pub type SchemaVersion = u32;
pub const SCHEMA_VERSION: SchemaVersion = 1;

#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
    Text,
    Json,
    Markdown,
}

#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RunnerOs {
    Linux,
    Windows,
    Macos,
}

impl RunnerOs {
    pub fn gha_name(self) -> &'static str {
        match self {
            Self::Linux => "Linux",
            Self::Windows => "Windows",
            Self::Macos => "macOS",
        }
    }
}

#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Compression {
    Gzip,
    Zstd,
}

impl Compression {
    pub fn default_for(runner_os: RunnerOs, cross_os: bool) -> Self {
        if cross_os {
            return Self::Zstd;
        }

        match runner_os {
            RunnerOs::Windows => Self::Gzip,
            RunnerOs::Linux | RunnerOs::Macos => Self::Zstd,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolInfo {
    pub name: String,
    pub version: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
    Pass,
    Warn,
    Fail,
    Skip,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Check {
    pub id: String,
    pub status: CheckStatus,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub location: Option<String>,
}

impl Check {
    pub fn pass(id: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            status: CheckStatus::Pass,
            message: message.into(),
            location: None,
        }
    }

    pub fn warn(id: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            status: CheckStatus::Warn,
            message: message.into(),
            location: None,
        }
    }

    pub fn fail(id: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            status: CheckStatus::Fail,
            message: message.into(),
            location: None,
        }
    }

    pub fn skip(id: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            status: CheckStatus::Skip,
            message: message.into(),
            location: None,
        }
    }

    pub fn at(mut self, location: impl Into<String>) -> Self {
        self.location = Some(location.into());
        self
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ReceiptSummary {
    pub passed: usize,
    pub warnings: usize,
    pub failed: usize,
    pub skipped: usize,
}

impl ReceiptSummary {
    pub fn from_checks(checks: &[Check]) -> Self {
        let mut summary = Self::default();
        for check in checks {
            match check.status {
                CheckStatus::Pass => summary.passed += 1,
                CheckStatus::Warn => summary.warnings += 1,
                CheckStatus::Fail => summary.failed += 1,
                CheckStatus::Skip => summary.skipped += 1,
            }
        }
        summary
    }

    pub fn add(&mut self, other: &Self) {
        self.passed += other.passed;
        self.warnings += other.warnings;
        self.failed += other.failed;
        self.skipped += other.skipped;
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheProofReceipt {
    pub schema_version: SchemaVersion,
    pub tool: ToolInfo,
    pub checked_at: DateTime<Utc>,
    pub mode: String,
    pub store: Utf8PathBuf,
    pub workspace: Utf8PathBuf,
    pub summary: ReceiptSummary,
    pub operations: Vec<CacheOperationReceipt>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub workflows: Vec<WorkflowCacheReport>,
    pub checks: Vec<Check>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CacheOperationKind {
    Cache,
    Restore,
    Save,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CacheMatchKind {
    ExactKey,
    PrefixKey,
    RestoreExact,
    RestorePrefix,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMatch {
    pub entry_id: String,
    pub key: String,
    pub version: String,
    pub scope: String,
    pub match_kind: CacheMatchKind,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachePathRecord {
    pub input: String,
    pub resolved: Utf8PathBuf,
    pub files: usize,
    pub bytes: u64,
    pub exists: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheOperationReceipt {
    pub operation: CacheOperationKind,
    pub key: String,
    pub restore_keys: Vec<String>,
    pub paths: Vec<String>,
    pub version: String,
    pub scope: String,
    pub accessible_scopes: Vec<String>,
    pub runner_os: RunnerOs,
    pub compression: Compression,
    pub enable_cross_os_archive: bool,
    pub lookup_only: bool,
    pub fail_on_cache_miss: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub matched: Option<CacheMatch>,
    pub cache_hit: String,
    pub restored_files: usize,
    pub restored_bytes: u64,
    #[serde(default, skip_serializing_if = "is_zero_usize")]
    pub skipped_absolute_files: usize,
    pub saved_files: usize,
    pub saved_bytes: u64,
    pub path_records: Vec<CachePathRecord>,
    pub checks: Vec<Check>,
}

impl CacheOperationReceipt {
    pub fn summary(&self) -> ReceiptSummary {
        ReceiptSummary::from_checks(&self.checks)
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowCacheReport {
    pub workflow: Utf8PathBuf,
    pub cache_steps: Vec<WorkflowCacheStep>,
    pub summary: ReceiptSummary,
    pub checks: Vec<Check>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowCacheStep {
    pub job_id: String,
    pub step_index: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    pub uses: String,
    pub operation: CacheOperationKind,
    pub key_template: String,
    pub key: String,
    pub restore_key_templates: Vec<String>,
    pub restore_keys: Vec<String>,
    pub path_templates: Vec<String>,
    pub paths: Vec<String>,
    pub expression_receipts: Vec<EvaluationReceipt>,
    pub operation_receipt: CacheOperationReceipt,
    pub checks: Vec<Check>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheIndex {
    pub schema_version: SchemaVersion,
    pub entries: Vec<CacheEntry>,
}

impl Default for CacheIndex {
    fn default() -> Self {
        Self {
            schema_version: SCHEMA_VERSION,
            entries: Vec::new(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
    pub id: String,
    pub key: String,
    pub version: String,
    pub scope: String,
    pub paths: Vec<String>,
    pub runner_os: RunnerOs,
    pub compression: Compression,
    pub enable_cross_os_archive: bool,
    pub created_at: DateTime<Utc>,
    pub last_accessed_at: DateTime<Utc>,
    pub files: usize,
    pub bytes: u64,
}

fn is_zero_usize(value: &usize) -> bool {
    *value == 0
}