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