1use crate::audit::{AuditConfig, AuditRunner, AuditTomlConfig, Severity, Site};
13use crate::error::SsgError;
14use clap::ArgMatches;
15use std::path::PathBuf;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Outcome {
28 Pass,
30 Fail,
32}
33
34pub fn run(sub_m: &ArgMatches) -> Result<Outcome, SsgError> {
56 let output_dir = sub_m
57 .get_one::<PathBuf>("output")
58 .cloned()
59 .unwrap_or_else(|| PathBuf::from("public"));
60
61 if sub_m.get_flag("explain") {
63 let gate = sub_m.get_one::<String>("gate");
64 return explain_gate(gate.map(String::as_str)).map(|()| Outcome::Pass);
65 }
66
67 let mut config = load_audit_config(sub_m.get_one::<PathBuf>("config"))?;
68 apply_cli_overrides(&mut config, sub_m);
69
70 let site = Site::load(&output_dir)?;
71 let runner = AuditRunner::new(config);
72 let report = runner.run(&site);
73
74 if sub_m.get_flag("json") {
75 report.print_json()?;
76 } else if sub_m.get_flag("junit") {
77 report.print_junit();
78 } else if sub_m.get_flag("sarif") {
79 report.print_sarif();
80 } else {
81 report.print_text();
82 }
83
84 let outcome = if report.should_fail(runner.fail_on()) {
85 Outcome::Fail
86 } else {
87 Outcome::Pass
88 };
89 Ok(outcome)
90}
91
92fn load_audit_config(
97 config_path: Option<&PathBuf>,
98) -> Result<AuditConfig, SsgError> {
99 let Some(path) = config_path else {
100 return Ok(AuditConfig::new());
101 };
102 let Ok(text) = std::fs::read_to_string(path) else {
103 return Ok(AuditConfig::new());
104 };
105
106 #[derive(Debug, Default, serde::Deserialize)]
110 struct OuterToml {
111 #[serde(default)]
112 audit: AuditTomlConfig,
113 }
114 let outer: OuterToml = toml::from_str(&text).unwrap_or_default();
115 Ok(outer.audit.into_audit_config())
116}
117
118fn apply_cli_overrides(config: &mut AuditConfig, sub_m: &ArgMatches) {
119 if let Some(gate) = sub_m.get_one::<String>("gate") {
120 config.only = Some(gate.clone());
121 }
122 if let Some(sev) = sub_m.get_one::<String>("severity") {
123 if let Some(parsed) = Severity::parse(sev) {
124 config.severity_floor = parsed;
125 }
126 }
127 if let Some(fo) = sub_m.get_one::<String>("fail-on") {
128 if let Some(parsed) = Severity::parse(fo) {
129 config.fail_on = parsed;
130 }
131 }
132 if sub_m.get_flag("skip-network") {
133 config.options.skip_network = true;
134 }
135 if sub_m.get_flag("no-skip-network") {
136 config.options.skip_network = false;
137 }
138}
139
140fn explain_gate(name: Option<&str>) -> Result<(), SsgError> {
141 let gates = crate::audit::gates::all();
142 match name {
143 Some(target) => {
144 let Some(gate) = gates.iter().find(|g| g.name() == target) else {
145 return Err(SsgError::Validation {
146 field: "gate".to_string(),
147 message: format!("unknown gate `{target}`"),
148 });
149 };
150 println!("[{}] {}", gate.name(), gate.explain());
151 }
152 None => {
153 for gate in &gates {
154 println!("[{}] {}\n", gate.name(), gate.explain());
155 }
156 }
157 }
158 Ok(())
159}
160
161#[must_use]
172pub fn build_subcommand() -> clap::Command {
173 use clap::{Arg, ArgAction};
174 clap::Command::new("audit")
175 .about("Run the 15 native audit gates against the built site")
176 .long_about(
177 "Runs WCAG, JSON-LD, hreflang, CSP/SRI, PQC TLS, HTML5, broken \
178 links, metadata, markdown, performance, AI discovery, feeds, \
179 images, and the semantic search index gates. Exits 1 if any \
180 finding is at or above --fail-on (default: error).",
181 )
182 .arg(
183 Arg::new("output")
184 .help("Site output directory (defaults to ./public)")
185 .long("output")
186 .short('o')
187 .value_name("DIR")
188 .value_parser(clap::value_parser!(PathBuf)),
189 )
190 .arg(
191 Arg::new("config")
192 .help("ssg.toml path (used to load [audit] section)")
193 .long("config")
194 .short('f')
195 .value_name("FILE")
196 .value_parser(clap::value_parser!(PathBuf)),
197 )
198 .arg(
199 Arg::new("gate")
200 .help("Only run the named gate (e.g. hreflang)")
201 .long("gate")
202 .value_name("NAME"),
203 )
204 .arg(
205 Arg::new("severity")
206 .help("Minimum severity to print (info|warn|error)")
207 .long("severity")
208 .value_name("LEVEL"),
209 )
210 .arg(
211 Arg::new("fail-on")
212 .help("Severity that triggers a non-zero exit (default: error)")
213 .long("fail-on")
214 .value_name("LEVEL"),
215 )
216 .arg(
217 Arg::new("json")
218 .help("Emit JSON to stdout instead of rich text")
219 .long("json")
220 .action(ArgAction::SetTrue)
221 .conflicts_with("junit"),
222 )
223 .arg(
224 Arg::new("junit")
225 .help("Emit JUnit XML to stdout instead of rich text")
226 .long("junit")
227 .action(ArgAction::SetTrue)
228 .conflicts_with("sarif"),
229 )
230 .arg(
231 Arg::new("sarif")
232 .help("Emit SARIF v2.1.0 JSON (GitHub Code Scanning, GitLab Ultra, Sonatype) — issue #562")
233 .long("sarif")
234 .action(ArgAction::SetTrue)
235 .conflicts_with("json"),
236 )
237 .arg(
238 Arg::new("skip-network")
239 .help("Skip external HTTP probes (default)")
240 .long("skip-network")
241 .action(ArgAction::SetTrue),
242 )
243 .arg(
244 Arg::new("no-skip-network")
245 .help("Enable external HTTP probes for the broken-link gate")
246 .long("no-skip-network")
247 .action(ArgAction::SetTrue)
248 .conflicts_with("skip-network"),
249 )
250 .arg(
251 Arg::new("explain")
252 .help("Print the long-form explainer (use with --gate)")
253 .long("explain")
254 .action(ArgAction::SetTrue),
255 )
256}
257
258pub fn run_and_dispatch(
279 matches: &ArgMatches,
280 quiet: bool,
281) -> Result<(), SsgError> {
282 let outcome = run(matches)?;
283 match outcome {
284 Outcome::Pass => {
285 if !quiet {
286 log::info!("[audit] all gates passed");
287 }
288 Ok(())
289 }
290 Outcome::Fail => {
291 if !quiet {
292 eprintln!("audit: one or more gates failed");
293 }
294 std::process::exit(1);
295 }
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn build_subcommand_exposes_audit_flags() {
305 let cmd = build_subcommand();
306 let arg_names: Vec<&str> =
307 cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
308 for required in &[
309 "output",
310 "config",
311 "gate",
312 "severity",
313 "fail-on",
314 "json",
315 "junit",
316 "skip-network",
317 "explain",
318 ] {
319 assert!(arg_names.contains(required), "missing arg: {required}");
320 }
321 }
322
323 #[test]
324 fn audit_passes_on_empty_site() {
325 let tmp = tempfile::tempdir().unwrap();
326 let site = tmp.path().join("public");
327 std::fs::create_dir_all(&site).unwrap();
328 let cmd = build_subcommand();
329 let matches = cmd
330 .try_get_matches_from([
331 "audit",
332 "--output",
333 site.to_str().unwrap(),
334 "--fail-on",
335 "error",
336 ])
337 .unwrap();
338 let outcome = run(&matches).unwrap();
339 assert_eq!(outcome, Outcome::Pass);
342 }
343
344 #[test]
345 fn explain_with_unknown_gate_errors() {
346 let cmd = build_subcommand();
347 let matches = cmd
348 .try_get_matches_from([
349 "audit",
350 "--explain",
351 "--gate",
352 "no-such-gate",
353 ])
354 .unwrap();
355 let err = run(&matches).unwrap_err();
356 assert!(format!("{err:?}").starts_with("Validation"));
360 }
361
362 #[test]
363 fn explain_with_known_gate_prints() {
364 let cmd = build_subcommand();
367 let matches = cmd
368 .try_get_matches_from(["audit", "--explain", "--gate", "wcag"])
369 .unwrap();
370 let outcome = run(&matches).unwrap();
371 assert_eq!(outcome, Outcome::Pass);
372 }
373
374 #[test]
375 fn explain_with_no_gate_prints_all() {
376 let cmd = build_subcommand();
378 let matches = cmd.try_get_matches_from(["audit", "--explain"]).unwrap();
379 let outcome = run(&matches).unwrap();
380 assert_eq!(outcome, Outcome::Pass);
381 }
382
383 #[test]
384 #[serial_test::parallel]
385 fn json_output_branch() {
386 let tmp = tempfile::tempdir().unwrap();
394 let site = tmp.path().join("public");
395 std::fs::create_dir_all(&site).unwrap();
396 let cmd = build_subcommand();
397 let matches = cmd
398 .try_get_matches_from([
399 "audit",
400 "--output",
401 site.to_str().unwrap(),
402 "--json",
403 ])
404 .unwrap();
405 let outcome = run(&matches).unwrap();
406 assert_eq!(outcome, Outcome::Pass);
407 }
408
409 #[test]
410 fn sarif_output_branch() {
411 let tmp = tempfile::tempdir().unwrap();
415 let site = tmp.path().join("public");
416 std::fs::create_dir_all(&site).unwrap();
417 let cmd = build_subcommand();
418 let matches = cmd
419 .try_get_matches_from([
420 "audit",
421 "--output",
422 site.to_str().unwrap(),
423 "--sarif",
424 ])
425 .unwrap();
426 let outcome = run(&matches).unwrap();
427 assert_eq!(outcome, Outcome::Pass);
428 }
429
430 #[test]
431 fn junit_output_branch() {
432 let tmp = tempfile::tempdir().unwrap();
434 let site = tmp.path().join("public");
435 std::fs::create_dir_all(&site).unwrap();
436 let cmd = build_subcommand();
437 let matches = cmd
438 .try_get_matches_from([
439 "audit",
440 "--output",
441 site.to_str().unwrap(),
442 "--junit",
443 ])
444 .unwrap();
445 let outcome = run(&matches).unwrap();
446 assert_eq!(outcome, Outcome::Pass);
447 }
448
449 #[test]
450 fn cli_overrides_gate_severity_failon() {
451 let tmp = tempfile::tempdir().unwrap();
454 let site = tmp.path().join("public");
455 std::fs::create_dir_all(&site).unwrap();
456 let cmd = build_subcommand();
457 let matches = cmd
458 .try_get_matches_from([
459 "audit",
460 "--output",
461 site.to_str().unwrap(),
462 "--gate",
463 "wcag",
464 "--severity",
465 "warn",
466 "--fail-on",
467 "error",
468 ])
469 .unwrap();
470 let outcome = run(&matches).unwrap();
471 assert_eq!(outcome, Outcome::Pass);
472 }
473
474 #[test]
475 fn cli_override_skip_network_flag() {
476 let tmp = tempfile::tempdir().unwrap();
478 let site = tmp.path().join("public");
479 std::fs::create_dir_all(&site).unwrap();
480 let cmd = build_subcommand();
481 let matches = cmd
482 .try_get_matches_from([
483 "audit",
484 "--output",
485 site.to_str().unwrap(),
486 "--skip-network",
487 ])
488 .unwrap();
489 let outcome = run(&matches).unwrap();
490 assert_eq!(outcome, Outcome::Pass);
491 }
492
493 #[test]
494 fn cli_override_no_skip_network_flag() {
495 let tmp = tempfile::tempdir().unwrap();
497 let site = tmp.path().join("public");
498 std::fs::create_dir_all(&site).unwrap();
499 let cmd = build_subcommand();
500 let matches = cmd
501 .try_get_matches_from([
502 "audit",
503 "--output",
504 site.to_str().unwrap(),
505 "--no-skip-network",
506 ])
507 .unwrap();
508 let outcome = run(&matches).unwrap();
509 assert_eq!(outcome, Outcome::Pass);
510 }
511
512 #[test]
513 fn load_audit_config_missing_file_returns_default() {
514 let cfg =
517 load_audit_config(Some(&PathBuf::from("/nonexistent/x.toml")))
518 .unwrap();
519 assert!(format!("{cfg:?}").contains("AuditConfig"));
522 }
523
524 #[test]
525 fn load_audit_config_garbage_toml_returns_default() {
526 let tmp = tempfile::tempdir().unwrap();
529 let bad = tmp.path().join("bad.toml");
530 std::fs::write(&bad, "this is = not valid <<< toml >>>").unwrap();
531 let cfg = load_audit_config(Some(&bad)).unwrap();
532 assert!(format!("{cfg:?}").contains("AuditConfig"));
533 }
534
535 #[test]
536 fn load_audit_config_with_table_parses() {
537 let tmp = tempfile::tempdir().unwrap();
539 let good = tmp.path().join("ssg.toml");
540 std::fs::write(
541 &good,
542 r#"
543[audit]
544severity_floor = "warn"
545fail_on = "error"
546"#,
547 )
548 .unwrap();
549 let cfg = load_audit_config(Some(&good)).unwrap();
550 assert!(format!("{cfg:?}").contains("AuditConfig"));
551 }
552
553 #[test]
554 fn fail_on_info_turns_info_findings_into_fail_outcome() {
555 let tmp = tempfile::tempdir().unwrap();
559 let site = tmp.path().join("public");
560 std::fs::create_dir_all(&site).unwrap();
561 let cmd = build_subcommand();
562 let matches = cmd
563 .try_get_matches_from([
564 "audit",
565 "--output",
566 site.to_str().unwrap(),
567 "--fail-on",
568 "info",
569 ])
570 .unwrap();
571 let outcome = run(&matches).unwrap();
572 assert_eq!(outcome, Outcome::Fail);
573 }
574
575 #[test]
576 fn unparseable_severity_and_fail_on_are_ignored() {
577 let tmp = tempfile::tempdir().unwrap();
580 let site = tmp.path().join("public");
581 std::fs::create_dir_all(&site).unwrap();
582 let cmd = build_subcommand();
583 let matches = cmd
584 .try_get_matches_from([
585 "audit",
586 "--output",
587 site.to_str().unwrap(),
588 "--severity",
589 "bogus-level",
590 "--fail-on",
591 "another-bogus-level",
592 ])
593 .unwrap();
594 let outcome = run(&matches).unwrap();
596 assert_eq!(outcome, Outcome::Pass);
597 }
598
599 #[test]
600 fn run_and_dispatch_propagates_run_errors() {
601 let cmd = build_subcommand();
603 let matches = cmd
604 .try_get_matches_from([
605 "audit",
606 "--explain",
607 "--gate",
608 "no-such-gate",
609 ])
610 .unwrap();
611 let err = run_and_dispatch(&matches, true).unwrap_err();
612 assert!(format!("{err:?}").starts_with("Validation"));
613 }
614
615 #[test]
616 fn run_and_dispatch_fail_outcome_exits_one() {
617 if std::env::var("SSG_AUDIT_EXIT_TEST").is_ok() {
622 let tmp = tempfile::tempdir().unwrap();
623 let site = tmp.path().join("public");
624 std::fs::create_dir_all(&site).unwrap();
625 let cmd = build_subcommand();
626 let matches = cmd
627 .try_get_matches_from([
628 "audit",
629 "--output",
630 site.to_str().unwrap(),
631 "--fail-on",
632 "info",
633 ])
634 .unwrap();
635 let _ = run_and_dispatch(&matches, false);
637 unreachable!("run_and_dispatch must exit(1) on Fail");
638 }
639
640 let exe = std::env::current_exe().unwrap();
641 let output = std::process::Command::new(exe)
642 .args([
643 "--exact",
644 "cmd::audit::tests::run_and_dispatch_fail_outcome_exits_one",
645 "--nocapture",
646 ])
647 .env("SSG_AUDIT_EXIT_TEST", "1")
648 .output()
649 .unwrap();
650 assert_eq!(output.status.code(), Some(1), "child must exit(1)");
651 let stderr = String::from_utf8_lossy(&output.stderr);
652 assert!(
653 stderr.contains("audit: one or more gates failed"),
654 "child stderr must carry the failure banner, got: {stderr}"
655 );
656 }
657
658 #[test]
659 fn run_and_dispatch_pass_quiet() {
660 let tmp = tempfile::tempdir().unwrap();
662 let site = tmp.path().join("public");
663 std::fs::create_dir_all(&site).unwrap();
664 let cmd = build_subcommand();
665 let matches = cmd
666 .try_get_matches_from(["audit", "--output", site.to_str().unwrap()])
667 .unwrap();
668 run_and_dispatch(&matches, true).unwrap();
669 }
670
671 #[test]
672 fn run_and_dispatch_pass_verbose() {
673 let tmp = tempfile::tempdir().unwrap();
675 let site = tmp.path().join("public");
676 std::fs::create_dir_all(&site).unwrap();
677 let cmd = build_subcommand();
678 let matches = cmd
679 .try_get_matches_from(["audit", "--output", site.to_str().unwrap()])
680 .unwrap();
681 run_and_dispatch(&matches, false).unwrap();
682 }
683}
684
685#[cfg(all(test, feature = "test-fault-injection"))]
686mod fault_tests {
687 use super::*;
688
689 struct FailGuard(&'static str);
692
693 impl Drop for FailGuard {
694 fn drop(&mut self) {
695 let _ = fail::cfg(self.0, "off");
696 }
697 }
698
699 #[test]
711 #[serial_test::serial]
712 fn json_output_propagates_serialize_error() {
713 let _guard = FailGuard("audit::json-format");
714 fail::cfg("audit::json-format", "return").expect("activate failpoint");
715
716 let tmp = tempfile::tempdir().unwrap();
717 let site = tmp.path().join("public");
718 std::fs::create_dir_all(&site).unwrap();
719 let cmd = build_subcommand();
720 let matches = cmd
721 .try_get_matches_from([
722 "audit",
723 "--output",
724 site.to_str().unwrap(),
725 "--json",
726 ])
727 .unwrap();
728 let err = run(&matches).unwrap_err();
729 assert!(
730 format!("{err:?}").starts_with("Io"),
731 "expected Io error, got: {err:?}"
732 );
733 }
734}