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}