Skip to main content

ssg/audit/output/
json.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! JSON formatter for audit reports.
5//!
6//! Schema is stable: `{ "gates": [ { name, skipped, skip_reason?,
7//! severity_counts: { info, warn, error }, findings: [...] } ] }`.
8//! Golden-file tested in `tests/audit_gates.rs` so any breaking
9//! re-shape requires a deliberate commit.
10//!
11//! See also the sibling `JUnit` formatter for CI dashboards that
12//! prefer the JUnit/Surefire schema over JSON.
13
14use crate::audit::AuditReport;
15use crate::error::SsgError;
16
17/// Serialises `report` to a pretty-printed JSON string.
18///
19/// # Errors
20/// Returns [`SsgError::Io`] when `serde_json` cannot serialise the
21/// report — only possible if a finding's strings contain invalid
22/// UTF-8, which the type-system prevents in safe Rust.
23///
24/// # Examples
25///
26/// ```
27/// use ssg::audit::AuditReport;
28/// use ssg::audit::output::json::format;
29/// let report = AuditReport { gates: vec![] };
30/// let s = format(&report).unwrap();
31/// assert!(s.contains("\"gates\""));
32/// ```
33pub fn format(report: &AuditReport) -> Result<String, SsgError> {
34    fail_point!("audit::json-format", |_| {
35        Err(SsgError::Io {
36            path: std::path::PathBuf::from("<audit-report>"),
37            source: std::io::Error::other("injected: audit::json-format"),
38        })
39    });
40    serde_json::to_string_pretty(report).map_err(serialize_error)
41}
42
43/// Wraps a `serde_json` serialisation failure in [`SsgError::Io`]
44/// against the synthetic `<audit-report>` path.
45fn serialize_error(e: serde_json::Error) -> SsgError {
46    SsgError::Io {
47        path: std::path::PathBuf::from("<audit-report>"),
48        source: std::io::Error::other(e),
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use crate::audit::{Finding, GateResult, Severity, SeverityCounts};
56
57    // These two tests call `format()`, which contains the
58    // `audit::json-format` failpoint. The unkeyed `#[parallel]` joins
59    // the same default lock group as `fault_tests`' unkeyed
60    // `#[serial]` test below (and `cmd::audit`'s own fault test on the
61    // same failpoint), so the fault-injection window can never race
62    // with a normal call to `format()` running on another test thread.
63    #[test]
64    #[serial_test::parallel]
65    fn json_round_trips() {
66        let report = AuditReport {
67            gates: vec![GateResult {
68                name: "g".to_string(),
69                skipped: false,
70                skip_reason: None,
71                severity_counts: SeverityCounts {
72                    info: 0,
73                    warn: 1,
74                    error: 0,
75                },
76                findings: vec![Finding::new("g", Severity::Warn, "msg")
77                    .with_code("X")
78                    .with_path("a.html")],
79            }],
80        };
81        let s = format(&report).unwrap();
82        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
83        assert_eq!(v["gates"][0]["name"], "g");
84        assert_eq!(v["gates"][0]["severity_counts"]["warn"], 1);
85        assert_eq!(v["gates"][0]["findings"][0]["code"], "X");
86        assert_eq!(v["gates"][0]["findings"][0]["path"], "a.html");
87        assert_eq!(v["gates"][0]["findings"][0]["severity"], "warn");
88    }
89
90    #[test]
91    fn serialize_error_maps_to_io_variant() {
92        let e = serde_json::from_str::<serde_json::Value>("{")
93            .expect_err("truncated JSON must fail");
94        match serialize_error(e) {
95            SsgError::Io { path, source } => {
96                assert_eq!(path, std::path::PathBuf::from("<audit-report>"));
97                assert_eq!(source.kind(), std::io::ErrorKind::Other);
98            }
99            other => panic!("expected SsgError::Io, got {other:?}"),
100        }
101    }
102
103    #[test]
104    #[serial_test::parallel]
105    fn json_schema_has_stable_shape() {
106        let report = AuditReport { gates: vec![] };
107        let s = format(&report).unwrap();
108        assert!(s.contains("\"gates\""));
109    }
110}
111
112#[cfg(all(test, feature = "test-fault-injection"))]
113mod fault_tests {
114    use super::*;
115    use serial_test::serial;
116
117    /// RAII guard that disables a failpoint on drop.
118    struct FailGuard(&'static str);
119
120    impl Drop for FailGuard {
121        fn drop(&mut self) {
122            let _ = fail::cfg(self.0, "off");
123        }
124    }
125
126    // Unkeyed `#[serial]` (the default lock) pairs with
127    // `cmd::audit::fault_tests::json_output_propagates_serialize_error`,
128    // which reuses this same `audit::json-format` failpoint on the
129    // same unkeyed lock — see that test's doc comment.
130    #[test]
131    #[serial]
132    fn format_propagates_injected_io_error() {
133        let _guard = FailGuard("audit::json-format");
134        fail::cfg("audit::json-format", "return").expect("activate failpoint");
135
136        let report = AuditReport { gates: vec![] };
137        let err = format(&report).unwrap_err();
138        match err {
139            SsgError::Io { path, source } => {
140                assert_eq!(path, std::path::PathBuf::from("<audit-report>"));
141                assert_eq!(source.kind(), std::io::ErrorKind::Other);
142                assert!(
143                    format!("{source}").contains("audit::json-format"),
144                    "got: {source}"
145                );
146            }
147            other => panic!("expected SsgError::Io, got {other:?}"),
148        }
149    }
150}