Skip to main content

gha_command_proof/
render.rs

1use anyhow::Result;
2use clap::ValueEnum;
3
4use crate::receipt::{CheckStatus, Receipt};
5
6#[derive(Clone, Copy, Debug, ValueEnum)]
7pub enum OutputFormat {
8    Text,
9    Json,
10    Markdown,
11}
12
13pub fn render_receipt(receipt: &Receipt, format: OutputFormat) -> Result<String> {
14    match format {
15        OutputFormat::Text => Ok(render_text(receipt)),
16        OutputFormat::Json => Ok(format!("{}\n", serde_json::to_string_pretty(receipt)?)),
17        OutputFormat::Markdown => Ok(render_markdown(receipt)),
18    }
19}
20
21fn render_text(receipt: &Receipt) -> String {
22    let mut out = String::new();
23    out.push_str(&format!(
24        "{} {} ({})\n",
25        receipt.tool.name, receipt.tool.version, receipt.mode
26    ));
27    out.push_str(&format!(
28        "summary: {} passed, {} warned, {} failed, {} skipped\n",
29        receipt.summary.passed,
30        receipt.summary.warned,
31        receipt.summary.failed,
32        receipt.summary.skipped
33    ));
34
35    for check in &receipt.checks {
36        out.push_str(&format!(
37            "{} {:<7} {}",
38            status_symbol(check.status),
39            status_word(check.status),
40            check.id
41        ));
42        if let Some(location) = &check.location {
43            if location.source.is_some() || location.line.is_some() {
44                out.push_str(" (");
45                if let Some(source) = &location.source {
46                    out.push_str(source);
47                }
48                if let Some(line) = location.line {
49                    out.push_str(&format!(":{line}"));
50                }
51                out.push(')');
52            }
53        }
54        out.push('\n');
55        out.push_str(&format!("  {}\n", check.message));
56    }
57
58    if !receipt.commands.is_empty() {
59        out.push_str("\ncommands:\n");
60        for command in &receipt.commands {
61            out.push_str(&format!(
62                "  line {:<4} {:<11} {:<14} {}\n",
63                command.line,
64                syntax_word(command.syntax),
65                command.name,
66                command.outcome
67            ));
68        }
69    }
70
71    if !receipt.env_files.is_empty() {
72        out.push_str("\nenvironment files:\n");
73        for file in &receipt.env_files {
74            out.push_str(&format!(
75                "  {} {} records, {} bytes",
76                file.kind.context_name(),
77                file.records.len(),
78                file.bytes
79            ));
80            if let Some(source) = &file.source {
81                out.push_str(&format!(" ({source})"));
82            }
83            out.push('\n');
84        }
85    }
86
87    out
88}
89
90fn render_markdown(receipt: &Receipt) -> String {
91    let mut out = String::new();
92    out.push_str("# GHA Command Proof\n\n");
93    out.push_str(&format!(
94        "- Tool: `{}` `{}`\n",
95        receipt.tool.name, receipt.tool.version
96    ));
97    out.push_str(&format!("- Mode: `{}`\n", markdown_escape(&receipt.mode)));
98    out.push_str(&format!("- Checked at: `{}`\n", receipt.checked_at));
99    out.push_str(&format!(
100        "- Summary: **{} passed**, **{} warned**, **{} failed**, **{} skipped**\n\n",
101        receipt.summary.passed,
102        receipt.summary.warned,
103        receipt.summary.failed,
104        receipt.summary.skipped
105    ));
106
107    out.push_str("| Status | Check | Location | Message |\n");
108    out.push_str("| --- | --- | --- | --- |\n");
109    for check in &receipt.checks {
110        let location = check
111            .location
112            .as_ref()
113            .map(|location| {
114                let mut rendered = String::new();
115                if let Some(source) = &location.source {
116                    rendered.push_str(&markdown_escape(source));
117                }
118                if let Some(line) = location.line {
119                    rendered.push_str(&format!(":{line}"));
120                }
121                rendered
122            })
123            .unwrap_or_default();
124        out.push_str(&format!(
125            "| {} {} | `{}` | {} | {} |\n",
126            status_symbol(check.status),
127            status_word(check.status),
128            markdown_escape(&check.id),
129            location,
130            markdown_escape(&check.message)
131        ));
132    }
133
134    out
135}
136
137fn status_symbol(status: CheckStatus) -> &'static str {
138    match status {
139        CheckStatus::Pass => "[PASS]",
140        CheckStatus::Warn => "[WARN]",
141        CheckStatus::Fail => "[FAIL]",
142        CheckStatus::Skip => "[SKIP]",
143    }
144}
145
146fn status_word(status: CheckStatus) -> &'static str {
147    match status {
148        CheckStatus::Pass => "pass",
149        CheckStatus::Warn => "warn",
150        CheckStatus::Fail => "fail",
151        CheckStatus::Skip => "skip",
152    }
153}
154
155fn syntax_word(syntax: crate::CommandSyntax) -> &'static str {
156    match syntax {
157        crate::CommandSyntax::Modern => "modern",
158        crate::CommandSyntax::Legacy => "legacy",
159    }
160}
161
162fn markdown_escape(value: &str) -> String {
163    value.replace('|', "\\|").replace('\n', "<br>")
164}