use core::num::NonZeroU32;
use rust_llm_tidy::reporting::{Change, RunReport};
use rust_llm_tidy::reporting::{Diagnostic, Severity};
use serde::Serialize;
use std::borrow::Cow;
use std::io::{self, Write};
use std::path::Path;
#[derive(Serialize)]
pub(crate) struct JsonRecord<'a> {
path: Cow<'a, str>,
line: Option<NonZeroU32>,
severity: &'static str,
code: &'static str,
message: Cow<'a, str>,
item_kind: Cow<'a, str>,
item_name: Option<Cow<'a, str>>,
title: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum OutputMode {
Text,
Json,
}
pub(crate) fn emit_report(report: &RunReport, json: bool) -> anyhow::Result<()> {
let mut stderr = io::stderr().lock();
for warning in &report.warnings {
writeln!(stderr, "warning: {warning}")?;
}
if json {
for file in &report.files {
if let Some(error) = &file.failure {
writeln!(stderr, "error processing {}: {error}", file.path.display())?;
}
}
emit_json(report)?;
} else {
write_text(&mut stderr, report)?;
}
for failure in &report.post_process_failures {
let action = if failure.spawn_failed {
"failed to spawn"
} else {
"failed"
};
writeln!(
stderr,
"post_process `{}` {action} on {}: {}",
failure.command,
failure.path.display(),
failure.message
)?;
}
Ok(())
}
pub(crate) fn emit_json(report: &RunReport) -> anyhow::Result<()> {
let count = report
.files
.iter()
.map(|file| file.diagnostics.len() + file.changes.len())
.sum();
let mut records: Vec<JsonRecord<'_>> = Vec::with_capacity(count);
records.extend(
report
.files
.iter()
.flat_map(|file| file.diagnostics.iter().map(|d| project_lint(&file.path, d))),
);
records.extend(
report
.files
.iter()
.flat_map(|file| file.changes.iter().map(|c| project_change(&file.path, c))),
);
let doc = serde_json::to_string(&records)?;
let mut out = io::stdout().lock();
out.write_all(doc.as_bytes())?;
out.write_all(b"\n")?;
Ok(())
}
fn project_change<'a>(path: &Path, c: &'a Change) -> JsonRecord<'a> {
JsonRecord {
path: Cow::Owned(path.display().to_string()),
line: c.line,
severity: "success",
code: c.code,
message: Cow::Borrowed(c.message.as_ref()),
item_kind: Cow::Borrowed(c.kind.as_str()),
item_name: c.name.as_deref().map(Cow::Borrowed),
title: None,
}
}
fn project_lint<'a>(path: &Path, d: &'a Diagnostic) -> JsonRecord<'a> {
JsonRecord {
path: Cow::Owned(path.display().to_string()),
line: NonZeroU32::new(d.line as u32),
severity: match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Hint => "hint",
Severity::Reminder => "reminder",
},
code: d.code,
message: Cow::Borrowed(d.message.as_ref()),
item_kind: Cow::Borrowed(d.item_kind.as_ref()),
item_name: d.item_name.as_deref().map(Cow::Borrowed),
title: Some(d.title()),
}
}
fn write_text(output: &mut impl Write, report: &RunReport) -> io::Result<()> {
for file in &report.files {
for change in &file.changes {
writeln!(output, "{}:{change}", file.path.display())?;
}
for diagnostic in &file.diagnostics {
if matches!(diagnostic.severity, Severity::Error | Severity::Warning) {
writeln!(output, "{}:{diagnostic}", file.path.display())?;
}
}
if let Some(error) = &file.failure {
writeln!(output, "error processing {}: {error}", file.path.display())?;
}
}
for severity in [Severity::Hint, Severity::Reminder] {
let mut explanation_written = false;
for file in &report.files {
for diagnostic in &file.diagnostics {
if diagnostic.severity == severity {
if severity == Severity::Reminder && !explanation_written {
writeln!(
output,
"\nReminders are prompts to consider, not required fixes. They cannot always\n\
be resolved and may remain even when the code is appropriate.\n\
Reminders alone do not fail the check.\n"
)?;
explanation_written = true;
}
writeln!(output, "{}:{diagnostic}", file.path.display())?;
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::project_lint;
use rstest::rstest;
use rust_llm_tidy::reporting::{Diagnostic, FileReport, RunReport, Severity};
use std::path::Path;
#[rstest]
#[case::builtin("DOC001", Some("missing documentation"), "missing documentation", "")]
#[case::symbol("SYM", Some("API reminder"), "API reminder", "API reminder: ")]
#[case::untitled_known("DOC001", None, "DOC001", "")]
#[case::untitled_unknown("DOC999", None, "DOC999", "")]
#[case::untitled_symbol("SYM", None, "SYM", "")]
fn report_should_preserve_message_and_project_producer_title(
#[case] code: &'static str,
#[case] title: Option<&str>,
#[case] expected_title: &str,
#[case] prefix: &str,
) {
let report = RunReport {
files: vec![FileReport {
path: "input.rs".into(),
diagnostics: vec![Diagnostic {
severity: Severity::Warning,
code,
title: title.map(Into::into),
message: "complete finding summary".into(),
line: 1,
item_kind: "fn".into(),
item_name: None,
}],
..FileReport::default()
}],
..RunReport::default()
};
let mut rendered = Vec::new();
super::write_text(&mut rendered, &report).unwrap();
let json = serde_json::to_value(project_lint(
Path::new("input.rs"),
&report.files[0].diagnostics[0],
))
.unwrap();
assert_eq!(json["title"], expected_title);
assert_eq!(json["message"], "complete finding summary");
assert!(!String::from_utf8_lossy(&rendered).contains("Reminders are prompts"));
assert_eq!(
String::from_utf8(rendered).unwrap(),
format!("input.rs:1: warning[{code}]: {prefix}complete finding summary (fn)\n")
);
}
#[rstest]
#[case::error(Severity::Error, "error", 1)]
#[case::warning(Severity::Warning, "warning", 0)]
#[case::hint(Severity::Hint, "hint", 0)]
#[case::reminder(Severity::Reminder, "reminder", 0)]
fn report_should_group_reminders_last_and_gate_only_errors(
#[case] severity: Severity,
#[case] token: &str,
#[case] errors: usize,
) {
let diagnostic = |severity, line| Diagnostic {
title: None,
severity,
code: "DOC999",
message: "finding".into(),
line,
item_kind: "fn".into(),
item_name: None,
};
let report = RunReport {
files: vec![FileReport {
path: "input.rs".into(),
diagnostics: vec![diagnostic(Severity::Reminder, 1), diagnostic(severity, 2)],
..FileReport::default()
}],
..RunReport::default()
};
let mut rendered = Vec::new();
super::write_text(&mut rendered, &report).unwrap();
let text = String::from_utf8(rendered).unwrap();
let json = serde_json::to_value(project_lint(
Path::new("input.rs"),
&report.files[0].diagnostics[1],
))
.unwrap();
assert_eq!(report.error_count(), errors);
assert_eq!(report.ensure_success().is_err(), errors > 0);
assert_eq!(json["severity"], token);
assert!(text.contains(&format!("2: {token}[DOC999]")));
let last = text.lines().last().unwrap();
assert!(last.contains("reminder[DOC999]"));
assert_eq!(text.matches("Reminders are prompts to consider").count(), 1);
assert!(text.find("Reminders are prompts").unwrap() < text.find("reminder[").unwrap());
}
#[test]
fn report_should_render_hints_last_and_count_only_errors() {
for (name, severity, errors) in [
("hint_only", Severity::Hint, 0),
("hint_and_warning", Severity::Warning, 0),
("hint_and_error", Severity::Error, 1),
] {
let diagnostic = |severity, line| Diagnostic {
title: None,
severity,
code: "DOC999",
message: "finding".into(),
line,
item_kind: "fn".into(),
item_name: None,
};
let report = RunReport {
files: vec![
FileReport {
path: "a.rs".into(),
diagnostics: vec![diagnostic(Severity::Hint, 1)],
..FileReport::default()
},
FileReport {
path: "b.rs".into(),
diagnostics: vec![diagnostic(severity, 2)],
..FileReport::default()
},
],
..RunReport::default()
};
let mut rendered = Vec::new();
super::write_text(&mut rendered, &report).unwrap();
let text = String::from_utf8(rendered).unwrap();
let lines: Vec<_> = text.lines().collect();
let json: Vec<_> = report
.files
.iter()
.flat_map(|file| {
file.diagnostics
.iter()
.map(|d| serde_json::to_value(project_lint(&file.path, d)).unwrap())
})
.collect();
assert_eq!(report.error_count(), errors, "{name}");
assert_eq!(report.ensure_success().is_err(), errors > 0, "{name}");
assert_eq!(lines.len(), 2, "{name}");
if name == "hint_only" {
assert!(lines[0].starts_with("a.rs:1: hint["));
assert!(lines[1].starts_with("b.rs:2: hint["));
} else {
assert!(lines[0].starts_with("b.rs:2:"));
assert!(lines[1].starts_with("a.rs:1: hint["));
}
assert_eq!(json.len(), 2);
assert_eq!(json[0]["severity"], "hint");
}
}
#[test]
fn project_lint_serializes_hint_severity() {
let finding = Diagnostic {
title: None,
severity: Severity::Hint,
code: "DOC999",
message: String::from("consider pre-allocating the buffer"),
line: 3,
item_kind: String::from("fn"),
item_name: Some(String::from("load")),
};
let json = serde_json::to_string(&project_lint(Path::new("src/lib.rs"), &finding)).unwrap();
assert_eq!(
json,
"{\"path\":\"src/lib.rs\",\"line\":3,\"severity\":\"hint\",\
\"code\":\"DOC999\",\"message\":\"consider pre-allocating the buffer\",\
\"item_kind\":\"fn\",\"item_name\":\"load\",\"title\":\"DOC999\"}"
);
}
}