Skip to main content

ssg/cmd/
audit.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! `ssg audit` subcommand handler.
5//!
6//! Loads the site under `--output` (or the configured `output_dir`),
7//! merges `[audit]` from `ssg.toml` over the default config, applies
8//! CLI overrides (`--gate`, `--severity`, `--fail-on`, `--skip-network`,
9//! `--json`, `--junit`, `--explain`), runs the [`crate::audit::AuditRunner`]
10//! and renders + returns an exit-code-shaped result.
11
12use crate::audit::{AuditConfig, AuditRunner, AuditTomlConfig, Severity, Site};
13use crate::error::SsgError;
14use clap::ArgMatches;
15use std::path::PathBuf;
16
17/// Outcome of running [`run`] — the caller turns this into a process
18/// exit code (0 == [`Outcome::Pass`]; 1 == [`Outcome::Fail`]).
19///
20/// # Examples
21///
22/// ```
23/// use ssg::cmd::audit::Outcome;
24/// assert_ne!(Outcome::Pass, Outcome::Fail);
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Outcome {
28    /// No finding exceeded the configured `--fail-on` threshold.
29    Pass,
30    /// At least one gate produced a finding at or above `--fail-on`.
31    Fail,
32}
33
34/// Executes the audit subcommand against the parsed clap matches.
35///
36/// # Errors
37/// Returns [`SsgError`] when the site cannot be loaded or rendering
38/// fails. Findings themselves never produce an `Err` — they're folded
39/// into the returned [`Outcome`].
40///
41/// # Examples
42///
43/// ```
44/// use ssg::cmd::audit::{build_subcommand, run, Outcome};
45/// let tmp = tempfile::tempdir().unwrap();
46/// let site = tmp.path().join("public");
47/// std::fs::create_dir_all(&site).unwrap();
48/// let cmd = build_subcommand();
49/// let matches = cmd
50///     .try_get_matches_from(["audit", "--output", site.to_str().unwrap()])
51///     .unwrap();
52/// let outcome = run(&matches).unwrap();
53/// assert_eq!(outcome, Outcome::Pass);
54/// ```
55pub 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    // --explain (early-exit, no audit run)
62    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
92/// Loads the `[audit]` table from `ssg.toml` if `--config` was passed.
93///
94/// Returns the default config when `--config` is absent or the file
95/// has no `[audit]` table.
96fn 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    // We don't deserialise the whole file with `SsgConfig` here —
107    // that would force a `[site]` table that an audit-only config
108    // doesn't need. Instead, parse the bare `[audit]` table.
109    #[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/// Builds the clap `Command` for the `audit` subcommand. Re-used by
162/// `cli.rs::subcommand_app` so the surface is wired in one place.
163///
164/// # Examples
165///
166/// ```
167/// use ssg::cmd::audit::build_subcommand;
168/// let cmd = build_subcommand();
169/// assert_eq!(cmd.get_name(), "audit");
170/// ```
171#[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
258/// Tiny shim used by `lib.rs::run`. Mapped to `Result<(), SsgError>`
259/// so the dispatcher's call-site stays uniform across subcommands; the
260/// non-zero exit is surfaced by a `process::exit(1)` in the caller.
261///
262/// # Errors
263/// Propagates [`SsgError`] from [`run`].
264///
265/// # Examples
266///
267/// ```
268/// use ssg::cmd::audit::{build_subcommand, run_and_dispatch};
269/// let tmp = tempfile::tempdir().unwrap();
270/// let site = tmp.path().join("public");
271/// std::fs::create_dir_all(&site).unwrap();
272/// let cmd = build_subcommand();
273/// let matches = cmd
274///     .try_get_matches_from(["audit", "--output", site.to_str().unwrap()])
275///     .unwrap();
276/// run_and_dispatch(&matches, true).unwrap();
277/// ```
278pub 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        // Empty site triggers info-level skips from PQC/Search/Markdown
340        // gates only — no errors, so should pass on `--fail-on error`.
341        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        // Debug-format check keeps this assertion region-free — a
357        // `matches!` here would leave its never-taken false arm
358        // uncovered.
359        assert!(format!("{err:?}").starts_with("Validation"));
360    }
361
362    #[test]
363    fn explain_with_known_gate_prints() {
364        // Covers explain_gate(Some(gate)) success arm. We accept any
365        // gate name that exists; pick `wcag` which always ships.
366        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        // Covers explain_gate(None) loop.
377        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        // Covers `report.print_json()` arm (line ~75). Tagged
387        // `#[parallel]` (default/unkeyed group) to pair with
388        // `fault_tests::json_output_propagates_serialize_error`'s
389        // unkeyed `#[serial]` lock on the shared `audit::json-format`
390        // failpoint below — otherwise this test can race the fault
391        // test and observe an injected failure that was never meant
392        // for it.
393        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        // Covers `report.print_sarif()` arm (line ~79) — the new
412        // --sarif flag routes here instead of falling through to
413        // print_text.
414        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        // Covers `report.print_junit()` arm (line ~77).
433        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        // Covers apply_cli_overrides paths for --gate, --severity,
452        // --fail-on (lines 117-128).
453        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        // Covers apply_cli_overrides --skip-network arm (line 130-131).
477        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        // Covers apply_cli_overrides --no-skip-network arm (line 133-134).
496        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        // Covers the `Err` -> default branch at line 100-101 of
515        // load_audit_config (path passed but file doesn't exist).
516        let cfg =
517            load_audit_config(Some(&PathBuf::from("/nonexistent/x.toml")))
518                .unwrap();
519        // Default config: severity_floor is `Warn`-or-similar by
520        // default. Smoke-check that we got a usable value back.
521        assert!(format!("{cfg:?}").contains("AuditConfig"));
522    }
523
524    #[test]
525    fn load_audit_config_garbage_toml_returns_default() {
526        // Covers the toml::from_str -> unwrap_or_default branch at
527        // line 112-113 (file exists, parse fails).
528        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        // Covers the success path through line 112-113.
538        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        // An empty site still yields info-level skip findings from the
556        // PQC/search/markdown gates, so lowering --fail-on to `info`
557        // must flip the outcome to Fail.
558        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        // Covers the `Severity::parse -> None` miss branches for both
578        // --severity and --fail-on.
579        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        // Defaults stay in force, so the empty site still passes.
595        let outcome = run(&matches).unwrap();
596        assert_eq!(outcome, Outcome::Pass);
597    }
598
599    #[test]
600    fn run_and_dispatch_propagates_run_errors() {
601        // Covers the `?` on run() inside run_and_dispatch.
602        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        // `run_and_dispatch` calls process::exit(1) on Outcome::Fail,
618        // which would kill the test harness — so the Fail arm runs in
619        // a child copy of this exact test, and the parent asserts on
620        // the child's exit code and stderr.
621        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            // quiet = false so the eprintln branch executes too.
636            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        // Covers run_and_dispatch's Pass + quiet arm (line 274-278).
661        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        // Covers the !quiet log::info!() arm (line 275-276).
674        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    /// RAII guard that disables a failpoint on drop — mirrors the
690    /// pattern used by `audit::output::json`'s own fault tests.
691    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    /// Covers the `report.print_json()?` propagation arm in [`run`]
700    /// (the `--json` branch) — the only way to observe a non-`Ok` from
701    /// [`crate::audit::AuditReport::print_json`] without invalid UTF-8
702    /// (impossible in safe Rust). Reuses the `audit::json-format`
703    /// failpoint already defined in `audit::output::json::format`
704    /// rather than adding a new one.
705    ///
706    /// `#[serial]` (the default, unkeyed lock) pairs with
707    /// `audit::output::json`'s own `format_propagates_injected_io_error`
708    /// test, which uses the same unkeyed `#[serial]` lock — so the two
709    /// never run concurrently and race on the process-global failpoint.
710    #[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}