Skip to main content

ssg/audit/output/
text.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Rich-text formatter for audit reports.
5//!
6//! The default output mode for `ssg audit` on TTY. Uses ANSI escape
7//! codes for severity colour, but the codes are kept short so the
8//! output stays readable when redirected to a file.
9
10use crate::audit::{AuditReport, Severity};
11use std::fmt::Write;
12
13/// Renders `report` into `out` using a grouped-by-gate layout.
14///
15/// # Examples
16///
17/// ```
18/// use ssg::audit::AuditReport;
19/// use ssg::audit::output::text::format;
20/// let report = AuditReport { gates: vec![] };
21/// let mut out = String::new();
22/// format(&report, &mut out);
23/// assert!(out.is_empty());
24/// ```
25pub fn format(report: &AuditReport, out: &mut String) {
26    for gate in &report.gates {
27        if gate.skipped {
28            let _ = writeln!(
29                out,
30                "[{}] skipped — {}",
31                gate.name,
32                gate.skip_reason.as_deref().unwrap_or("no reason given")
33            );
34            continue;
35        }
36
37        let counts = &gate.severity_counts;
38        if counts.total() == 0 {
39            let _ = writeln!(out, "[{}] OK", gate.name);
40            continue;
41        }
42        let _ = writeln!(
43            out,
44            "[{}] {} finding(s) — {} error / {} warn / {} info",
45            gate.name,
46            counts.total(),
47            counts.error,
48            counts.warn,
49            counts.info
50        );
51        for f in &gate.findings {
52            let sigil = severity_sigil(f.severity);
53            let path = f.path.as_deref().unwrap_or("");
54            let code = f.code.as_deref().unwrap_or("");
55            let _ = writeln!(
56                out,
57                "  {sigil} {} {} {}",
58                code,
59                if path.is_empty() {
60                    String::new()
61                } else {
62                    format!("({path})")
63                },
64                f.message
65            );
66        }
67    }
68}
69
70const fn severity_sigil(s: Severity) -> &'static str {
71    match s {
72        Severity::Info => "i",
73        Severity::Warn => "!",
74        Severity::Error => "x",
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::audit::{Finding, GateResult, Severity, SeverityCounts};
82
83    #[test]
84    fn skipped_gate_renders_skipped_line() {
85        let report = AuditReport {
86            gates: vec![GateResult {
87                name: "g".to_string(),
88                skipped: true,
89                skip_reason: Some("test".to_string()),
90                severity_counts: SeverityCounts::default(),
91                findings: vec![],
92            }],
93        };
94        let mut s = String::new();
95        format(&report, &mut s);
96        assert!(s.contains("[g] skipped"));
97        assert!(s.contains("test"));
98    }
99
100    #[test]
101    fn ok_gate_renders_ok_line() {
102        let report = AuditReport {
103            gates: vec![GateResult {
104                name: "g".to_string(),
105                skipped: false,
106                skip_reason: None,
107                severity_counts: SeverityCounts::default(),
108                findings: vec![],
109            }],
110        };
111        let mut s = String::new();
112        format(&report, &mut s);
113        assert!(s.contains("[g] OK"));
114    }
115
116    #[test]
117    fn findings_are_rendered_with_severity_sigil() {
118        let report = AuditReport {
119            gates: vec![GateResult {
120                name: "g".to_string(),
121                skipped: false,
122                skip_reason: None,
123                severity_counts: SeverityCounts {
124                    info: 0,
125                    warn: 0,
126                    error: 1,
127                },
128                findings: vec![Finding::new("g", Severity::Error, "boom")
129                    .with_code("C")
130                    .with_path("x.html")],
131            }],
132        };
133        let mut s = String::new();
134        format(&report, &mut s);
135        assert!(s.contains("x C"));
136        assert!(s.contains("(x.html)"));
137        assert!(s.contains("boom"));
138    }
139}