Skip to main content

stern4rust/
json_printer.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::collections::BTreeSet;
6
7use serde_json::json;
8
9use crate::offence::Offence;
10use crate::offence_threshold::OffenceThreshold;
11
12// The same run as data rather than as a table.
13//
14// The table is sized to its contents and meant for a person. Nothing can parse
15// it reliably -- paths and descriptions both contain spaces, and descriptions
16// carry backticks, quotes and semicolons, so splitting on whitespace is
17// guesswork. A gate script or an agent reads this instead and never has to
18// infer where one column ends and the next begins.
19pub struct JsonPrinter {
20    files_scanned: usize,
21    threshold: OffenceThreshold,
22    applied: Vec<String>,
23    skipped: Vec<String>,
24    unconfigured: Vec<String>,
25}
26
27impl JsonPrinter {
28    pub fn new(files_scanned: usize) -> Self {
29        Self {
30            files_scanned,
31            threshold: OffenceThreshold::default(),
32            applied: Vec::new(),
33            skipped: Vec::new(),
34            unconfigured: Vec::new(),
35        }
36    }
37
38    // A consumer that could not tell an all-rules run from a one-rule run would
39    // read "no offences" as "nothing wrong", which is only true of the rules
40    // that were actually applied.
41    pub fn with_rules(
42        self,
43        applied: Vec<String>,
44        skipped: Vec<String>,
45        unconfigured: Vec<String>,
46    ) -> Self {
47        Self {
48            applied,
49            skipped,
50            unconfigured,
51            ..self
52        }
53    }
54
55    pub fn with_threshold(self, threshold: OffenceThreshold) -> Self {
56        Self { threshold, ..self }
57    }
58
59    // Returns the document rather than printing it, so the shape is assertable
60    // in a test instead of being checked for not panicking.
61    // offences_found is the true total and offences is what survived the
62    // threshold, so a consumer reading only the array can still see that it is
63    // not the whole story. rules_broken counts every rule that was broken, not
64    // only those whose offences fitted.
65    pub fn render(&self, offences: &[Offence]) -> String {
66        let broken: BTreeSet<&str> = offences.iter().map(|offence| offence.rule).collect();
67        let shown = self.threshold.kept(offences);
68        let document = json!({
69            "files_scanned": self.files_scanned,
70            "offences_found": offences.len(),
71            "offences_reported": shown.len(),
72            "offences_omitted": self.threshold.omitted(offences),
73            "offence_threshold": self.threshold.limit(),
74            "rules_broken": broken.len(),
75            "rules_applied": self.applied,
76            "rules_skipped": self.skipped,
77            "rules_unconfigured": self.unconfigured,
78            "offences": shown,
79        });
80        serde_json::to_string_pretty(&document).unwrap_or_default()
81    }
82
83    pub fn print(&self, offences: &[Offence]) {
84        println!("{}", self.render(offences));
85    }
86}