Skip to main content

ssg/audit/
mod.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Native CI audit gates (issue #549).
5//!
6//! Exposes 15 [`AuditGate`] implementations that run locally and in CI
7//! to catch violations of WCAG, schema.org, hreflang reciprocity, CSP +
8//! SRI, PQC TLS readiness, HTML5 structure, broken links, OG metadata,
9//! markdown style, performance budgets, AI discovery files, RSS/Atom
10//! feeds, image optimisation, the localised semantic search index, and
11//! JSON-LD `inLanguage` vs `<html lang>` consistency.
12//!
13//! ## Surface
14//!
15//! ```rust,no_run
16//! use ssg::audit::{AuditRunner, AuditConfig, Site};
17//! use std::path::Path;
18//!
19//! let site = Site::load(Path::new("./public"))?;
20//! let cfg  = AuditConfig::default();
21//! let report = AuditRunner::new(cfg).run(&site);
22//! report.print_text();
23//! # Ok::<(), ssg::SsgError>(())
24//! ```
25//!
26//! Each gate ships with `name()`, `explain()`, and a `run(&Site)` that
27//! returns a `Vec<Finding>`. Findings carry a [`Severity`] so callers
28//! can filter or fail based on the configured `--fail-on` threshold.
29//!
30//! Some gates depend on other v0.0.44 epics (PQC on E6, AI discovery on
31//! E8, semantic search on E1). Those gates emit an *info* finding when
32//! their input files are absent rather than failing — they upgrade to
33//! enforcement once the producing epic merges.
34
35pub mod gates;
36pub mod output;
37
38use crate::error::{PathErrorExt, SsgError};
39use crate::walk::walk_files;
40use serde::{Deserialize, Serialize};
41use std::collections::BTreeSet;
42use std::fs;
43use std::path::{Path, PathBuf};
44
45// ---------------------------------------------------------------------
46// Severity
47// ---------------------------------------------------------------------
48
49/// Severity of an audit [`Finding`].
50///
51/// Comparison order is `Info < Warn < Error`, so `>=` filters work
52/// against a configured `--fail-on` threshold.
53#[derive(
54    Debug,
55    Clone,
56    Copy,
57    PartialEq,
58    Eq,
59    PartialOrd,
60    Ord,
61    Hash,
62    Serialize,
63    Deserialize,
64)]
65#[serde(rename_all = "lowercase")]
66pub enum Severity {
67    /// Informational note; does not affect exit code.
68    Info,
69    /// Soft violation; fails when `--fail-on warn` is set.
70    Warn,
71    /// Hard violation; fails by default.
72    Error,
73}
74
75impl Severity {
76    /// Returns the canonical lowercase name (`"info"`, `"warn"`, `"error"`).
77    ///
78    /// # Examples
79    ///
80    /// ```
81    /// use ssg::audit::Severity;
82    /// assert_eq!(Severity::Info.as_str(), "info");
83    /// assert_eq!(Severity::Warn.as_str(), "warn");
84    /// assert_eq!(Severity::Error.as_str(), "error");
85    /// ```
86    #[must_use]
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Self::Info => "info",
90            Self::Warn => "warn",
91            Self::Error => "error",
92        }
93    }
94
95    /// Parses a severity from its lowercase textual form. Accepts both
96    /// `"warn"` and `"warning"` for ergonomics.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use ssg::audit::Severity;
102    /// assert_eq!(Severity::parse("info"), Some(Severity::Info));
103    /// assert_eq!(Severity::parse("warning"), Some(Severity::Warn));
104    /// assert_eq!(Severity::parse("err"), Some(Severity::Error));
105    /// assert_eq!(Severity::parse("nope"), None);
106    /// ```
107    pub fn parse(s: &str) -> Option<Self> {
108        match s.to_ascii_lowercase().as_str() {
109            "info" => Some(Self::Info),
110            "warn" | "warning" => Some(Self::Warn),
111            "error" | "err" => Some(Self::Error),
112            _ => None,
113        }
114    }
115}
116
117impl std::fmt::Display for Severity {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.write_str(self.as_str())
120    }
121}
122
123// ---------------------------------------------------------------------
124// Finding
125// ---------------------------------------------------------------------
126
127/// A single audit finding produced by a gate.
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct Finding {
130    /// Identifier of the gate that produced the finding
131    /// (e.g. `"wcag"`, `"hreflang"`).
132    pub gate: String,
133    /// Severity of the finding.
134    pub severity: Severity,
135    /// Optional rule code (`"WCAG-1.1.1"`, `"OG-MISSING"`, …) for
136    /// downstream tooling to group by.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub code: Option<String>,
139    /// Human-readable message.
140    pub message: String,
141    /// Path the finding was raised against, relative to the site root.
142    /// `None` for site-wide findings (e.g. a missing manifest file).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub path: Option<String>,
145}
146
147impl Finding {
148    /// Convenience constructor for a path-scoped finding.
149    ///
150    /// # Examples
151    ///
152    /// ```
153    /// use ssg::audit::{Finding, Severity};
154    /// let f = Finding::new("wcag", Severity::Warn, "missing alt text");
155    /// assert_eq!(f.gate, "wcag");
156    /// assert_eq!(f.severity, Severity::Warn);
157    /// assert_eq!(f.message, "missing alt text");
158    /// assert!(f.code.is_none());
159    /// ```
160    pub fn new(
161        gate: impl Into<String>,
162        severity: Severity,
163        message: impl Into<String>,
164    ) -> Self {
165        Self {
166            gate: gate.into(),
167            severity,
168            code: None,
169            message: message.into(),
170            path: None,
171        }
172    }
173
174    /// Builder: attaches a rule code.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use ssg::audit::{Finding, Severity};
180    /// let f = Finding::new("wcag", Severity::Warn, "missing alt").with_code("WCAG-1.1.1");
181    /// assert_eq!(f.code.as_deref(), Some("WCAG-1.1.1"));
182    /// ```
183    #[must_use]
184    pub fn with_code(mut self, code: impl Into<String>) -> Self {
185        self.code = Some(code.into());
186        self
187    }
188
189    /// Builder: attaches a path.
190    ///
191    /// # Examples
192    ///
193    /// ```
194    /// use ssg::audit::{Finding, Severity};
195    /// let f = Finding::new("links", Severity::Error, "broken").with_path("blog/foo.html");
196    /// assert_eq!(f.path.as_deref(), Some("blog/foo.html"));
197    /// ```
198    #[must_use]
199    pub fn with_path(mut self, path: impl Into<String>) -> Self {
200        self.path = Some(path.into());
201        self
202    }
203}
204
205// ---------------------------------------------------------------------
206// Site
207// ---------------------------------------------------------------------
208
209/// A loaded view of a built site for audit gates to consume.
210///
211/// Walks the site directory once at construction time so each gate
212/// avoids redundant filesystem scans (the 15 gates would otherwise
213/// stat-walk the same tree 15 times).
214#[derive(Debug, Clone)]
215pub struct Site {
216    /// Root directory of the built site (the `public/` output dir).
217    pub root: PathBuf,
218    /// All `.html` files under the root, in directory-walk order.
219    pub html_files: Vec<PathBuf>,
220}
221
222impl Site {
223    /// Loads a site from `root`, walking it for HTML files.
224    ///
225    /// # Errors
226    /// Returns [`SsgError::Io`] if the directory walk fails.
227    ///
228    /// # Examples
229    ///
230    /// ```
231    /// use ssg::audit::Site;
232    /// let tmp = tempfile::tempdir().unwrap();
233    /// let site = Site::load(tmp.path()).unwrap();
234    /// assert_eq!(site.root, tmp.path());
235    /// assert!(site.html_files.is_empty());
236    /// ```
237    pub fn load(root: &Path) -> Result<Self, SsgError> {
238        let html_files = if root.exists() {
239            walk_files(root, "html").unwrap_or_default()
240        } else {
241            Vec::new()
242        };
243        Ok(Self {
244            root: root.to_path_buf(),
245            html_files,
246        })
247    }
248
249    /// Returns a relative path string for `path` against the site root,
250    /// always using `/` as the separator.
251    ///
252    /// The separator is normalised because these strings are compared against
253    /// URL paths taken from the HTML — `hreflang` targets, canonical hrefs,
254    /// sitemap entries. On Windows the native separator made
255    /// `en\index.html` the key while the document said `/en/index.html`, so
256    /// no reciprocal pair ever matched and the hreflang gate reported both a
257    /// false negative and a false positive. A path used as a URL has one
258    /// separator, whatever the filesystem underneath calls it.
259    ///
260    /// # Examples
261    ///
262    /// ```
263    /// use std::path::{Path, PathBuf};
264    /// use ssg::audit::Site;
265    /// let site = Site { root: PathBuf::from("/site"), html_files: Vec::new() };
266    /// assert_eq!(site.rel(Path::new("/site/blog/a.html")), "blog/a.html");
267    /// ```
268    #[must_use]
269    pub fn rel(&self, path: &Path) -> String {
270        let rel = path.strip_prefix(&self.root).unwrap_or(path);
271        // `MAIN_SEPARATOR` is `/` on unix, so this is a no-op there rather
272        // than a platform branch.
273        rel.to_string_lossy()
274            .replace(std::path::MAIN_SEPARATOR, "/")
275    }
276
277    /// Reads the contents of `path` as UTF-8.
278    ///
279    /// # Errors
280    /// Returns [`SsgError::Io`] if reading fails.
281    ///
282    /// # Examples
283    ///
284    /// ```
285    /// use std::path::PathBuf;
286    /// use ssg::audit::Site;
287    /// let tmp = tempfile::tempdir().unwrap();
288    /// let p = tmp.path().join("a.html");
289    /// std::fs::write(&p, "<html></html>").unwrap();
290    /// let site = Site { root: tmp.path().to_path_buf(), html_files: vec![p.clone()] };
291    /// assert_eq!(site.read(&p).unwrap(), "<html></html>");
292    /// ```
293    pub fn read(&self, path: &Path) -> Result<String, SsgError> {
294        fs::read_to_string(path).with_path(path)
295    }
296}
297
298// ---------------------------------------------------------------------
299// AuditGate trait
300// ---------------------------------------------------------------------
301
302/// Trait implemented by every audit gate.
303///
304/// Each gate is stateless and side-effect-free — it must not write
305/// anything to disk and must not hit the network unless explicitly
306/// asked to (see [`AuditOptions::skip_network`]).
307pub trait AuditGate: Sync + Send {
308    /// Short identifier (`snake_case`, used on `--gate <name>`).
309    fn name(&self) -> &'static str;
310
311    /// Long-form explainer printed by `ssg audit --explain --gate <name>`.
312    fn explain(&self) -> &'static str;
313
314    /// Runs the gate against `site` and returns its findings.
315    fn run(&self, site: &Site, opts: &AuditOptions) -> Vec<Finding>;
316}
317
318// ---------------------------------------------------------------------
319// AuditOptions / AuditConfig
320// ---------------------------------------------------------------------
321
322/// Runtime options passed to every gate at execution time.
323#[derive(Debug, Clone, Copy)]
324pub struct AuditOptions {
325    /// When `true`, gates that would otherwise issue HTTP requests
326    /// (only the broken-link gate today) must skip the network and
327    /// emit an info finding noting the skip.
328    pub skip_network: bool,
329    /// Page-weight budget (HTML + critical CSS) in bytes for the
330    /// performance gate.
331    pub page_weight_budget: usize,
332    /// Total JS budget in bytes for the performance gate.
333    pub js_budget: usize,
334    /// Image file-size budget in bytes for the image gate.
335    pub image_budget: usize,
336}
337
338impl Default for AuditOptions {
339    fn default() -> Self {
340        Self {
341            skip_network: true,
342            page_weight_budget: 100 * 1024,
343            js_budget: 50 * 1024,
344            image_budget: 250 * 1024,
345        }
346    }
347}
348
349/// User-facing configuration for an [`AuditRunner`].
350#[derive(Debug, Clone)]
351pub struct AuditConfig {
352    /// Gate identifiers to skip. Names not matching any registered
353    /// gate are silently ignored (so a deprecated gate name in
354    /// `ssg.toml` doesn't break the build).
355    pub disabled: BTreeSet<String>,
356    /// If `Some`, only the named gate runs (`--gate <name>`).
357    pub only: Option<String>,
358    /// Minimum severity to include in the report.
359    pub severity_floor: Severity,
360    /// Severity that triggers a non-zero exit code.
361    pub fail_on: Severity,
362    /// Runtime knobs forwarded to gates.
363    pub options: AuditOptions,
364}
365
366impl AuditConfig {
367    /// Sensible defaults: include everything, fail on `Error`.
368    ///
369    /// # Examples
370    ///
371    /// ```
372    /// use ssg::audit::{AuditConfig, Severity};
373    /// let cfg = AuditConfig::new();
374    /// assert_eq!(cfg.fail_on, Severity::Error);
375    /// assert!(cfg.disabled.is_empty());
376    /// assert!(cfg.only.is_none());
377    /// ```
378    #[must_use]
379    pub fn new() -> Self {
380        Self {
381            disabled: BTreeSet::new(),
382            only: None,
383            severity_floor: Severity::Info,
384            fail_on: Severity::Error,
385            options: AuditOptions::default(),
386        }
387    }
388}
389
390impl Default for AuditConfig {
391    fn default() -> Self {
392        Self::new()
393    }
394}
395
396// ---------------------------------------------------------------------
397// Reports
398// ---------------------------------------------------------------------
399
400/// Per-gate result block.
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct GateResult {
403    /// Gate identifier (matches [`AuditGate::name`]).
404    pub name: String,
405    /// `true` when the gate was disabled via config or `--gate` filter.
406    pub skipped: bool,
407    /// Reason for being skipped (only set when `skipped == true`).
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub skip_reason: Option<String>,
410    /// Severity counts for findings produced by the gate.
411    pub severity_counts: SeverityCounts,
412    /// Individual findings produced by the gate.
413    pub findings: Vec<Finding>,
414}
415
416/// Tally of severities for a gate result.
417#[derive(
418    Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq,
419)]
420pub struct SeverityCounts {
421    /// Number of `info` findings.
422    pub info: usize,
423    /// Number of `warn` findings.
424    pub warn: usize,
425    /// Number of `error` findings.
426    pub error: usize,
427}
428
429impl SeverityCounts {
430    /// Bumps the counter for `sev`.
431    ///
432    /// # Examples
433    ///
434    /// ```
435    /// use ssg::audit::{Severity, SeverityCounts};
436    /// let mut c = SeverityCounts::default();
437    /// c.add(Severity::Warn);
438    /// c.add(Severity::Warn);
439    /// assert_eq!(c.warn, 2);
440    /// ```
441    pub const fn add(&mut self, sev: Severity) {
442        match sev {
443            Severity::Info => self.info += 1,
444            Severity::Warn => self.warn += 1,
445            Severity::Error => self.error += 1,
446        }
447    }
448
449    /// Total findings across severities.
450    ///
451    /// # Examples
452    ///
453    /// ```
454    /// use ssg::audit::{Severity, SeverityCounts};
455    /// let mut c = SeverityCounts::default();
456    /// c.add(Severity::Info);
457    /// c.add(Severity::Error);
458    /// assert_eq!(c.total(), 2);
459    /// ```
460    #[must_use]
461    pub const fn total(&self) -> usize {
462        self.info + self.warn + self.error
463    }
464}
465
466/// Aggregate audit report.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct AuditReport {
469    /// Per-gate results in registration order.
470    pub gates: Vec<GateResult>,
471}
472
473impl AuditReport {
474    /// Returns the highest severity present across all gates, or `None`
475    /// if no findings were produced.
476    ///
477    /// # Examples
478    ///
479    /// ```
480    /// use ssg::audit::AuditReport;
481    /// let r = AuditReport { gates: vec![] };
482    /// assert!(r.max_severity().is_none());
483    /// ```
484    #[must_use]
485    pub fn max_severity(&self) -> Option<Severity> {
486        let mut max: Option<Severity> = None;
487        for g in &self.gates {
488            if g.severity_counts.error > 0 {
489                return Some(Severity::Error);
490            }
491            if g.severity_counts.warn > 0 {
492                max =
493                    Some(max.map_or(Severity::Warn, |m| m.max(Severity::Warn)));
494            } else if g.severity_counts.info > 0 {
495                max =
496                    Some(max.map_or(Severity::Info, |m| m.max(Severity::Info)));
497            }
498        }
499        max
500    }
501
502    /// Returns `true` when the report contains at least one finding at
503    /// `fail_on` or higher.
504    ///
505    /// # Examples
506    ///
507    /// ```
508    /// use ssg::audit::{AuditReport, Severity};
509    /// let r = AuditReport { gates: vec![] };
510    /// assert!(!r.should_fail(Severity::Error));
511    /// ```
512    #[must_use]
513    pub fn should_fail(&self, fail_on: Severity) -> bool {
514        self.max_severity().is_some_and(|sev| sev >= fail_on)
515    }
516
517    /// Total number of registered gates (skipped or not).
518    ///
519    /// # Examples
520    ///
521    /// ```
522    /// use ssg::audit::AuditReport;
523    /// let r = AuditReport { gates: vec![] };
524    /// assert_eq!(r.len(), 0);
525    /// ```
526    #[must_use]
527    pub const fn len(&self) -> usize {
528        self.gates.len()
529    }
530
531    /// `true` when no gates ran.
532    ///
533    /// # Examples
534    ///
535    /// ```
536    /// use ssg::audit::AuditReport;
537    /// let r = AuditReport { gates: vec![] };
538    /// assert!(r.is_empty());
539    /// ```
540    #[must_use]
541    pub const fn is_empty(&self) -> bool {
542        self.gates.is_empty()
543    }
544
545    /// Convenience: prints the rich text formatter to stdout.
546    ///
547    /// # Examples
548    ///
549    /// ```
550    /// use ssg::audit::AuditReport;
551    /// let r = AuditReport { gates: vec![] };
552    /// r.print_text(); // emits nothing for an empty report
553    /// ```
554    pub fn print_text(&self) {
555        let mut out = String::new();
556        output::text::format(self, &mut out);
557        print!("{out}");
558    }
559
560    /// Convenience: prints the JSON formatter to stdout.
561    ///
562    /// # Errors
563    /// Propagates any JSON serialisation error from
564    /// [`crate::audit::output::json::format`].
565    ///
566    /// # Examples
567    ///
568    /// ```
569    /// use ssg::audit::AuditReport;
570    /// let r = AuditReport { gates: vec![] };
571    /// r.print_json().unwrap();
572    /// ```
573    pub fn print_json(&self) -> Result<(), SsgError> {
574        let s = output::json::format(self)?;
575        println!("{s}");
576        Ok(())
577    }
578
579    /// Convenience: prints the `JUnit` XML formatter to stdout.
580    ///
581    /// # Examples
582    ///
583    /// ```
584    /// use ssg::audit::AuditReport;
585    /// let r = AuditReport { gates: vec![] };
586    /// r.print_junit();
587    /// ```
588    pub fn print_junit(&self) {
589        let s = output::junit::format(self);
590        println!("{s}");
591    }
592
593    /// Convenience: prints the SARIF v2.1.0 formatter to stdout (#562).
594    ///
595    /// # Examples
596    ///
597    /// ```
598    /// use ssg::audit::AuditReport;
599    /// let r = AuditReport { gates: vec![] };
600    /// r.print_sarif();
601    /// ```
602    pub fn print_sarif(&self) {
603        let s = output::sarif::format(self);
604        println!("{s}");
605    }
606}
607
608// ---------------------------------------------------------------------
609// AuditRunner
610// ---------------------------------------------------------------------
611
612/// Orchestrates dispatch across the 15 registered gates.
613pub struct AuditRunner {
614    config: AuditConfig,
615    gates: Vec<Box<dyn AuditGate>>,
616}
617
618impl std::fmt::Debug for AuditRunner {
619    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
620        f.debug_struct("AuditRunner")
621            .field("config", &self.config)
622            .field(
623                "gates",
624                &self.gates.iter().map(|g| g.name()).collect::<Vec<_>>(),
625            )
626            .finish()
627    }
628}
629
630impl AuditRunner {
631    /// Constructs a runner with the 15 built-in gates registered.
632    ///
633    /// # Examples
634    ///
635    /// ```
636    /// use ssg::audit::{AuditConfig, AuditRunner};
637    /// let r = AuditRunner::new(AuditConfig::new());
638    /// assert_eq!(r.gate_names().len(), 15);
639    /// ```
640    #[must_use]
641    pub fn new(config: AuditConfig) -> Self {
642        Self {
643            config,
644            gates: gates::all(),
645        }
646    }
647
648    /// Constructs a runner with a user-supplied gate list (for tests).
649    ///
650    /// # Examples
651    ///
652    /// ```
653    /// use ssg::audit::{AuditConfig, AuditRunner};
654    /// let r = AuditRunner::with_gates(AuditConfig::new(), Vec::new());
655    /// assert!(r.gate_names().is_empty());
656    /// ```
657    #[must_use]
658    pub fn with_gates(
659        config: AuditConfig,
660        gates: Vec<Box<dyn AuditGate>>,
661    ) -> Self {
662        Self { config, gates }
663    }
664
665    /// Returns the names of every gate registered with the runner.
666    ///
667    /// # Examples
668    ///
669    /// ```
670    /// use ssg::audit::{AuditConfig, AuditRunner};
671    /// let r = AuditRunner::new(AuditConfig::new());
672    /// assert!(r.gate_names().contains(&"wcag"));
673    /// ```
674    #[must_use]
675    pub fn gate_names(&self) -> Vec<&'static str> {
676        self.gates.iter().map(|g| g.name()).collect()
677    }
678
679    /// Runs every (enabled) gate sequentially and collects the result.
680    ///
681    /// # Examples
682    ///
683    /// ```
684    /// use std::path::PathBuf;
685    /// use ssg::audit::{AuditConfig, AuditRunner, Site};
686    /// let runner = AuditRunner::new(AuditConfig::new());
687    /// let site = Site { root: PathBuf::from("/nonexistent"), html_files: Vec::new() };
688    /// let report = runner.run(&site);
689    /// assert_eq!(report.len(), 15);
690    /// ```
691    #[must_use]
692    pub fn run(&self, site: &Site) -> AuditReport {
693        let mut results = Vec::with_capacity(self.gates.len());
694        for gate in &self.gates {
695            let name = gate.name();
696
697            // Single-gate filter (`--gate <name>`).
698            if let Some(ref only) = self.config.only {
699                if only != name {
700                    results.push(GateResult {
701                        name: name.to_string(),
702                        skipped: true,
703                        skip_reason: Some(format!(
704                            "not selected (--gate {only})"
705                        )),
706                        severity_counts: SeverityCounts::default(),
707                        findings: Vec::new(),
708                    });
709                    continue;
710                }
711            }
712
713            // Disabled in config.
714            if self.config.disabled.contains(name) {
715                results.push(GateResult {
716                    name: name.to_string(),
717                    skipped: true,
718                    skip_reason: Some(
719                        "disabled by ssg.toml [audit.disabled]".to_string(),
720                    ),
721                    severity_counts: SeverityCounts::default(),
722                    findings: Vec::new(),
723                });
724                continue;
725            }
726
727            // Run.
728            let findings = gate.run(site, &self.config.options);
729
730            // Apply severity floor.
731            let filtered: Vec<Finding> = findings
732                .into_iter()
733                .filter(|f| f.severity >= self.config.severity_floor)
734                .collect();
735
736            let mut counts = SeverityCounts::default();
737            for f in &filtered {
738                counts.add(f.severity);
739            }
740
741            results.push(GateResult {
742                name: name.to_string(),
743                skipped: false,
744                skip_reason: None,
745                severity_counts: counts,
746                findings: filtered,
747            });
748        }
749        AuditReport { gates: results }
750    }
751
752    /// Returns the configured `fail_on` threshold.
753    ///
754    /// # Examples
755    ///
756    /// ```
757    /// use ssg::audit::{AuditConfig, AuditRunner, Severity};
758    /// let r = AuditRunner::new(AuditConfig::new());
759    /// assert_eq!(r.fail_on(), Severity::Error);
760    /// ```
761    #[must_use]
762    pub const fn fail_on(&self) -> Severity {
763        self.config.fail_on
764    }
765}
766
767// ---------------------------------------------------------------------
768// Audit config (ssg.toml [audit] section)
769// ---------------------------------------------------------------------
770
771/// Schema for the `[audit]` table in `ssg.toml`.
772///
773/// All fields are optional and absent fields fall back to
774/// [`AuditConfig::new`] defaults.
775#[derive(Debug, Clone, Default, Serialize, Deserialize)]
776pub struct AuditTomlConfig {
777    /// Gate identifiers to skip. Mirrors
778    /// `[audit.disabled] gates = ["markdownlint"]` in `ssg.toml`.
779    #[serde(default)]
780    pub disabled: AuditDisabledSection,
781    /// Performance budgets.
782    #[serde(default)]
783    pub budgets: AuditBudgets,
784}
785
786/// `[audit.disabled]` subsection.
787#[derive(Debug, Clone, Default, Serialize, Deserialize)]
788pub struct AuditDisabledSection {
789    /// Names of gates to skip at audit time.
790    #[serde(default)]
791    pub gates: Vec<String>,
792}
793
794/// `[audit.budgets]` subsection.
795#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
796pub struct AuditBudgets {
797    /// HTML + critical CSS budget (bytes).
798    #[serde(default = "default_page_weight_budget")]
799    pub page_weight_bytes: usize,
800    /// Total JS budget (bytes).
801    #[serde(default = "default_js_budget")]
802    pub js_bytes: usize,
803    /// Per-image size budget (bytes).
804    #[serde(default = "default_image_budget")]
805    pub image_bytes: usize,
806}
807
808impl Default for AuditBudgets {
809    fn default() -> Self {
810        Self {
811            page_weight_bytes: default_page_weight_budget(),
812            js_bytes: default_js_budget(),
813            image_bytes: default_image_budget(),
814        }
815    }
816}
817
818const fn default_page_weight_budget() -> usize {
819    100 * 1024
820}
821
822const fn default_js_budget() -> usize {
823    50 * 1024
824}
825
826const fn default_image_budget() -> usize {
827    250 * 1024
828}
829
830impl AuditTomlConfig {
831    /// Merges the TOML config into a default [`AuditConfig`] and
832    /// returns the result.
833    ///
834    /// # Examples
835    ///
836    /// ```
837    /// use ssg::audit::AuditTomlConfig;
838    /// let toml_cfg = AuditTomlConfig::default();
839    /// let cfg = toml_cfg.into_audit_config();
840    /// assert!(cfg.disabled.is_empty());
841    /// ```
842    #[must_use]
843    pub fn into_audit_config(self) -> AuditConfig {
844        let mut cfg = AuditConfig::new();
845        cfg.disabled.extend(self.disabled.gates);
846        cfg.options.page_weight_budget = self.budgets.page_weight_bytes;
847        cfg.options.js_budget = self.budgets.js_bytes;
848        cfg.options.image_budget = self.budgets.image_bytes;
849        cfg
850    }
851}
852
853#[cfg(test)]
854mod tests {
855
856    /// `rel` is compared against URL paths from the HTML, so it must use `/`
857    /// on every platform.
858    ///
859    /// The hreflang gate keys its reciprocity index on this value and looks
860    /// it up by the `href` written in the document. On Windows the native
861    /// separator produced `en\\index.html` against a document saying
862    /// `/en/index.html`, so `hreflang_positive_reciprocal_pair_is_clean` and
863    /// `hreflang_negative_missing_reciprocal_flagged` both failed — and had
864    /// been failing unnoticed, because `tests/audit_gates.rs` was one of the
865    /// 29 targets CI never ran.
866    #[test]
867    fn rel_always_uses_forward_slashes() {
868        use std::path::PathBuf;
869        let root = PathBuf::from("site");
870        let site = Site {
871            root: root.clone(),
872            html_files: Vec::new(),
873        };
874
875        // Built with `join` so the input carries the platform separator.
876        let nested = root.join("en").join("index.html");
877        assert_eq!(site.rel(&nested), "en/index.html");
878        assert!(
879            !site.rel(&nested).contains('\\'),
880            "a backslash survived into a URL-compared path"
881        );
882    }
883    use super::*;
884
885    #[test]
886    fn severity_ordering_is_info_warn_error() {
887        assert!(Severity::Info < Severity::Warn);
888        assert!(Severity::Warn < Severity::Error);
889    }
890
891    #[test]
892    fn severity_parse_round_trip() {
893        for sev in [Severity::Info, Severity::Warn, Severity::Error] {
894            assert_eq!(Severity::parse(sev.as_str()), Some(sev));
895        }
896        assert_eq!(Severity::parse("warning"), Some(Severity::Warn));
897        assert_eq!(Severity::parse("err"), Some(Severity::Error));
898        assert_eq!(Severity::parse("nope"), None);
899    }
900
901    #[test]
902    fn severity_display_matches_as_str() {
903        assert_eq!(format!("{}", Severity::Info), "info");
904        assert_eq!(format!("{}", Severity::Warn), "warn");
905        assert_eq!(format!("{}", Severity::Error), "error");
906    }
907
908    #[test]
909    fn finding_builders_attach_optional_fields() {
910        let f = Finding::new("g", Severity::Warn, "msg")
911            .with_code("CODE")
912            .with_path("a/b.html");
913        assert_eq!(f.code.as_deref(), Some("CODE"));
914        assert_eq!(f.path.as_deref(), Some("a/b.html"));
915    }
916
917    #[test]
918    fn severity_counts_total_and_add() {
919        let mut c = SeverityCounts::default();
920        c.add(Severity::Info);
921        c.add(Severity::Warn);
922        c.add(Severity::Warn);
923        c.add(Severity::Error);
924        assert_eq!(c.total(), 4);
925        assert_eq!(c.info, 1);
926        assert_eq!(c.warn, 2);
927        assert_eq!(c.error, 1);
928    }
929
930    #[test]
931    fn audit_runner_registers_fifteen_gates() {
932        let r = AuditRunner::new(AuditConfig::new());
933        assert_eq!(r.gate_names().len(), 15, "must register exactly 15 gates");
934    }
935
936    #[test]
937    fn audit_runner_gate_filter_skips_others() {
938        let r = AuditRunner::new(AuditConfig {
939            only: Some("hreflang".to_string()),
940            ..AuditConfig::new()
941        });
942        let site = Site {
943            root: PathBuf::from("/nonexistent"),
944            html_files: Vec::new(),
945        };
946        let report = r.run(&site);
947        let executed: Vec<_> =
948            report.gates.iter().filter(|g| !g.skipped).collect();
949        assert_eq!(executed.len(), 1);
950        assert_eq!(executed[0].name, "hreflang");
951    }
952
953    #[test]
954    fn audit_runner_disabled_gate_records_skip_reason() {
955        let mut cfg = AuditConfig::new();
956        let _ = cfg.disabled.insert("markdownlint".to_string());
957        let r = AuditRunner::new(cfg);
958        let site = Site {
959            root: PathBuf::from("/nonexistent"),
960            html_files: Vec::new(),
961        };
962        let report = r.run(&site);
963        let md = report
964            .gates
965            .iter()
966            .find(|g| g.name == "markdownlint")
967            .expect("markdownlint gate registered");
968        assert!(md.skipped);
969        assert!(md
970            .skip_reason
971            .as_deref()
972            .unwrap_or_default()
973            .contains("disabled"));
974    }
975
976    #[test]
977    fn audit_toml_config_parses_disabled_and_budgets() {
978        let toml_src = r#"
979            [disabled]
980            gates = ["markdownlint", "links"]
981            [budgets]
982            page_weight_bytes = 200000
983            js_bytes = 20000
984            image_bytes = 100000
985        "#;
986        let parsed: AuditTomlConfig = toml::from_str(toml_src).unwrap();
987        let cfg = parsed.into_audit_config();
988        assert!(cfg.disabled.contains("markdownlint"));
989        assert!(cfg.disabled.contains("links"));
990        assert_eq!(cfg.options.page_weight_budget, 200_000);
991        assert_eq!(cfg.options.js_budget, 20_000);
992        assert_eq!(cfg.options.image_budget, 100_000);
993    }
994
995    #[test]
996    fn audit_toml_config_uses_defaults_when_empty() {
997        let cfg: AuditTomlConfig = toml::from_str("").unwrap();
998        let merged = cfg.into_audit_config();
999        assert!(merged.disabled.is_empty());
1000        assert_eq!(merged.options.page_weight_budget, 100 * 1024);
1001        assert_eq!(merged.options.js_budget, 50 * 1024);
1002        assert_eq!(merged.options.image_budget, 250 * 1024);
1003    }
1004
1005    #[test]
1006    fn report_should_fail_compares_against_fail_on() {
1007        let report = AuditReport {
1008            gates: vec![GateResult {
1009                name: "x".to_string(),
1010                skipped: false,
1011                skip_reason: None,
1012                severity_counts: SeverityCounts {
1013                    info: 0,
1014                    warn: 1,
1015                    error: 0,
1016                },
1017                findings: vec![Finding::new("x", Severity::Warn, "m")],
1018            }],
1019        };
1020        assert!(report.should_fail(Severity::Warn));
1021        assert!(!report.should_fail(Severity::Error));
1022    }
1023
1024    #[test]
1025    fn report_max_severity_accumulates_across_multiple_warn_and_info_gates() {
1026        // Drives the `Some(m) => m.max(...)` arm of both `map_or`
1027        // closures in `max_severity` — a single gate never re-enters
1028        // the accumulator, so at least two non-error gates (in either
1029        // order) are required to exercise the closure bodies.
1030        let report = AuditReport {
1031            gates: vec![
1032                GateResult {
1033                    name: "a".to_string(),
1034                    skipped: false,
1035                    skip_reason: None,
1036                    severity_counts: SeverityCounts {
1037                        info: 1,
1038                        warn: 0,
1039                        error: 0,
1040                    },
1041                    findings: vec![],
1042                },
1043                GateResult {
1044                    name: "b".to_string(),
1045                    skipped: false,
1046                    skip_reason: None,
1047                    severity_counts: SeverityCounts {
1048                        info: 0,
1049                        warn: 1,
1050                        error: 0,
1051                    },
1052                    findings: vec![],
1053                },
1054                GateResult {
1055                    name: "c".to_string(),
1056                    skipped: false,
1057                    skip_reason: None,
1058                    severity_counts: SeverityCounts {
1059                        info: 1,
1060                        warn: 0,
1061                        error: 0,
1062                    },
1063                    findings: vec![],
1064                },
1065            ],
1066        };
1067        assert_eq!(report.max_severity(), Some(Severity::Warn));
1068    }
1069
1070    #[test]
1071    fn report_max_severity_returns_highest() {
1072        let report = AuditReport {
1073            gates: vec![
1074                GateResult {
1075                    name: "a".to_string(),
1076                    skipped: false,
1077                    skip_reason: None,
1078                    severity_counts: SeverityCounts {
1079                        info: 2,
1080                        warn: 0,
1081                        error: 0,
1082                    },
1083                    findings: vec![],
1084                },
1085                GateResult {
1086                    name: "b".to_string(),
1087                    skipped: false,
1088                    skip_reason: None,
1089                    severity_counts: SeverityCounts {
1090                        info: 0,
1091                        warn: 0,
1092                        error: 1,
1093                    },
1094                    findings: vec![],
1095                },
1096            ],
1097        };
1098        assert_eq!(report.max_severity(), Some(Severity::Error));
1099    }
1100
1101    #[test]
1102    fn site_load_nonexistent_returns_empty_html_files() {
1103        // Covers line 240 — `Vec::new()` arm when root doesn't exist.
1104        let site = Site::load(Path::new("/nonexistent/xxx-audit")).unwrap();
1105        assert!(site.html_files.is_empty());
1106    }
1107
1108    #[test]
1109    fn audit_config_default_is_new() {
1110        // Covers lines 381-383.
1111        let cfg = AuditConfig::default();
1112        assert_eq!(cfg.severity_floor, AuditConfig::new().severity_floor);
1113    }
1114
1115    #[test]
1116    fn audit_report_len_and_is_empty_align() {
1117        // Covers lines 517-519 (len) + 531-533 (is_empty).
1118        let r0 = AuditReport { gates: vec![] };
1119        assert_eq!(r0.len(), 0);
1120        assert!(r0.is_empty());
1121        let r1 = AuditReport {
1122            gates: vec![GateResult {
1123                name: "g".into(),
1124                skipped: false,
1125                skip_reason: None,
1126                severity_counts: SeverityCounts {
1127                    info: 0,
1128                    warn: 0,
1129                    error: 0,
1130                },
1131                findings: vec![],
1132            }],
1133        };
1134        assert_eq!(r1.len(), 1);
1135        assert!(!r1.is_empty());
1136    }
1137
1138    #[test]
1139    fn audit_runner_debug_lists_gate_names() {
1140        // Covers lines 595-603 — Debug impl for AuditRunner.
1141        let runner = AuditRunner::new(AuditConfig::new());
1142        let dbg = format!("{runner:?}");
1143        assert!(dbg.contains("AuditRunner"));
1144        assert!(dbg.contains("gates"));
1145    }
1146
1147    #[test]
1148    fn audit_runner_with_gates_accepts_empty_vec() {
1149        // Covers lines 634-639 — with_gates constructor.
1150        let runner = AuditRunner::with_gates(AuditConfig::new(), Vec::new());
1151        assert!(runner.gate_names().is_empty());
1152    }
1153
1154    #[test]
1155    fn audit_report_print_sarif_emits_to_stdout() {
1156        // Covers AuditReport::print_sarif body (lines 592-595).
1157        // The doctest under #[doc] doesn't get credited toward
1158        // `cargo llvm-cov --tests` coverage on stable; this unit
1159        // test does. We don't assert on stdout content — just that
1160        // the call runs to completion without panicking.
1161        let r = AuditReport { gates: vec![] };
1162        r.print_sarif();
1163    }
1164}