Skip to main content

git_closure/snapshot/
render.rs

1/// Audit report rendering: Markdown, HTML, and JSON output from a snapshot.
2use std::fs;
3use std::path::Path;
4
5use serde::Serialize;
6
7use crate::utils::io_error_with_path;
8
9use super::serial::parse_snapshot;
10use super::{ListEntry, Result, SnapshotHeader};
11
12// ── Public types ──────────────────────────────────────────────────────────────
13
14/// Output format for [`render_snapshot`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum RenderFormat {
17    /// Render as a Markdown report.
18    Markdown,
19    /// Render as a standalone HTML page.
20    Html,
21    /// Render as pretty-printed JSON.
22    Json,
23}
24
25// ── Public API ────────────────────────────────────────────────────────────────
26
27/// Renders a snapshot file as an audit report in the given format.
28pub fn render_snapshot(snapshot: &Path, format: RenderFormat) -> Result<String> {
29    let text = fs::read_to_string(snapshot).map_err(|err| io_error_with_path(err, snapshot))?;
30    let (header, files) = parse_snapshot(&text)?;
31    let entries: Vec<ListEntry> = files
32        .into_iter()
33        .map(|f| ListEntry {
34            is_symlink: f.symlink_target.is_some(),
35            symlink_target: f.symlink_target,
36            sha256: f.sha256,
37            mode: f.mode,
38            size: f.size,
39            path: f.path,
40        })
41        .collect();
42
43    match format {
44        RenderFormat::Markdown => Ok(render_markdown(&header, &entries)),
45        RenderFormat::Html => Ok(render_html(&header, &entries)),
46        RenderFormat::Json => Ok(render_json(&header, &entries)),
47    }
48}
49
50// ── Markdown renderer ─────────────────────────────────────────────────────────
51
52fn render_markdown(header: &SnapshotHeader, entries: &[ListEntry]) -> String {
53    let mut out = String::new();
54
55    out.push_str("# Snapshot Audit Report\n\n");
56    out.push_str("## Metadata\n\n");
57    out.push_str(&format!(
58        "| Field | Value |\n|---|---|\n| Snapshot hash | `{}` |\n| File count | {} |\n",
59        header.snapshot_hash, header.file_count
60    ));
61    if let Some(rev) = &header.git_rev {
62        out.push_str(&format!("| Git revision | `{rev}` |\n"));
63    }
64    if let Some(branch) = &header.git_branch {
65        out.push_str(&format!("| Git branch | `{branch}` |\n"));
66    }
67
68    let (regular_count, symlink_count) = count_entry_types(entries);
69    let total_bytes: u64 = entries.iter().map(|e| e.size).sum();
70    out.push_str(&format!(
71        "| Regular files | {regular_count} |\n| Symlinks | {symlink_count} |\n| Total bytes | {total_bytes} |\n"
72    ));
73
74    out.push_str("\n## Files\n\n");
75    out.push_str("| Path | Type | Mode | Size | SHA-256 (prefix) |\n");
76    out.push_str("|---|---|---|---|---|\n");
77
78    for e in entries {
79        let entry_type = if e.is_symlink { "symlink" } else { "file" };
80        let sha256_display = if e.is_symlink {
81            format!("→ {}", e.symlink_target.as_deref().unwrap_or(""))
82        } else {
83            format!("`{}`", &e.sha256[..16])
84        };
85        out.push_str(&format!(
86            "| `{}` | {} | {} | {} | {} |\n",
87            md_escape(&e.path),
88            entry_type,
89            e.mode,
90            e.size,
91            sha256_display
92        ));
93    }
94
95    out
96}
97
98// ── HTML renderer ─────────────────────────────────────────────────────────────
99
100fn render_html(header: &SnapshotHeader, entries: &[ListEntry]) -> String {
101    let mut out = String::new();
102
103    out.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
104    out.push_str("<meta charset=\"UTF-8\">\n");
105    out.push_str("<title>Snapshot Audit Report</title>\n");
106    out.push_str("<style>body{font-family:monospace;max-width:1200px;margin:2em auto;padding:0 1em}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px;text-align:left}th{background:#f0f0f0}code{background:#f8f8f8;padding:2px 4px;border-radius:2px}</style>\n");
107    out.push_str("</head>\n<body>\n");
108    out.push_str("<h1>Snapshot Audit Report</h1>\n");
109    out.push_str("<h2>Metadata</h2>\n<table>\n");
110    out.push_str(&format!(
111        "<tr><th>Snapshot hash</th><td><code>{}</code></td></tr>\n",
112        html_escape(&header.snapshot_hash)
113    ));
114    out.push_str(&format!(
115        "<tr><th>File count</th><td>{}</td></tr>\n",
116        header.file_count
117    ));
118    if let Some(rev) = &header.git_rev {
119        out.push_str(&format!(
120            "<tr><th>Git revision</th><td><code>{}</code></td></tr>\n",
121            html_escape(rev)
122        ));
123    }
124    if let Some(branch) = &header.git_branch {
125        out.push_str(&format!(
126            "<tr><th>Git branch</th><td><code>{}</code></td></tr>\n",
127            html_escape(branch)
128        ));
129    }
130    let (regular_count, symlink_count) = count_entry_types(entries);
131    let total_bytes: u64 = entries.iter().map(|e| e.size).sum();
132    out.push_str(&format!(
133        "<tr><th>Regular files</th><td>{regular_count}</td></tr>\n"
134    ));
135    out.push_str(&format!(
136        "<tr><th>Symlinks</th><td>{symlink_count}</td></tr>\n"
137    ));
138    out.push_str(&format!(
139        "<tr><th>Total bytes</th><td>{total_bytes}</td></tr>\n"
140    ));
141    out.push_str("</table>\n");
142
143    out.push_str("<h2>Files</h2>\n<table>\n");
144    out.push_str("<thead><tr><th>Path</th><th>Type</th><th>Mode</th><th>Size</th><th>SHA-256 (prefix)</th></tr></thead>\n<tbody>\n");
145
146    for e in entries {
147        let entry_type = if e.is_symlink { "symlink" } else { "file" };
148        let sha256_display = if e.is_symlink {
149            format!(
150                "→ {}",
151                html_escape(e.symlink_target.as_deref().unwrap_or(""))
152            )
153        } else {
154            format!("<code>{}</code>", &e.sha256[..16])
155        };
156        out.push_str(&format!(
157            "<tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
158            html_escape(&e.path),
159            entry_type,
160            e.mode,
161            e.size,
162            sha256_display
163        ));
164    }
165    out.push_str("</tbody></table>\n</body>\n</html>\n");
166    out
167}
168
169// ── JSON renderer ─────────────────────────────────────────────────────────────
170
171fn render_json(header: &SnapshotHeader, entries: &[ListEntry]) -> String {
172    let (regular_count, symlink_count) = count_entry_types(entries);
173    let total_bytes: u64 = entries.iter().map(|e| e.size).sum();
174
175    let files: Vec<RenderJsonFile<'_>> = entries
176        .iter()
177        .map(|entry| RenderJsonFile {
178            path: entry.path.as_str(),
179            entry_type: if entry.is_symlink { "symlink" } else { "file" },
180            mode: entry.mode.as_str(),
181            size: entry.size,
182            sha256: entry.sha256.as_str(),
183            symlink_target: entry.symlink_target.as_deref(),
184        })
185        .collect();
186
187    let payload = RenderJson {
188        snapshot_hash: header.snapshot_hash.as_str(),
189        file_count: header.file_count,
190        git_rev: header.git_rev.as_deref(),
191        git_branch: header.git_branch.as_deref(),
192        regular_file_count: regular_count,
193        symlink_count,
194        total_bytes,
195        files,
196    };
197
198    let mut json = serde_json::to_string_pretty(&payload).expect("serialize render JSON");
199    json.push('\n');
200    json
201}
202
203// ── Helpers ───────────────────────────────────────────────────────────────────
204
205fn count_entry_types(entries: &[ListEntry]) -> (usize, usize) {
206    let symlinks = entries.iter().filter(|e| e.is_symlink).count();
207    (entries.len() - symlinks, symlinks)
208}
209
210fn html_escape(s: &str) -> String {
211    s.replace('&', "&amp;")
212        .replace('<', "&lt;")
213        .replace('>', "&gt;")
214        .replace('"', "&quot;")
215}
216
217fn md_escape(s: &str) -> String {
218    s.replace('|', "\\|").replace('`', "\\`")
219}
220
221#[derive(Debug, Serialize)]
222struct RenderJson<'a> {
223    snapshot_hash: &'a str,
224    file_count: usize,
225    git_rev: Option<&'a str>,
226    git_branch: Option<&'a str>,
227    regular_file_count: usize,
228    symlink_count: usize,
229    total_bytes: u64,
230    files: Vec<RenderJsonFile<'a>>,
231}
232
233#[derive(Debug, Serialize)]
234struct RenderJsonFile<'a> {
235    path: &'a str,
236    #[serde(rename = "type")]
237    entry_type: &'a str,
238    mode: &'a str,
239    size: u64,
240    sha256: &'a str,
241    symlink_target: Option<&'a str>,
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::snapshot::hash::{compute_snapshot_hash, sha256_hex};
248    use crate::snapshot::serial::serialize_snapshot;
249    use crate::snapshot::{SnapshotFile, SnapshotHeader};
250    use std::fs;
251    use tempfile::TempDir;
252
253    fn text_file(path: &str, content: &str) -> SnapshotFile {
254        let bytes = content.as_bytes().to_vec();
255        SnapshotFile {
256            path: path.to_string(),
257            sha256: sha256_hex(&bytes),
258            mode: "644".to_string(),
259            size: bytes.len() as u64,
260            encoding: None,
261            symlink_target: None,
262            content: bytes,
263        }
264    }
265
266    fn symlink_file(path: &str, target: &str) -> SnapshotFile {
267        SnapshotFile {
268            path: path.to_string(),
269            sha256: String::new(),
270            mode: "120000".to_string(),
271            size: 0,
272            encoding: None,
273            symlink_target: Some(target.to_string()),
274            content: Vec::new(),
275        }
276    }
277
278    fn write_snap(
279        dir: &TempDir,
280        files: &[SnapshotFile],
281        git_rev: Option<&str>,
282        git_branch: Option<&str>,
283    ) -> std::path::PathBuf {
284        let mut sorted = files.to_vec();
285        sorted.sort_by(|a, b| a.path.cmp(&b.path));
286        let snapshot_hash = compute_snapshot_hash(&sorted);
287        let header = SnapshotHeader {
288            snapshot_hash,
289            file_count: sorted.len(),
290            git_rev: git_rev.map(str::to_string),
291            git_branch: git_branch.map(str::to_string),
292            extra_headers: Vec::new(),
293        };
294        let text = serialize_snapshot(&sorted, &header);
295        let path = dir.path().join("snap.gcl");
296        fs::write(&path, text.as_bytes()).unwrap();
297        path
298    }
299
300    #[test]
301    fn render_markdown_contains_hash_and_file_count() {
302        let dir = TempDir::new().unwrap();
303        let files = vec![text_file("src/main.rs", "fn main() {}")];
304        let snap = write_snap(&dir, &files, None, None);
305
306        let output = render_snapshot(&snap, RenderFormat::Markdown).unwrap();
307        assert!(
308            output.contains("# Snapshot Audit Report"),
309            "markdown must start with h1"
310        );
311        assert!(
312            output.contains("| File count | 1 |"),
313            "must show file count"
314        );
315        assert!(output.contains("src/main.rs"), "must list file paths");
316    }
317
318    #[test]
319    fn render_markdown_includes_git_metadata_when_present() {
320        let dir = TempDir::new().unwrap();
321        let files = vec![text_file("a.txt", "a")];
322        let snap = write_snap(&dir, &files, Some("cafebabe"), Some("main"));
323
324        let output = render_snapshot(&snap, RenderFormat::Markdown).unwrap();
325        assert!(
326            output.contains("cafebabe"),
327            "markdown must include git revision"
328        );
329        assert!(output.contains("main"), "markdown must include branch name");
330    }
331
332    #[test]
333    fn render_html_is_valid_html_structure() {
334        let dir = TempDir::new().unwrap();
335        // Use a path with a character that needs HTML escaping.
336        let files = vec![text_file("src/main.rs", "fn main() {}")];
337        let snap = write_snap(&dir, &files, None, None);
338
339        let output = render_snapshot(&snap, RenderFormat::Html).unwrap();
340        assert!(output.starts_with("<!DOCTYPE html>"), "must be proper HTML");
341        assert!(output.ends_with("</html>\n"), "must end with </html>");
342        assert!(output.contains("src/main.rs"), "must list file path");
343        assert!(
344            output.contains("<table>"),
345            "must contain a table for file listing"
346        );
347    }
348
349    #[test]
350    fn render_json_is_parseable_and_contains_expected_fields() {
351        let dir = TempDir::new().unwrap();
352        let files = vec![text_file("a.txt", "aaa"), text_file("b.txt", "bb")];
353        let snap = write_snap(&dir, &files, Some("abc"), Some("dev"));
354
355        let output = render_snapshot(&snap, RenderFormat::Json).unwrap();
356        let value: serde_json::Value = serde_json::from_str(&output).expect("json must parse");
357        assert_eq!(value["file_count"], serde_json::Value::from(2));
358        assert_eq!(value["git_rev"], serde_json::Value::from("abc"));
359        assert_eq!(value["git_branch"], serde_json::Value::from("dev"));
360        assert!(value["total_bytes"].is_u64());
361        let files = value["files"].as_array().expect("files must be an array");
362        assert_eq!(files.len(), 2);
363        assert_eq!(files[0]["path"], serde_json::Value::from("a.txt"));
364    }
365
366    #[test]
367    fn render_json_null_git_fields_when_absent() {
368        let dir = TempDir::new().unwrap();
369        let files = vec![text_file("x.txt", "x")];
370        let snap = write_snap(&dir, &files, None, None);
371
372        let output = render_snapshot(&snap, RenderFormat::Json).unwrap();
373        assert!(
374            output.contains("\"git_rev\": null"),
375            "git_rev must be null when absent"
376        );
377        assert!(
378            output.contains("\"git_branch\": null"),
379            "git_branch must be null when absent"
380        );
381    }
382
383    #[test]
384    fn render_outputs_are_deterministic_for_same_input() {
385        let dir = TempDir::new().unwrap();
386        let files = vec![text_file("a.txt", "aaa"), symlink_file("link", "a.txt")];
387        let snap = write_snap(&dir, &files, Some("abc123"), Some("main"));
388
389        let md_a = render_snapshot(&snap, RenderFormat::Markdown).unwrap();
390        let md_b = render_snapshot(&snap, RenderFormat::Markdown).unwrap();
391        assert_eq!(md_a, md_b, "markdown output must be deterministic");
392
393        let html_a = render_snapshot(&snap, RenderFormat::Html).unwrap();
394        let html_b = render_snapshot(&snap, RenderFormat::Html).unwrap();
395        assert_eq!(html_a, html_b, "html output must be deterministic");
396
397        let json_a = render_snapshot(&snap, RenderFormat::Json).unwrap();
398        let json_b = render_snapshot(&snap, RenderFormat::Json).unwrap();
399        assert_eq!(json_a, json_b, "json output must be deterministic");
400    }
401
402    #[test]
403    fn render_symlink_entries_are_consistent_across_formats() {
404        let dir = TempDir::new().unwrap();
405        let files = vec![text_file("a.txt", "aaa"), symlink_file("link", "a.txt")];
406        let snap = write_snap(&dir, &files, None, None);
407
408        let markdown = render_snapshot(&snap, RenderFormat::Markdown).unwrap();
409        assert!(
410            markdown.contains("`link`")
411                && markdown.contains("symlink")
412                && markdown.contains("→ a.txt"),
413            "markdown must identify symlink entries and targets"
414        );
415
416        let html = render_snapshot(&snap, RenderFormat::Html).unwrap();
417        assert!(
418            html.contains("<code>link</code>")
419                && html.contains("symlink")
420                && html.contains("→ a.txt"),
421            "html must identify symlink entries and targets"
422        );
423
424        let json = render_snapshot(&snap, RenderFormat::Json).unwrap();
425        let value: serde_json::Value = serde_json::from_str(&json).expect("json must parse");
426        let files = value["files"].as_array().expect("files must be an array");
427        let link_entry = files
428            .iter()
429            .find(|entry| entry["path"].as_str() == Some("link"))
430            .expect("json must include link entry");
431        assert_eq!(link_entry["type"], serde_json::Value::from("symlink"));
432        assert_eq!(
433            link_entry["symlink_target"],
434            serde_json::Value::from("a.txt")
435        );
436    }
437}