Skip to main content

stern4rust/reporting/
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 crate::reporting::offence::Offence;
6use crate::reporting::offence_threshold::OffenceThreshold;
7use crate::reporting::package_roster::PackageRoster;
8use serde_json::Value;
9use serde_json::json;
10use serde_json::to_string_pretty;
11use std::collections::BTreeSet;
12
13// The same run as data rather than as a table.
14//
15// The table is sized to its contents and meant for a person. Nothing can parse
16// it reliably -- paths and descriptions both contain spaces, and descriptions
17// carry backticks, quotes and semicolons, so splitting on whitespace is
18// guesswork. A gate script or an agent reads this instead and never has to
19// infer where one column ends and the next begins.
20pub struct JsonPrinter {
21    files_scanned: usize,
22    threshold: OffenceThreshold,
23    applied: Vec<String>,
24    rosters: Vec<PackageRoster>,
25    skipped: Vec<String>,
26    unconfigured: Vec<String>,
27    exclusions: Vec<(String, usize)>,
28    config_file: Option<String>,
29    baseline: Option<String>,
30    suppressed: usize,
31    stale: usize,
32    fixed: usize,
33}
34
35impl JsonPrinter {
36    pub fn new(files_scanned: usize) -> Self {
37        Self {
38            files_scanned,
39            threshold: OffenceThreshold::default(),
40            applied: Vec::new(),
41            rosters: Vec::new(),
42            skipped: Vec::new(),
43            unconfigured: Vec::new(),
44            exclusions: Vec::new(),
45            config_file: None,
46            baseline: None,
47            suppressed: 0,
48            stale: 0,
49            fixed: 0,
50        }
51    }
52
53    // How many files --fix repaired. Stated alongside what is left, because a
54    // fixer reporting only its successes would be the same silence this tool
55    // refuses everywhere else.
56    pub fn with_fixed(self, fixed: usize) -> Self {
57        Self { fixed, ..self }
58    }
59
60    pub fn with_baseline(self, baseline: Option<String>, suppressed: usize, stale: usize) -> Self {
61        Self {
62            baseline,
63            suppressed,
64            stale,
65            ..self
66        }
67    }
68
69    pub fn with_config_file(self, config_file: Option<String>) -> Self {
70        Self {
71            config_file,
72            ..self
73        }
74    }
75
76    // Each pattern with the number of files it removed, so a consumer can tell
77    // a run that looked at everything from one that was told not to -- and can
78    // see a pattern sitting at zero, which is a stale exclusion rather than a
79    // working one.
80    pub fn with_exclusions(self, exclusions: Vec<(String, usize)>) -> Self {
81        Self { exclusions, ..self }
82    }
83
84    // A consumer that could not tell an all-rules run from a one-rule run would
85    // read "no offences" as "nothing wrong", which is only true of the rules
86    // that were actually applied.
87    // A roster per package walked. The text report collapses these where they
88    // agree; a document does not, because nothing is reading it for brevity and
89    // a consumer that has to tell absent from empty has been given a puzzle.
90    pub fn with_package_rosters(self, rosters: Vec<PackageRoster>) -> Self {
91        Self { rosters, ..self }
92    }
93
94    pub fn with_rules(
95        self,
96        applied: Vec<String>,
97        skipped: Vec<String>,
98        unconfigured: Vec<String>,
99    ) -> Self {
100        Self {
101            applied,
102            skipped,
103            unconfigured,
104            ..self
105        }
106    }
107
108    pub fn with_threshold(self, threshold: OffenceThreshold) -> Self {
109        Self { threshold, ..self }
110    }
111
112    // Returns the document rather than printing it, so the shape is assertable
113    // in a test instead of being checked for not panicking.
114    // offences_found is the true total and offences is what survived the
115    // threshold, so a consumer reading only the array can still see that it is
116    // not the whole story. rules_broken counts every rule that was broken, not
117    // only those whose offences fitted.
118    fn excluded_total(&self) -> usize {
119        self.exclusions.iter().map(|(_, count)| count).sum()
120    }
121
122    pub fn render(&self, offences: &[Offence]) -> String {
123        let broken: BTreeSet<&str> = offences.iter().map(|offence| offence.rule).collect();
124        let shown = self.threshold.kept(offences);
125        let document = json!({
126            "baseline": self.baseline,
127            "files_fixed": self.fixed,
128            "baselined": self.suppressed,
129            "baseline_stale_entries": self.stale,
130            "config_file": self.config_file,
131            "files_scanned": self.files_scanned,
132            "files_excluded": self.excluded_total(),
133            "exclusions": self.exclusions.iter().map(|(pattern, count)| json!({
134                "pattern": pattern,
135                "files_excluded": count,
136            })).collect::<Vec<_>>(),
137            "offences_found": offences.len(),
138            "offences_reported": shown.len(),
139            "offences_omitted": self.threshold.omitted(offences),
140            "offence_threshold": self.threshold.limit(),
141            "rules_broken": broken.len(),
142            "rules_applied": self.applied,
143            "rules_skipped": self.skipped,
144            "rules_unconfigured": self.unconfigured,
145            "packages": self.package_documents(),
146            "offences": shown,
147        });
148        to_string_pretty(&document).unwrap_or_default()
149    }
150
151    fn package_documents(&self) -> Vec<Value> {
152        self.rosters
153            .iter()
154            .map(|roster| {
155                json!({
156                    "package": roster.package,
157                    "rules_applied": roster.applied,
158                    "rules_skipped": roster.skipped,
159                    "rules_unconfigured": roster
160                        .unconfigured
161                        .iter()
162                        .map(|(rule, requirement)| json!({
163                            "rule": rule,
164                            "requirement": requirement,
165                        }))
166                        .collect::<Vec<_>>(),
167                })
168            })
169            .collect()
170    }
171
172    pub fn print(&self, offences: &[Offence]) {
173        println!("{}", self.render(offences));
174    }
175
176    // The same content the text listing carries, as a document. The two must not
177    // give different pictures -- see
178    // [ADR-MachineReadableReport](../../docs/ADRs/ADR-MachineReadableReport.md).
179}