1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum RenderFormat {
17 Markdown,
19 Html,
21 Json,
23}
24
25pub 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
50fn 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
98fn 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
169fn 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
203fn 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('&', "&")
212 .replace('<', "<")
213 .replace('>', ">")
214 .replace('"', """)
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 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}