gha-command-proof 1.0.0

Verifier for GitHub Actions workflow commands and environment files
Documentation
use anyhow::Result;
use clap::ValueEnum;

use crate::receipt::{CheckStatus, Receipt};

#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum OutputFormat {
    Text,
    Json,
    Markdown,
}

pub fn render_receipt(receipt: &Receipt, format: OutputFormat) -> Result<String> {
    match format {
        OutputFormat::Text => Ok(render_text(receipt)),
        OutputFormat::Json => Ok(format!("{}\n", serde_json::to_string_pretty(receipt)?)),
        OutputFormat::Markdown => Ok(render_markdown(receipt)),
    }
}

fn render_text(receipt: &Receipt) -> String {
    let mut out = String::new();
    out.push_str(&format!(
        "{} {} ({})\n",
        receipt.tool.name, receipt.tool.version, receipt.mode
    ));
    out.push_str(&format!(
        "summary: {} passed, {} warned, {} failed, {} skipped\n",
        receipt.summary.passed,
        receipt.summary.warned,
        receipt.summary.failed,
        receipt.summary.skipped
    ));

    for check in &receipt.checks {
        out.push_str(&format!(
            "{} {:<7} {}",
            status_symbol(check.status),
            status_word(check.status),
            check.id
        ));
        if let Some(location) = &check.location {
            if location.source.is_some() || location.line.is_some() {
                out.push_str(" (");
                if let Some(source) = &location.source {
                    out.push_str(source);
                }
                if let Some(line) = location.line {
                    out.push_str(&format!(":{line}"));
                }
                out.push(')');
            }
        }
        out.push('\n');
        out.push_str(&format!("  {}\n", check.message));
    }

    if !receipt.commands.is_empty() {
        out.push_str("\ncommands:\n");
        for command in &receipt.commands {
            out.push_str(&format!(
                "  line {:<4} {:<11} {:<14} {}\n",
                command.line,
                syntax_word(command.syntax),
                command.name,
                command.outcome
            ));
        }
    }

    if !receipt.env_files.is_empty() {
        out.push_str("\nenvironment files:\n");
        for file in &receipt.env_files {
            out.push_str(&format!(
                "  {} {} records, {} bytes",
                file.kind.context_name(),
                file.records.len(),
                file.bytes
            ));
            if let Some(source) = &file.source {
                out.push_str(&format!(" ({source})"));
            }
            out.push('\n');
        }
    }

    out
}

fn render_markdown(receipt: &Receipt) -> String {
    let mut out = String::new();
    out.push_str("# GHA Command Proof\n\n");
    out.push_str(&format!(
        "- Tool: `{}` `{}`\n",
        receipt.tool.name, receipt.tool.version
    ));
    out.push_str(&format!("- Mode: `{}`\n", markdown_escape(&receipt.mode)));
    out.push_str(&format!("- Checked at: `{}`\n", receipt.checked_at));
    out.push_str(&format!(
        "- Summary: **{} passed**, **{} warned**, **{} failed**, **{} skipped**\n\n",
        receipt.summary.passed,
        receipt.summary.warned,
        receipt.summary.failed,
        receipt.summary.skipped
    ));

    out.push_str("| Status | Check | Location | Message |\n");
    out.push_str("| --- | --- | --- | --- |\n");
    for check in &receipt.checks {
        let location = check
            .location
            .as_ref()
            .map(|location| {
                let mut rendered = String::new();
                if let Some(source) = &location.source {
                    rendered.push_str(&markdown_escape(source));
                }
                if let Some(line) = location.line {
                    rendered.push_str(&format!(":{line}"));
                }
                rendered
            })
            .unwrap_or_default();
        out.push_str(&format!(
            "| {} {} | `{}` | {} | {} |\n",
            status_symbol(check.status),
            status_word(check.status),
            markdown_escape(&check.id),
            location,
            markdown_escape(&check.message)
        ));
    }

    out
}

fn status_symbol(status: CheckStatus) -> &'static str {
    match status {
        CheckStatus::Pass => "[PASS]",
        CheckStatus::Warn => "[WARN]",
        CheckStatus::Fail => "[FAIL]",
        CheckStatus::Skip => "[SKIP]",
    }
}

fn status_word(status: CheckStatus) -> &'static str {
    match status {
        CheckStatus::Pass => "pass",
        CheckStatus::Warn => "warn",
        CheckStatus::Fail => "fail",
        CheckStatus::Skip => "skip",
    }
}

fn syntax_word(syntax: crate::CommandSyntax) -> &'static str {
    match syntax {
        crate::CommandSyntax::Modern => "modern",
        crate::CommandSyntax::Legacy => "legacy",
    }
}

fn markdown_escape(value: &str) -> String {
    value.replace('|', "\\|").replace('\n', "<br>")
}