use std::io::Write;
use alint_core::{Level, Report, RuleResult, Violation};
pub fn write_junit(report: &Report, w: &mut dyn Write) -> std::io::Result<()> {
let active: Vec<&RuleResult> = report
.results
.iter()
.filter(|r| r.level != Level::Off)
.collect();
let total_violations: usize = active.iter().map(|r| r.violations.len()).sum();
let passing_rules = active.iter().filter(|r| r.passed()).count();
let total_cases = passing_rules + total_violations;
writeln!(w, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
writeln!(
w,
r#"<testsuites name="alint" tests="{total_cases}" failures="{total_violations}" errors="0" time="0">"#
)?;
writeln!(
w,
r#" <testsuite name="alint" tests="{total_cases}" failures="{total_violations}" errors="0" time="0">"#
)?;
for result in active {
if result.passed() {
writeln!(
w,
r#" <testcase classname="alint.{}" name="{}" time="0"/>"#,
xml_attr(&result.rule_id),
xml_attr(&result.rule_id),
)?;
continue;
}
for violation in &result.violations {
write_failure_case(w, result, violation)?;
}
}
writeln!(w, " </testsuite>")?;
writeln!(w, "</testsuites>")?;
Ok(())
}
fn write_failure_case(
w: &mut dyn Write,
result: &RuleResult,
violation: &Violation,
) -> std::io::Result<()> {
let case_name = match &violation.path {
Some(p) => p.display().to_string(),
None => "(repository)".to_string(),
};
let level_attr = match result.level {
Level::Error => "error",
Level::Warning => "warning",
Level::Info => "info",
Level::Off => "off",
};
writeln!(
w,
r#" <testcase classname="alint.{rule}" name="{name}" time="0">"#,
rule = xml_attr(&result.rule_id),
name = xml_attr(&case_name),
)?;
writeln!(
w,
r#" <failure message="{msg}" type="{level_attr}">{body}</failure>"#,
msg = xml_attr(&violation.message),
body = xml_text(&format_failure_body(result, violation)),
)?;
writeln!(w, " </testcase>")?;
Ok(())
}
fn format_failure_body(result: &RuleResult, violation: &Violation) -> String {
let mut s = String::new();
if let Some(p) = &violation.path {
s.push_str(&p.display().to_string());
if let Some(line) = violation.line {
s.push(':');
s.push_str(&line.to_string());
if let Some(col) = violation.column {
s.push(':');
s.push_str(&col.to_string());
}
}
s.push_str(": ");
}
s.push_str(&violation.message);
if let Some(url) = &result.policy_url
&& !url.is_empty()
{
s.push_str("\nPolicy: ");
s.push_str(url);
}
s
}
fn xml_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if is_xml_illegal_control(ch) {
continue;
}
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
out
}
fn xml_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if is_xml_illegal_control(ch) {
continue;
}
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'\n' | '\r' | '\t' => out.push(' '),
_ => out.push(ch),
}
}
out
}
fn is_xml_illegal_control(ch: char) -> bool {
let cp = ch as u32;
(cp < 0x20 && cp != 0x09 && cp != 0x0A && cp != 0x0D) || cp == 0xFFFE || cp == 0xFFFF
}
#[cfg(test)]
mod tests {
use super::*;
use alint_core::{Report, RuleResult, Violation};
use std::path::{Path, PathBuf};
fn render(report: &Report) -> String {
let mut buf = Vec::new();
write_junit(report, &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
fn rule(id: &str, level: Level, violations: Vec<Violation>) -> RuleResult {
RuleResult {
rule_id: id.into(),
level,
policy_url: None,
violations,
notes: Vec::new(),
is_fixable: false,
}
}
#[test]
fn empty_report_has_zero_tests_zero_failures() {
let out = render(&Report {
results: Vec::new(),
});
assert!(out.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
assert!(out.contains(r#"<testsuites name="alint" tests="0" failures="0" errors="0""#));
assert!(out.contains("</testsuite>\n</testsuites>\n"));
}
#[test]
fn passing_rule_emits_self_closed_testcase() {
let report = Report {
results: vec![rule("ok", Level::Error, vec![])],
};
let out = render(&report);
assert!(out.contains(r#"<testcase classname="alint.ok" name="ok" time="0"/>"#));
assert!(out.contains(r#"tests="1" failures="0""#));
}
#[test]
fn single_violation_renders_failure_with_path_line_col() {
let report = Report {
results: vec![rule(
"no-todo",
Level::Error,
vec![Violation {
path: Some(Path::new("src/lib.rs").into()),
message: "TODO marker found".into(),
line: Some(12),
column: Some(4),
is_note: false,
}],
)],
};
let out = render(&report);
assert!(out.contains(r#"<testcase classname="alint.no-todo" name="src/lib.rs" time="0">"#));
assert!(out.contains(r#"<failure message="TODO marker found" type="error">"#));
assert!(out.contains("src/lib.rs:12:4: TODO marker found"));
assert!(out.contains(r#"tests="1" failures="1""#));
}
#[test]
fn level_warning_and_info_use_distinct_failure_types() {
let report = Report {
results: vec![
rule(
"w",
Level::Warning,
vec![Violation::new("warn-msg").with_path(PathBuf::from("a"))],
),
rule(
"i",
Level::Info,
vec![Violation::new("info-msg").with_path(PathBuf::from("b"))],
),
],
};
let out = render(&report);
assert!(out.contains(r#"type="warning""#));
assert!(out.contains(r#"type="info""#));
assert!(out.contains(r#"failures="2""#));
}
#[test]
fn cross_file_violation_uses_repository_marker_for_name() {
let report = Report {
results: vec![rule(
"unique-pkg",
Level::Error,
vec![Violation::new("dup")],
)],
};
let out = render(&report);
assert!(out.contains(r#"name="(repository)""#));
}
#[test]
fn xml_special_chars_are_escaped() {
let report = Report {
results: vec![rule(
"r&<>\"'",
Level::Error,
vec![Violation {
path: Some(Path::new("a&b.rs").into()),
message: "<bad> & \"quoted\"".into(),
line: None,
column: None,
is_note: false,
}],
)],
};
let out = render(&report);
assert!(out.contains(r#"classname="alint.r&<>"'""#));
assert!(out.contains(r#"name="a&b.rs""#));
assert!(out.contains(r#"message="<bad> & "quoted"""#));
assert!(out.contains("<bad> & \"quoted\""));
}
#[test]
fn control_characters_are_stripped() {
let report = Report {
results: vec![rule(
"ctrl",
Level::Error,
vec![Violation {
path: Some(Path::new("a.rs").into()),
message: "before\u{0001}\u{0008}after".into(),
line: None,
column: None,
is_note: false,
}],
)],
};
let out = render(&report);
assert!(out.contains("beforeafter"));
assert!(!out.contains('\u{0001}'));
}
#[test]
fn newline_in_attribute_normalizes_to_space() {
let report = Report {
results: vec![rule(
"r",
Level::Error,
vec![Violation {
path: Some(Path::new("a").into()),
message: "line1\nline2".into(),
line: None,
column: None,
is_note: false,
}],
)],
};
let out = render(&report);
assert!(out.contains(r#"message="line1 line2""#));
assert!(out.contains("line1\nline2"));
}
#[test]
fn policy_url_appended_to_failure_body() {
let report = Report {
results: vec![RuleResult {
rule_id: "r".into(),
level: Level::Error,
policy_url: Some("https://example.com/p".into()),
violations: vec![Violation::new("x").with_path(PathBuf::from("a"))],
notes: Vec::new(),
is_fixable: false,
}],
};
let out = render(&report);
assert!(out.contains("Policy: https://example.com/p"));
}
#[test]
fn level_off_rule_is_silenced_entirely() {
let report = Report {
results: vec![RuleResult {
rule_id: "off".into(),
level: Level::Off,
policy_url: None,
violations: vec![Violation::new("ignored").with_path(PathBuf::from("a"))],
notes: Vec::new(),
is_fixable: false,
}],
};
let out = render(&report);
assert!(out.contains(r#"tests="0" failures="0""#));
assert!(!out.contains("<testcase"));
assert!(!out.contains("<failure"));
}
#[test]
fn multiple_violations_one_rule_emit_separate_testcases() {
let report = Report {
results: vec![rule(
"r",
Level::Error,
vec![
Violation::new("v1").with_path(PathBuf::from("a")),
Violation::new("v2").with_path(PathBuf::from("b")),
],
)],
};
let out = render(&report);
assert_eq!(out.matches("<testcase").count(), 2);
assert_eq!(out.matches("<failure").count(), 2);
assert!(out.contains(r#"failures="2""#));
}
#[test]
fn output_is_deterministic_for_identical_input() {
let report = Report {
results: vec![rule(
"r",
Level::Error,
vec![
Violation::new("v1").with_path(PathBuf::from("a")),
Violation::new("v2").with_path(PathBuf::from("b")),
],
)],
};
assert_eq!(render(&report), render(&report));
}
}