Skip to main content

auths_sdk/presentation/
html.rs

1//! HTML report rendering for audit data.
2
3use crate::ports::git::{CommitRecord, SignatureStatus};
4use crate::workflows::audit::AuditSummary;
5
6/// Render a full HTML audit report from structured data.
7///
8/// Args:
9/// * `generated_at`: ISO-8601 timestamp string for the report header.
10/// * `repository`: Repository path or identifier shown in the report.
11/// * `summary`: Aggregate statistics over the commit set.
12/// * `commits`: The commit records to render as table rows.
13///
14/// Usage:
15/// ```ignore
16/// let html = render_audit_html("2024-01-01T00:00:00Z", "org/repo", &summary, &commits);
17/// std::fs::write("report.html", html)?;
18/// ```
19pub fn render_audit_html(
20    generated_at: &str,
21    repository: &str,
22    summary: &AuditSummary,
23    commits: &[CommitRecord],
24) -> String {
25    let signed_pct = if summary.total_commits > 0 {
26        (summary.signed_commits as f64 / summary.total_commits as f64) * 100.0
27    } else {
28        0.0
29    };
30
31    let rows = commits
32        .iter()
33        .map(render_commit_row)
34        .collect::<Vec<_>>()
35        .join("\n");
36
37    format!(
38        r#"<!DOCTYPE html>
39<html lang="en">
40<head>
41    <meta charset="UTF-8">
42    <meta name="viewport" content="width=device-width, initial-scale=1.0">
43    <title>Audit Report</title>
44    <style>
45        body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 2rem; }}
46        h1 {{ color: #1a1a1a; }}
47        .summary {{ background: #f5f5f5; padding: 1rem; border-radius: 8px; margin: 1rem 0; }}
48        .stat {{ display: inline-block; margin-right: 2rem; }}
49        .stat-value {{ font-size: 2rem; font-weight: bold; color: #0066cc; }}
50        .stat-label {{ color: #666; }}
51        table {{ width: 100%; border-collapse: collapse; margin-top: 1rem; }}
52        th, td {{ padding: 0.5rem; text-align: left; border-bottom: 1px solid #ddd; }}
53        th {{ background: #f5f5f5; }}
54        .signed {{ color: #22c55e; }}
55        .unsigned {{ color: #ef4444; }}
56        .verified {{ color: #22c55e; }}
57        .unverified {{ color: #f59e0b; }}
58    </style>
59</head>
60<body>
61    <h1>Audit Report</h1>
62    <p>Generated: {generated_at}</p>
63    <p>Repository: {repository}</p>
64
65    <div class="summary">
66        <div class="stat">
67            <div class="stat-value">{total}</div>
68            <div class="stat-label">Total Commits</div>
69        </div>
70        <div class="stat">
71            <div class="stat-value signed">{signed}</div>
72            <div class="stat-label">Signed ({signed_pct:.0}%)</div>
73        </div>
74        <div class="stat">
75            <div class="stat-value unsigned">{unsigned}</div>
76            <div class="stat-label">Unsigned</div>
77        </div>
78    </div>
79
80    <table>
81        <thead>
82            <tr>
83                <th>Hash</th>
84                <th>Date</th>
85                <th>Author</th>
86                <th>Message</th>
87                <th>Method</th>
88                <th>Verified</th>
89            </tr>
90        </thead>
91        <tbody>
92            {rows}
93        </tbody>
94    </table>
95</body>
96</html>"#,
97        generated_at = html_escape::encode_text(generated_at),
98        repository = html_escape::encode_text(repository),
99        total = summary.total_commits,
100        signed = summary.signed_commits,
101        unsigned = summary.unsigned_commits,
102        signed_pct = signed_pct,
103        rows = rows,
104    )
105}
106
107fn render_commit_row(c: &CommitRecord) -> String {
108    let (signing_method, is_signed, is_verified) = classify_signature(&c.signature_status);
109    let method_class = if is_signed { "signed" } else { "unsigned" };
110    let verified_class = if is_verified {
111        "verified"
112    } else {
113        "unverified"
114    };
115    let verified_text = if !is_signed {
116        "-"
117    } else if is_verified {
118        "Yes"
119    } else {
120        "No"
121    };
122    let date = if c.timestamp.len() >= 10 {
123        &c.timestamp[..10]
124    } else {
125        &c.timestamp
126    };
127
128    format!(
129        r#"<tr>
130                <td><code>{hash}</code></td>
131                <td>{date}</td>
132                <td>{author}</td>
133                <td>{message}</td>
134                <td class="{method_class}">{method}</td>
135                <td class="{verified_class}">{verified}</td>
136            </tr>"#,
137        hash = html_escape::encode_text(&c.hash),
138        date = html_escape::encode_text(date),
139        author = html_escape::encode_text(&c.author_name),
140        message = html_escape::encode_text(&c.message),
141        method_class = method_class,
142        method = html_escape::encode_text(signing_method),
143        verified_class = verified_class,
144        verified = verified_text,
145    )
146}
147
148fn classify_signature(status: &SignatureStatus) -> (&'static str, bool, bool) {
149    match status {
150        SignatureStatus::AuthsSigned { .. } => ("auths", true, true),
151        SignatureStatus::SshSigned => ("ssh", true, false),
152        SignatureStatus::GpgSigned { verified } => ("gpg", true, *verified),
153        SignatureStatus::Unsigned => ("none", false, false),
154        SignatureStatus::InvalidSignature { .. } => ("invalid", true, false),
155    }
156}