pub mod gates;
pub mod output;
use crate::error::{PathErrorExt, SsgError};
use crate::walk::walk_files;
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warn,
Error,
}
impl Severity {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warn => "warn",
Self::Error => "error",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"info" => Some(Self::Info),
"warn" | "warning" => Some(Self::Warn),
"error" | "err" => Some(Self::Error),
_ => None,
}
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub gate: String,
pub severity: Severity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
impl Finding {
pub fn new(
gate: impl Into<String>,
severity: Severity,
message: impl Into<String>,
) -> Self {
Self {
gate: gate.into(),
severity,
code: None,
message: message.into(),
path: None,
}
}
#[must_use]
pub fn with_code(mut self, code: impl Into<String>) -> Self {
self.code = Some(code.into());
self
}
#[must_use]
pub fn with_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
}
#[derive(Debug, Clone)]
pub struct Site {
pub root: PathBuf,
pub html_files: Vec<PathBuf>,
}
impl Site {
pub fn load(root: &Path) -> Result<Self, SsgError> {
let html_files = if root.exists() {
walk_files(root, "html").unwrap_or_default()
} else {
Vec::new()
};
Ok(Self {
root: root.to_path_buf(),
html_files,
})
}
#[must_use]
pub fn rel(&self, path: &Path) -> String {
path.strip_prefix(&self.root)
.unwrap_or(path)
.to_string_lossy()
.into_owned()
}
pub fn read(&self, path: &Path) -> Result<String, SsgError> {
fs::read_to_string(path).with_path(path)
}
}
pub trait AuditGate: Sync + Send {
fn name(&self) -> &'static str;
fn explain(&self) -> &'static str;
fn run(&self, site: &Site, opts: &AuditOptions) -> Vec<Finding>;
}
#[derive(Debug, Clone, Copy)]
pub struct AuditOptions {
pub skip_network: bool,
pub page_weight_budget: usize,
pub js_budget: usize,
pub image_budget: usize,
}
impl Default for AuditOptions {
fn default() -> Self {
Self {
skip_network: true,
page_weight_budget: 100 * 1024,
js_budget: 50 * 1024,
image_budget: 250 * 1024,
}
}
}
#[derive(Debug, Clone)]
pub struct AuditConfig {
pub disabled: BTreeSet<String>,
pub only: Option<String>,
pub severity_floor: Severity,
pub fail_on: Severity,
pub options: AuditOptions,
}
impl AuditConfig {
#[must_use]
pub fn new() -> Self {
Self {
disabled: BTreeSet::new(),
only: None,
severity_floor: Severity::Info,
fail_on: Severity::Error,
options: AuditOptions::default(),
}
}
}
impl Default for AuditConfig {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GateResult {
pub name: String,
pub skipped: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_reason: Option<String>,
pub severity_counts: SeverityCounts,
pub findings: Vec<Finding>,
}
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq,
)]
pub struct SeverityCounts {
pub info: usize,
pub warn: usize,
pub error: usize,
}
impl SeverityCounts {
pub const fn add(&mut self, sev: Severity) {
match sev {
Severity::Info => self.info += 1,
Severity::Warn => self.warn += 1,
Severity::Error => self.error += 1,
}
}
#[must_use]
pub const fn total(&self) -> usize {
self.info + self.warn + self.error
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditReport {
pub gates: Vec<GateResult>,
}
impl AuditReport {
#[must_use]
pub fn max_severity(&self) -> Option<Severity> {
let mut max: Option<Severity> = None;
for g in &self.gates {
if g.severity_counts.error > 0 {
return Some(Severity::Error);
}
if g.severity_counts.warn > 0 {
max =
Some(max.map_or(Severity::Warn, |m| m.max(Severity::Warn)));
} else if g.severity_counts.info > 0 {
max =
Some(max.map_or(Severity::Info, |m| m.max(Severity::Info)));
}
}
max
}
#[must_use]
pub fn should_fail(&self, fail_on: Severity) -> bool {
self.max_severity().is_some_and(|sev| sev >= fail_on)
}
#[must_use]
pub const fn len(&self) -> usize {
self.gates.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.gates.is_empty()
}
pub fn print_text(&self) {
let mut out = String::new();
output::text::format(self, &mut out);
print!("{out}");
}
pub fn print_json(&self) -> Result<(), SsgError> {
let s = output::json::format(self)?;
println!("{s}");
Ok(())
}
pub fn print_junit(&self) {
let s = output::junit::format(self);
println!("{s}");
}
pub fn print_sarif(&self) {
let s = output::sarif::format(self);
println!("{s}");
}
}
pub struct AuditRunner {
config: AuditConfig,
gates: Vec<Box<dyn AuditGate>>,
}
impl std::fmt::Debug for AuditRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuditRunner")
.field("config", &self.config)
.field(
"gates",
&self.gates.iter().map(|g| g.name()).collect::<Vec<_>>(),
)
.finish()
}
}
impl AuditRunner {
#[must_use]
pub fn new(config: AuditConfig) -> Self {
Self {
config,
gates: gates::all(),
}
}
#[must_use]
pub fn with_gates(
config: AuditConfig,
gates: Vec<Box<dyn AuditGate>>,
) -> Self {
Self { config, gates }
}
#[must_use]
pub fn gate_names(&self) -> Vec<&'static str> {
self.gates.iter().map(|g| g.name()).collect()
}
#[must_use]
pub fn run(&self, site: &Site) -> AuditReport {
let mut results = Vec::with_capacity(self.gates.len());
for gate in &self.gates {
let name = gate.name();
if let Some(ref only) = self.config.only {
if only != name {
results.push(GateResult {
name: name.to_string(),
skipped: true,
skip_reason: Some(format!(
"not selected (--gate {only})"
)),
severity_counts: SeverityCounts::default(),
findings: Vec::new(),
});
continue;
}
}
if self.config.disabled.contains(name) {
results.push(GateResult {
name: name.to_string(),
skipped: true,
skip_reason: Some(
"disabled by ssg.toml [audit.disabled]".to_string(),
),
severity_counts: SeverityCounts::default(),
findings: Vec::new(),
});
continue;
}
let findings = gate.run(site, &self.config.options);
let filtered: Vec<Finding> = findings
.into_iter()
.filter(|f| f.severity >= self.config.severity_floor)
.collect();
let mut counts = SeverityCounts::default();
for f in &filtered {
counts.add(f.severity);
}
results.push(GateResult {
name: name.to_string(),
skipped: false,
skip_reason: None,
severity_counts: counts,
findings: filtered,
});
}
AuditReport { gates: results }
}
#[must_use]
pub const fn fail_on(&self) -> Severity {
self.config.fail_on
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditTomlConfig {
#[serde(default)]
pub disabled: AuditDisabledSection,
#[serde(default)]
pub budgets: AuditBudgets,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuditDisabledSection {
#[serde(default)]
pub gates: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct AuditBudgets {
#[serde(default = "default_page_weight_budget")]
pub page_weight_bytes: usize,
#[serde(default = "default_js_budget")]
pub js_bytes: usize,
#[serde(default = "default_image_budget")]
pub image_bytes: usize,
}
impl Default for AuditBudgets {
fn default() -> Self {
Self {
page_weight_bytes: default_page_weight_budget(),
js_bytes: default_js_budget(),
image_bytes: default_image_budget(),
}
}
}
const fn default_page_weight_budget() -> usize {
100 * 1024
}
const fn default_js_budget() -> usize {
50 * 1024
}
const fn default_image_budget() -> usize {
250 * 1024
}
impl AuditTomlConfig {
#[must_use]
pub fn into_audit_config(self) -> AuditConfig {
let mut cfg = AuditConfig::new();
cfg.disabled.extend(self.disabled.gates);
cfg.options.page_weight_budget = self.budgets.page_weight_bytes;
cfg.options.js_budget = self.budgets.js_bytes;
cfg.options.image_budget = self.budgets.image_bytes;
cfg
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn severity_ordering_is_info_warn_error() {
assert!(Severity::Info < Severity::Warn);
assert!(Severity::Warn < Severity::Error);
}
#[test]
fn severity_parse_round_trip() {
for sev in [Severity::Info, Severity::Warn, Severity::Error] {
assert_eq!(Severity::parse(sev.as_str()), Some(sev));
}
assert_eq!(Severity::parse("warning"), Some(Severity::Warn));
assert_eq!(Severity::parse("err"), Some(Severity::Error));
assert_eq!(Severity::parse("nope"), None);
}
#[test]
fn severity_display_matches_as_str() {
assert_eq!(format!("{}", Severity::Info), "info");
assert_eq!(format!("{}", Severity::Warn), "warn");
assert_eq!(format!("{}", Severity::Error), "error");
}
#[test]
fn finding_builders_attach_optional_fields() {
let f = Finding::new("g", Severity::Warn, "msg")
.with_code("CODE")
.with_path("a/b.html");
assert_eq!(f.code.as_deref(), Some("CODE"));
assert_eq!(f.path.as_deref(), Some("a/b.html"));
}
#[test]
fn severity_counts_total_and_add() {
let mut c = SeverityCounts::default();
c.add(Severity::Info);
c.add(Severity::Warn);
c.add(Severity::Warn);
c.add(Severity::Error);
assert_eq!(c.total(), 4);
assert_eq!(c.info, 1);
assert_eq!(c.warn, 2);
assert_eq!(c.error, 1);
}
#[test]
fn audit_runner_registers_fourteen_gates() {
let r = AuditRunner::new(AuditConfig::new());
assert_eq!(r.gate_names().len(), 14, "must register exactly 14 gates");
}
#[test]
fn audit_runner_gate_filter_skips_others() {
let r = AuditRunner::new(AuditConfig {
only: Some("hreflang".to_string()),
..AuditConfig::new()
});
let site = Site {
root: PathBuf::from("/nonexistent"),
html_files: Vec::new(),
};
let report = r.run(&site);
let executed: Vec<_> =
report.gates.iter().filter(|g| !g.skipped).collect();
assert_eq!(executed.len(), 1);
assert_eq!(executed[0].name, "hreflang");
}
#[test]
fn audit_runner_disabled_gate_records_skip_reason() {
let mut cfg = AuditConfig::new();
let _ = cfg.disabled.insert("markdownlint".to_string());
let r = AuditRunner::new(cfg);
let site = Site {
root: PathBuf::from("/nonexistent"),
html_files: Vec::new(),
};
let report = r.run(&site);
let md = report
.gates
.iter()
.find(|g| g.name == "markdownlint")
.expect("markdownlint gate registered");
assert!(md.skipped);
assert!(md
.skip_reason
.as_deref()
.unwrap_or_default()
.contains("disabled"));
}
#[test]
fn audit_toml_config_parses_disabled_and_budgets() {
let toml_src = r#"
[disabled]
gates = ["markdownlint", "links"]
[budgets]
page_weight_bytes = 200000
js_bytes = 20000
image_bytes = 100000
"#;
let parsed: AuditTomlConfig = toml::from_str(toml_src).unwrap();
let cfg = parsed.into_audit_config();
assert!(cfg.disabled.contains("markdownlint"));
assert!(cfg.disabled.contains("links"));
assert_eq!(cfg.options.page_weight_budget, 200_000);
assert_eq!(cfg.options.js_budget, 20_000);
assert_eq!(cfg.options.image_budget, 100_000);
}
#[test]
fn audit_toml_config_uses_defaults_when_empty() {
let cfg: AuditTomlConfig = toml::from_str("").unwrap();
let merged = cfg.into_audit_config();
assert!(merged.disabled.is_empty());
assert_eq!(merged.options.page_weight_budget, 100 * 1024);
assert_eq!(merged.options.js_budget, 50 * 1024);
assert_eq!(merged.options.image_budget, 250 * 1024);
}
#[test]
fn report_should_fail_compares_against_fail_on() {
let report = AuditReport {
gates: vec![GateResult {
name: "x".to_string(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts {
info: 0,
warn: 1,
error: 0,
},
findings: vec![Finding::new("x", Severity::Warn, "m")],
}],
};
assert!(report.should_fail(Severity::Warn));
assert!(!report.should_fail(Severity::Error));
}
#[test]
fn report_max_severity_returns_highest() {
let report = AuditReport {
gates: vec![
GateResult {
name: "a".to_string(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts {
info: 2,
warn: 0,
error: 0,
},
findings: vec![],
},
GateResult {
name: "b".to_string(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts {
info: 0,
warn: 0,
error: 1,
},
findings: vec![],
},
],
};
assert_eq!(report.max_severity(), Some(Severity::Error));
}
#[test]
fn site_load_nonexistent_returns_empty_html_files() {
let site = Site::load(Path::new("/nonexistent/xxx-audit")).unwrap();
assert!(site.html_files.is_empty());
}
#[test]
fn audit_config_default_is_new() {
let cfg = AuditConfig::default();
assert_eq!(cfg.severity_floor, AuditConfig::new().severity_floor);
}
#[test]
fn audit_report_len_and_is_empty_align() {
let r0 = AuditReport { gates: vec![] };
assert_eq!(r0.len(), 0);
assert!(r0.is_empty());
let r1 = AuditReport {
gates: vec![GateResult {
name: "g".into(),
skipped: false,
skip_reason: None,
severity_counts: SeverityCounts {
info: 0,
warn: 0,
error: 0,
},
findings: vec![],
}],
};
assert_eq!(r1.len(), 1);
assert!(!r1.is_empty());
}
#[test]
fn audit_runner_debug_lists_gate_names() {
let runner = AuditRunner::new(AuditConfig::new());
let dbg = format!("{runner:?}");
assert!(dbg.contains("AuditRunner"));
assert!(dbg.contains("gates"));
}
#[test]
fn audit_runner_with_gates_accepts_empty_vec() {
let runner = AuditRunner::with_gates(AuditConfig::new(), Vec::new());
assert!(runner.gate_names().is_empty());
}
#[test]
fn audit_report_print_sarif_emits_to_stdout() {
let r = AuditReport { gates: vec![] };
r.print_sarif();
}
}