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