gha-command-proof 1.0.0

Verifier for GitHub Actions workflow commands and environment files
Documentation
use std::collections::BTreeMap;

use chrono::{DateTime, Utc};
use serde::Serialize;

use crate::command::CommandRecord;
use crate::envfile::EnvFileAnalysis;

#[derive(Clone, Debug, Serialize)]
pub struct Receipt {
    pub schema_version: u8,
    pub tool: ToolReceipt,
    pub checked_at: DateTime<Utc>,
    pub mode: String,
    pub summary: Summary,
    pub checks: Vec<Check>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub commands: Vec<CommandRecord>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub env_files: Vec<EnvFileAnalysis>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub metadata: BTreeMap<String, String>,
}

#[derive(Clone, Debug, Serialize)]
pub struct ToolReceipt {
    pub name: &'static str,
    pub version: &'static str,
}

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

impl Summary {
    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.warned += 1,
                CheckStatus::Fail => summary.failed += 1,
                CheckStatus::Skip => summary.skipped += 1,
            }
        }
        summary
    }
}

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

impl Check {
    pub fn pass(
        id: impl Into<String>,
        message: impl Into<String>,
        location: Option<Location>,
    ) -> Self {
        Self::new(id, CheckStatus::Pass, message, location)
    }

    pub fn warn(
        id: impl Into<String>,
        message: impl Into<String>,
        location: Option<Location>,
    ) -> Self {
        Self::new(id, CheckStatus::Warn, message, location)
    }

    pub fn fail(
        id: impl Into<String>,
        message: impl Into<String>,
        location: Option<Location>,
    ) -> Self {
        Self::new(id, CheckStatus::Fail, message, location)
    }

    pub fn skip(
        id: impl Into<String>,
        message: impl Into<String>,
        location: Option<Location>,
    ) -> Self {
        Self::new(id, CheckStatus::Skip, message, location)
    }

    pub fn with_detail(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.details.insert(key.into(), value.into());
        self
    }

    fn new(
        id: impl Into<String>,
        status: CheckStatus,
        message: impl Into<String>,
        location: Option<Location>,
    ) -> Self {
        Self {
            id: id.into(),
            status,
            message: message.into(),
            location,
            details: BTreeMap::new(),
        }
    }
}

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

#[derive(Clone, Debug, Serialize)]
pub struct Location {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub line: Option<usize>,
}

impl Location {
    pub fn new(source: Option<String>, line: Option<usize>) -> Self {
        Self { source, line }
    }

    pub fn line(source: Option<&str>, line: usize) -> Self {
        Self {
            source: source.map(ToString::to_string),
            line: Some(line),
        }
    }
}