Skip to content

Commit 507245c

Browse files
psteinroeclaude
andcommitted
feat(cli): implement json, json-pretty, and summary reporters
The CLI advertised these as valid --reporter values but they were not implemented, causing an error when used. Adds the three missing reporter variants with snapshot tests. Closes #694 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 77366ec commit 507245c

8 files changed

Lines changed: 355 additions & 2 deletions

crates/pgls_cli/src/cli_options.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ pub enum CliReporter {
129129
Junit,
130130
/// Reports linter diagnostics using the [GitLab Code Quality report](https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool).
131131
GitLab,
132+
/// Diagnostics are printed as JSON
133+
Json,
134+
/// Diagnostics are printed as pretty-printed JSON
135+
JsonPretty,
136+
/// Only a summary of diagnostics is printed (counts, no individual diagnostics)
137+
Summary,
132138
}
133139

134140
impl CliReporter {
@@ -145,6 +151,9 @@ impl FromStr for CliReporter {
145151
"github" => Ok(Self::GitHub),
146152
"junit" => Ok(Self::Junit),
147153
"gitlab" => Ok(Self::GitLab),
154+
"json" => Ok(Self::Json),
155+
"json-pretty" => Ok(Self::JsonPretty),
156+
"summary" => Ok(Self::Summary),
148157
_ => Err(format!(
149158
"value {s:?} is not valid for the --reporter argument"
150159
)),
@@ -159,6 +168,9 @@ impl Display for CliReporter {
159168
CliReporter::GitHub => f.write_str("github"),
160169
CliReporter::Junit => f.write_str("junit"),
161170
CliReporter::GitLab => f.write_str("gitlab"),
171+
CliReporter::Json => f.write_str("json"),
172+
CliReporter::JsonPretty => f.write_str("json-pretty"),
173+
CliReporter::Summary => f.write_str("summary"),
162174
}
163175
}
164176
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
use crate::diagnostics::CliDiagnostic;
2+
use crate::reporter::{Report, ReportConfig, ReportWriter};
3+
use pgls_console::{Console, ConsoleExt, markup};
4+
use pgls_diagnostics::display::SourceFile;
5+
use pgls_diagnostics::{Error, PrintDescription, Resource, Severity};
6+
use serde::Serialize;
7+
8+
pub(crate) struct JsonReportWriter {
9+
pub pretty: bool,
10+
}
11+
12+
impl ReportWriter for JsonReportWriter {
13+
fn write(
14+
&mut self,
15+
console: &mut dyn Console,
16+
_command_name: &str,
17+
report: &Report,
18+
config: &ReportConfig,
19+
) -> Result<(), CliDiagnostic> {
20+
let diagnostics: Vec<_> = report
21+
.diagnostics
22+
.iter()
23+
.filter(|d| d.severity() >= config.diagnostic_level)
24+
.filter(|d| {
25+
if d.tags().is_verbose() {
26+
config.verbose
27+
} else {
28+
true
29+
}
30+
})
31+
.map(JsonDiagnostic::from_error)
32+
.collect();
33+
34+
let output = JsonReport {
35+
diagnostics,
36+
errors: report.errors,
37+
warnings: report.warnings,
38+
duration: format!("{:?}", report.duration),
39+
};
40+
41+
let serialized = if self.pretty {
42+
serde_json::to_string_pretty(&output)
43+
} else {
44+
serde_json::to_string(&output)
45+
}
46+
.map_err(|e| CliDiagnostic::io_error(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
47+
48+
console.log(markup!({ serialized }));
49+
Ok(())
50+
}
51+
}
52+
53+
#[derive(Serialize)]
54+
struct JsonReport {
55+
diagnostics: Vec<JsonDiagnostic>,
56+
errors: u32,
57+
warnings: u32,
58+
duration: String,
59+
}
60+
61+
#[derive(Serialize)]
62+
struct JsonDiagnostic {
63+
severity: &'static str,
64+
#[serde(skip_serializing_if = "Option::is_none")]
65+
category: Option<String>,
66+
description: String,
67+
#[serde(skip_serializing_if = "Option::is_none")]
68+
location: Option<JsonLocation>,
69+
}
70+
71+
#[derive(Serialize)]
72+
struct JsonLocation {
73+
path: String,
74+
#[serde(skip_serializing_if = "Option::is_none")]
75+
start: Option<JsonPosition>,
76+
#[serde(skip_serializing_if = "Option::is_none")]
77+
end: Option<JsonPosition>,
78+
}
79+
80+
#[derive(Serialize)]
81+
struct JsonPosition {
82+
line: usize,
83+
column: usize,
84+
}
85+
86+
impl JsonDiagnostic {
87+
fn from_error(diagnostic: &Error) -> Self {
88+
let description = PrintDescription(diagnostic).to_string();
89+
let category = diagnostic
90+
.category()
91+
.map(|c| c.name().to_string());
92+
let severity = match diagnostic.severity() {
93+
Severity::Hint => "hint",
94+
Severity::Information => "info",
95+
Severity::Warning => "warning",
96+
Severity::Error => "error",
97+
Severity::Fatal => "fatal",
98+
};
99+
100+
let location = Self::build_location(diagnostic);
101+
102+
Self {
103+
severity,
104+
category,
105+
description,
106+
location,
107+
}
108+
}
109+
110+
fn build_location(diagnostic: &Error) -> Option<JsonLocation> {
111+
let loc = diagnostic.location();
112+
let path = match loc.resource {
113+
Some(Resource::File(file)) => file.to_string(),
114+
_ => return None,
115+
};
116+
117+
let (start, end) = match (loc.span, loc.source_code) {
118+
(Some(span), Some(source_code)) => {
119+
let source = SourceFile::new(source_code);
120+
let start = source.location(span.start()).ok().map(|l| JsonPosition {
121+
line: l.line_number.get(),
122+
column: l.column_number.get(),
123+
});
124+
let end = source.location(span.end()).ok().map(|l| JsonPosition {
125+
line: l.line_number.get(),
126+
column: l.column_number.get(),
127+
});
128+
(start, end)
129+
}
130+
_ => (None, None),
131+
};
132+
133+
Some(JsonLocation { path, start, end })
134+
}
135+
}

crates/pgls_cli/src/reporter/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
pub(crate) mod github;
22
pub(crate) mod gitlab;
3+
pub(crate) mod json;
34
pub(crate) mod junit;
5+
pub(crate) mod summary;
46
pub(crate) mod terminal;
57

68
use crate::cli_options::{CliOptions, CliReporter};
@@ -39,6 +41,9 @@ pub enum ReportMode {
3941
GitHub,
4042
GitLab,
4143
Junit,
44+
Json,
45+
JsonPretty,
46+
Summary,
4247
}
4348

4449
impl From<CliReporter> for ReportMode {
@@ -48,6 +53,9 @@ impl From<CliReporter> for ReportMode {
4853
CliReporter::GitHub => Self::GitHub,
4954
CliReporter::Junit => Self::Junit,
5055
CliReporter::GitLab => Self::GitLab,
56+
CliReporter::Json => Self::Json,
57+
CliReporter::JsonPretty => Self::JsonPretty,
58+
CliReporter::Summary => Self::Summary,
5159
}
5260
}
5361
}
@@ -129,6 +137,9 @@ impl Reporter {
129137
ReportMode::GitHub => Box::new(github::GithubReportWriter),
130138
ReportMode::GitLab => Box::new(gitlab::GitLabReportWriter),
131139
ReportMode::Junit => Box::new(junit::JunitReportWriter),
140+
ReportMode::Json => Box::new(json::JsonReportWriter { pretty: false }),
141+
ReportMode::JsonPretty => Box::new(json::JsonReportWriter { pretty: true }),
142+
ReportMode::Summary => Box::new(summary::SummaryReportWriter),
132143
};
133144

134145
writer.write(console, command_name, payload, &self.config)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use crate::diagnostics::CliDiagnostic;
2+
use crate::reporter::{Report, ReportConfig, ReportWriter};
3+
use pgls_console::{Console, ConsoleExt, markup};
4+
5+
pub(crate) struct SummaryReportWriter;
6+
7+
impl ReportWriter for SummaryReportWriter {
8+
fn write(
9+
&mut self,
10+
console: &mut dyn Console,
11+
command_name: &str,
12+
report: &Report,
13+
_config: &ReportConfig,
14+
) -> Result<(), CliDiagnostic> {
15+
let mut lines = Vec::new();
16+
17+
if let Some(traversal) = &report.traversal {
18+
let total_files = traversal.changed + traversal.unchanged;
19+
lines.push(format!(
20+
"{command_name}: Checked {total_files} file(s) in {:?}.",
21+
report.duration
22+
));
23+
if traversal.changed > 0 {
24+
lines.push(format!("Fixed {} file(s).", traversal.changed));
25+
}
26+
if traversal.skipped > 0 {
27+
lines.push(format!("Skipped {} file(s).", traversal.skipped));
28+
}
29+
} else {
30+
lines.push(format!(
31+
"{command_name}: Completed in {:?}.",
32+
report.duration
33+
));
34+
}
35+
36+
lines.push(format!("Errors: {}", report.errors));
37+
lines.push(format!("Warnings: {}", report.warnings));
38+
39+
let output = lines.join("\n");
40+
console.log(markup!({ output }));
41+
Ok(())
42+
}
43+
}

crates/pgls_cli/tests/assert_check.rs

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,45 @@ fn check_junit_reporter_snapshot() {
5454
]));
5555
}
5656

57+
#[test]
58+
#[cfg_attr(
59+
target_os = "windows",
60+
ignore = "snapshot expectations only validated on unix-like platforms"
61+
)]
62+
fn check_json_reporter_snapshot() {
63+
assert_snapshot!(run_check(&[
64+
"--reporter",
65+
"json",
66+
"tests/fixtures/test.sql"
67+
]));
68+
}
69+
70+
#[test]
71+
#[cfg_attr(
72+
target_os = "windows",
73+
ignore = "snapshot expectations only validated on unix-like platforms"
74+
)]
75+
fn check_json_pretty_reporter_snapshot() {
76+
assert_snapshot!(run_check(&[
77+
"--reporter",
78+
"json-pretty",
79+
"tests/fixtures/test.sql"
80+
]));
81+
}
82+
83+
#[test]
84+
#[cfg_attr(
85+
target_os = "windows",
86+
ignore = "snapshot expectations only validated on unix-like platforms"
87+
)]
88+
fn check_summary_reporter_snapshot() {
89+
assert_snapshot!(run_check(&[
90+
"--reporter",
91+
"summary",
92+
"tests/fixtures/test.sql"
93+
]));
94+
}
95+
5796
#[test]
5897
#[cfg_attr(
5998
target_os = "windows",
@@ -120,8 +159,23 @@ fn normalize_durations(input: &str) -> String {
120159
let mut search_start = 0;
121160
while let Some(relative) = content[search_start..].find(" in ") {
122161
let start = search_start + relative + 4;
123-
if let Some(end_rel) = content[start..].find('.') {
124-
let end = start + end_rel;
162+
// Find end of sentence period: scan for '.' that is followed by
163+
// a non-digit (or EOL), skipping decimal points inside durations.
164+
let rest = &content[start..];
165+
let mut dot_search = 0;
166+
let mut found_end = None;
167+
while let Some(dot_rel) = rest[dot_search..].find('.') {
168+
let dot_pos = dot_search + dot_rel;
169+
let after_dot = dot_pos + 1;
170+
if after_dot >= rest.len()
171+
|| !rest.as_bytes()[after_dot].is_ascii_digit()
172+
{
173+
found_end = Some(start + dot_pos);
174+
break;
175+
}
176+
dot_search = after_dot;
177+
}
178+
if let Some(end) = found_end {
125179
if content[start..end].chars().any(|c| c.is_ascii_digit()) {
126180
content.replace_range(start..end, "<duration>");
127181
search_start = start + "<duration>".len() + 1;
@@ -147,6 +201,47 @@ fn normalize_durations(input: &str) -> String {
147201
}
148202
}
149203

204+
// Normalize JSON "duration":"..." and "duration": "..." fields
205+
for json_dur_pat in &["\"duration\":\"", "\"duration\": \""] {
206+
let mut json_search = 0;
207+
while let Some(relative) = content[json_search..].find(json_dur_pat) {
208+
let start = json_search + relative + json_dur_pat.len();
209+
if let Some(end_rel) = content[start..].find('"') {
210+
let end = start + end_rel;
211+
content.replace_range(start..end, "<duration>");
212+
json_search = start + "<duration>".len() + 1;
213+
} else {
214+
break;
215+
}
216+
}
217+
}
218+
219+
// Normalize Rust Debug durations (e.g. "4.877792ms", "1.23s", "123µs", "123ns")
220+
// used by summary reporter: "file(s) in 4.877ms." / "Completed in 4.877ms."
221+
for prefix in &["file(s) in ", "Completed in "] {
222+
let mut search = 0;
223+
while let Some(relative) = content[search..].find(prefix) {
224+
let start = search + relative + prefix.len();
225+
// Find the end of the duration: digits, '.', and time unit suffix
226+
let rest = &content[start..];
227+
let dur_end = rest
228+
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != 'µ')
229+
.unwrap_or(rest.len());
230+
// Include trailing unit letters (m, s, n, etc.)
231+
let after_digits = &rest[dur_end..];
232+
let unit_end = after_digits
233+
.find(|c: char| !c.is_ascii_lowercase())
234+
.unwrap_or(after_digits.len());
235+
let total_end = start + dur_end + unit_end;
236+
if total_end > start {
237+
content.replace_range(start..total_end, "<duration>");
238+
search = start + "<duration>".len();
239+
} else {
240+
search = start + 1;
241+
}
242+
}
243+
}
244+
150245
content
151246
}
152247

0 commit comments

Comments
 (0)