use anstyle::{AnsiColor, Style};
use serde_json::{json, Value};
use crate::domain::verdict::{Outcome, RuleOutcome, Violation};
const FAIL_STYLE: Style = AnsiColor::Red.on_default().bold();
const PASS_STYLE: Style = AnsiColor::Green.on_default().bold();
const SKIP_STYLE: Style = AnsiColor::Yellow.on_default().dimmed();
const ERROR_STYLE: Style = AnsiColor::Red.on_default().bold();
fn paint(text: &str, style: Style, color: bool) -> String {
if color {
format!("{style}{text}{style:#}")
} else {
text.to_string()
}
}
#[derive(Debug, Clone)]
pub struct Report {
pub outcomes: Vec<RuleOutcome>,
pub run_errors: Vec<String>,
}
impl Report {
pub fn new(mut outcomes: Vec<RuleOutcome>, run_errors: Vec<String>) -> Self {
outcomes.sort_by(|a, b| a.name.cmp(&b.name));
Report {
outcomes,
run_errors,
}
}
fn counts(&self) -> (usize, usize, usize) {
let mut pass = 0;
let mut fail = 0;
let mut skip = 0;
for o in &self.outcomes {
match o.outcome {
Outcome::Pass => pass += 1,
Outcome::Fail => fail += 1,
Outcome::Skipped => skip += 1,
}
}
(pass, fail, skip)
}
pub fn exit_code(&self) -> i32 {
if !self.run_errors.is_empty() {
2
} else if self.outcomes.iter().any(|o| o.outcome == Outcome::Fail) {
1
} else {
0
}
}
pub fn to_human(&self, verbosity: u8, color: bool) -> String {
let mut out = String::new();
for o in &self.outcomes {
match o.outcome {
Outcome::Fail => {
let label = paint("FAIL", FAIL_STYLE, color);
out.push_str(&format!("{label} {}{}\n", o.name, votes_suffix(o)));
push_reasoning(&mut out, o);
for v in &o.violations {
out.push_str(&format!(" {}\n", format_violation(v)));
}
}
Outcome::Pass if verbosity >= 1 => {
let label = paint("PASS", PASS_STYLE, color);
out.push_str(&format!("{label} {}{}\n", o.name, votes_suffix(o)));
push_reasoning(&mut out, o);
}
Outcome::Skipped if verbosity >= 1 => {
let label = paint("SKIP", SKIP_STYLE, color);
out.push_str(&format!("{label} {} (no files matched)\n", o.name))
}
Outcome::Pass | Outcome::Skipped => {}
}
}
for e in &self.run_errors {
let label = paint("ERROR", ERROR_STYLE, color);
out.push_str(&format!("{label} {e}\n"));
}
if !out.is_empty() {
out.push('\n');
}
let (pass, fail, skip) = self.counts();
let passed = paint(&format!("{pass} passed"), PASS_STYLE, color);
let failed = if fail > 0 {
paint(&format!("{fail} failed"), FAIL_STYLE, color)
} else {
format!("{fail} failed")
};
out.push_str(&format!(
"{} rules: {passed}, {failed}, {skip} skipped",
self.outcomes.len(),
));
if !self.run_errors.is_empty() {
let errored = paint(
&format!("{} errored", self.run_errors.len()),
ERROR_STYLE,
color,
);
out.push_str(&format!(", {errored}"));
}
out.push('\n');
out
}
pub fn to_json(&self) -> Value {
let (pass, fail, skip) = self.counts();
json!({
"summary": {
"total": self.outcomes.len(),
"passed": pass,
"failed": fail,
"skipped": skip,
"errored": self.run_errors.len(),
},
"rules": self.outcomes,
"errors": self.run_errors,
})
}
}
fn votes_suffix(o: &RuleOutcome) -> String {
if o.votes_total > 1 {
format!(" ({}/{} judges held)", o.votes_hold, o.votes_total)
} else {
String::new()
}
}
fn push_reasoning(out: &mut String, o: &RuleOutcome) {
if !o.judges.is_empty() {
for (i, j) in o.judges.iter().enumerate() {
let verdict = if j.holds { "held" } else { "violated" };
match j
.rationale
.as_deref()
.map(str::trim)
.filter(|r| !r.is_empty())
{
Some(r) => out.push_str(&format!(" judge {} {verdict}: {r}\n", i + 1)),
None => out.push_str(&format!(" judge {} {verdict}\n", i + 1)),
}
}
} else if let Some(r) = &o.rationale {
let r = r.trim();
if !r.is_empty() {
out.push_str(&format!(" rationale: {r}\n"));
}
}
}
fn format_violation(v: &Violation) -> String {
let mut loc = String::new();
if let Some(file) = &v.file {
loc.push_str(file);
if let Some(line) = v.line {
loc.push_str(&format!(":{line}"));
if let Some(end) = v.end_line {
loc.push_str(&format!("-{end}"));
}
}
}
let msg = v.message.as_deref().unwrap_or("violation");
if loc.is_empty() {
msg.to_string()
} else {
format!("{loc}: {msg}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::verdict::JudgeOpinion;
fn fail(name: &str, v: Vec<Violation>) -> RuleOutcome {
RuleOutcome {
name: name.into(),
rationale: None,
outcome: Outcome::Fail,
votes_total: 1,
votes_hold: 0,
judges: vec![],
violations: v,
}
}
fn pass(name: &str) -> RuleOutcome {
RuleOutcome {
name: name.into(),
rationale: None,
outcome: Outcome::Pass,
votes_total: 1,
votes_hold: 1,
judges: vec![],
violations: vec![],
}
}
fn with_rationale(mut o: RuleOutcome, why: &str) -> RuleOutcome {
o.rationale = Some(why.into());
o
}
fn opinion(holds: bool, why: Option<&str>) -> JudgeOpinion {
JudgeOpinion {
holds,
rationale: why.map(Into::into),
}
}
#[test]
fn exit_codes() {
assert_eq!(Report::new(vec![pass("a")], vec![]).exit_code(), 0);
assert_eq!(Report::new(vec![fail("a", vec![])], vec![]).exit_code(), 1);
assert_eq!(
Report::new(vec![pass("a")], vec!["boom".into()]).exit_code(),
2
);
}
#[test]
fn default_output_lists_failures_but_not_passes_or_skips() {
let r = Report::new(
vec![
fail(
"no_inline_sql",
vec![
Violation {
file: Some("src/db.rs".into()),
line: Some(42),
end_line: Some(45),
message: Some("inline SQL".into()),
},
Violation {
message: Some("architectural drift".into()),
..Default::default()
},
],
),
pass("layered"),
RuleOutcome::skipped("nofiles"),
],
vec![],
);
let text = r.to_human(0, false);
assert!(text.contains("FAIL no_inline_sql"));
assert!(text.contains("src/db.rs:42-45: inline SQL"));
assert!(text.contains("architectural drift"));
assert!(!text.contains("PASS layered"));
assert!(!text.contains("SKIP nofiles"));
assert!(text.contains("3 rules: 1 passed, 1 failed, 1 skipped"));
}
#[test]
fn all_passing_default_output_is_just_the_summary() {
let r = Report::new(vec![pass("a"), pass("b")], vec![]);
assert_eq!(
r.to_human(0, false),
"2 rules: 2 passed, 0 failed, 0 skipped\n"
);
}
#[test]
fn color_paints_status_words_and_counts_when_enabled() {
let r = Report::new(
vec![
fail("broke", vec![]),
pass("ok"),
RuleOutcome::skipped("nofiles"),
],
vec!["judge timed out".into()],
);
let plain = r.to_human(1, false);
assert!(!plain.contains('\u{1b}'), "no ANSI when color is off");
let colored = r.to_human(1, true);
const RED: &str = "\u{1b}[1m\u{1b}[31m";
const GREEN: &str = "\u{1b}[1m\u{1b}[32m";
const RESET: &str = "\u{1b}[0m";
assert!(colored.contains(&format!("{RED}FAIL{RESET} broke")));
assert!(colored.contains(&format!("{GREEN}PASS{RESET} ok")));
assert!(colored.contains(&format!("{RED}ERROR{RESET} judge timed out")));
assert!(colored.contains(&format!("{GREEN}1 passed{RESET}")));
assert!(colored.contains(&format!("{RED}1 failed{RESET}")));
assert!(colored.contains(&format!("{RED}1 errored{RESET}")));
assert_eq!(strip_ansi(&colored), plain);
}
#[test]
fn color_leaves_a_zero_failure_count_unpainted() {
let r = Report::new(vec![pass("a")], vec![]);
let colored = r.to_human(0, true);
assert!(colored.contains("0 failed"));
assert!(!colored.contains("\u{1b}[31m"), "no red on a clean run");
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[test]
fn verbose_itemizes_passing_and_skipped_rules_too() {
let r = Report::new(
vec![
fail(
"no_inline_sql",
vec![Violation {
file: Some("src/db.rs".into()),
line: Some(42),
end_line: Some(45),
message: Some("inline SQL".into()),
}],
),
pass("layered"),
RuleOutcome::skipped("nofiles"),
],
vec![],
);
let text = r.to_human(1, false);
assert!(text.contains("FAIL no_inline_sql"));
assert!(text.contains("src/db.rs:42-45: inline SQL"));
assert!(text.contains("PASS layered"));
assert!(text.contains("SKIP nofiles (no files matched)"));
assert!(text.contains("3 rules: 1 passed, 1 failed, 1 skipped"));
}
#[test]
fn vote_split_shows_at_default_errors_at_every_level() {
let r = Report::new(
vec![
RuleOutcome {
name: "voted".into(),
rationale: None,
outcome: Outcome::Fail,
votes_total: 3,
votes_hold: 1,
judges: vec![],
violations: vec![],
},
RuleOutcome::skipped("nofiles"),
],
vec!["judge timed out".into()],
);
let quiet = r.to_human(0, false);
assert!(quiet.contains("FAIL voted (1/3 judges held)"));
assert!(quiet.contains("ERROR judge timed out"));
assert!(quiet.contains("1 errored"));
assert!(!quiet.contains("SKIP nofiles"));
let text = r.to_human(1, false);
assert!(text.contains("SKIP nofiles (no files matched)"));
}
#[test]
fn rationale_shows_for_failures_at_default_and_for_all_rules_at_verbose() {
let r = Report::new(
vec![
with_rationale(
fail(
"no_inline_sql",
vec![Violation {
message: Some("inline SQL".into()),
..Default::default()
}],
),
"raw SQL string built in db.rs",
),
with_rationale(pass("layered"), "imports only flow downward"),
],
vec![],
);
let quiet = r.to_human(0, false);
assert!(quiet.contains("FAIL no_inline_sql"));
assert!(quiet.contains(" rationale: raw SQL string built in db.rs"));
let fail_idx = quiet.find("rationale:").unwrap();
let viol_idx = quiet.find("inline SQL").unwrap();
assert!(fail_idx < viol_idx, "rationale precedes the violation");
assert!(!quiet.contains("imports only flow downward"));
let loud = r.to_human(1, false);
assert!(loud.contains("PASS layered"));
assert!(loud.contains(" rationale: imports only flow downward"));
}
#[test]
fn multi_judge_shows_each_judges_result_and_rationale() {
let mut failing = fail(
"voted_rule",
vec![Violation {
message: Some("inline SQL".into()),
..Default::default()
}],
);
failing.votes_total = 3;
failing.votes_hold = 1;
failing.judges = vec![
opinion(false, Some("raw SQL at db.rs:42")),
opinion(true, Some("uses the query layer")),
opinion(false, None), ];
let mut passing = pass("agreed");
passing.votes_total = 3;
passing.votes_hold = 2;
passing.judges = vec![
opinion(true, Some("clean")),
opinion(false, Some("looked off")),
opinion(true, Some("fine")),
];
let r = Report::new(vec![failing, passing], vec![]);
let quiet = r.to_human(0, false);
assert!(quiet.contains("FAIL voted_rule (1/3 judges held)"));
assert!(quiet.contains("judge 1 violated: raw SQL at db.rs:42"));
assert!(quiet.contains("judge 2 held: uses the query layer"));
assert!(quiet.contains("judge 3 violated\n"));
assert!(quiet.contains("inline SQL"));
assert!(!quiet.contains("agreed"));
let loud = r.to_human(1, false);
assert!(loud.contains("PASS agreed (2/3 judges held)"));
assert!(loud.contains("judge 2 violated: looked off"));
}
#[test]
fn rationale_is_carried_in_json_when_present() {
let r = Report::new(
vec![with_rationale(pass("a"), "all good"), fail("b", vec![])],
vec![],
);
let j = r.to_json();
assert_eq!(j["rules"][0]["rationale"], "all good");
assert!(j["rules"][1].get("rationale").is_none());
}
#[test]
fn json_output_is_stable_shape() {
let r = Report::new(vec![pass("a"), fail("b", vec![])], vec![]);
let j = r.to_json();
assert_eq!(j["summary"]["total"], 2);
assert_eq!(j["summary"]["passed"], 1);
assert_eq!(j["summary"]["failed"], 1);
assert_eq!(j["rules"][0]["name"], "a");
assert_eq!(j["rules"][0]["outcome"], "pass");
assert_eq!(j["rules"][1]["outcome"], "fail");
}
}