use crate::delta::{DeltaReport, DeltaStatus};
use crate::merge::CrapEntry;
use anyhow::Result;
use std::io::Write;
pub(crate) fn render_github(
entries: &[CrapEntry],
threshold: f64,
out: &mut dyn Write,
) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_default();
for entry in entries {
if entry.crap <= threshold {
continue;
}
let file = entry.file.strip_prefix(&cwd).unwrap_or(&entry.file);
let cov_str = match entry.coverage {
Some(c) => format!("{c:.1}%"),
None => "—".to_string(),
};
let message = format!(
"{fn_name} has CRAP score {crap:.1} (CC={cc}, cov={cov})",
fn_name = entry.function,
crap = entry.crap,
cc = entry.cyclomatic as usize,
cov = cov_str,
);
writeln!(
out,
"::warning file={file},line={line},title=CRAP ({crap:.1} > {threshold})::{msg}",
file = file.display(),
line = entry.line,
crap = entry.crap,
threshold = threshold,
msg = gha_escape(&message),
)?;
}
Ok(())
}
pub(crate) fn render_delta_github(
report: &DeltaReport,
threshold: f64,
out: &mut dyn Write,
) -> Result<()> {
let cwd = std::env::current_dir().unwrap_or_default();
for de in &report.entries {
let e = &de.current;
let should_warn = match de.status {
DeltaStatus::Regressed => true,
DeltaStatus::New => e.crap > threshold,
_ => false,
};
if !should_warn {
continue;
}
let file = e.file.strip_prefix(&cwd).unwrap_or(&e.file);
let delta_str = match de.delta {
Some(d) => format!(" (Δ{d:+.1})"),
None => " (new)".to_string(),
};
let cov_str = e.coverage.map_or("—".into(), |c| format!("{c:.1}%"));
let moved_str = de
.previous_file
.as_ref()
.map(|prev| {
let prev_disp = prev.strip_prefix(&cwd).unwrap_or(prev);
format!(" (moved from {})", prev_disp.display())
})
.unwrap_or_default();
let message = format!(
"{fn_name} CRAP={crap:.1}{delta}{moved} CC={cc} cov={cov}",
fn_name = e.function,
crap = e.crap,
delta = delta_str,
moved = moved_str,
cc = e.cyclomatic as usize,
cov = cov_str,
);
writeln!(
out,
"::warning file={file},line={line},title=CRAP ({crap:.1})::{msg}",
file = file.display(),
line = e.line,
crap = e.crap,
msg = gha_escape(&message),
)?;
}
Ok(())
}
pub(crate) fn gha_escape(s: &str) -> String {
s.replace('%', "%25")
.replace('\r', "%0D")
.replace('\n', "%0A")
}
#[cfg(test)]
mod tests {
use super::super::test_support::sample;
use super::super::{Format, render};
use super::*;
use std::path::PathBuf;
#[test]
fn github_format_emits_warning_for_crappy_function() {
let mut buf = Vec::new();
render(&sample(), 30.0, Format::GitHub, None, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("::warning"),
"crappy function must produce a ::warning annotation"
);
assert!(
s.contains("crappy"),
"annotation must mention the crappy function"
);
}
#[test]
fn github_format_clean_function_produces_no_annotation() {
let mut buf = Vec::new();
render(&sample(), 30.0, Format::GitHub, None, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
!s.lines()
.any(|l| l.contains("clean") && l.contains("::warning")),
"clean function must not produce an annotation"
);
}
#[test]
fn github_format_all_clean_produces_empty_output() {
let all_clean = vec![CrapEntry {
file: PathBuf::from("a.rs"),
function: "clean".into(),
line: 1,
cyclomatic: 1.0,
coverage: Some(100.0),
crap: 1.0,
crate_name: None,
}];
let mut buf = Vec::new();
render(&all_clean, 30.0, Format::GitHub, None, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.is_empty(),
"no crappy functions must produce no output, got: {s:?}"
);
}
#[test]
fn github_format_annotation_contains_file_and_line() {
let entries = vec![CrapEntry {
file: PathBuf::from("src/lib.rs"),
function: "bad".into(),
line: 42,
cyclomatic: 10.0,
coverage: Some(0.0),
crap: 110.0,
crate_name: None,
}];
let mut buf = Vec::new();
render(&entries, 30.0, Format::GitHub, None, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("line=42"),
"annotation must include the line number"
);
assert!(
s.contains("lib.rs"),
"annotation must include the file name"
);
}
#[test]
fn gha_escape_encodes_special_characters() {
assert_eq!(gha_escape("a%b"), "a%25b");
assert_eq!(gha_escape("a\rb"), "a%0Db");
assert_eq!(gha_escape("a\nb"), "a%0Ab");
assert_eq!(gha_escape("plain"), "plain"); }
#[test]
fn delta_github_annotation_includes_moved_from_when_previous_file_set() {
use crate::delta::{DeltaEntry, DeltaReport, DeltaStatus};
let report = DeltaReport {
entries: vec![DeltaEntry {
current: CrapEntry {
file: PathBuf::from("src/new.rs"),
function: "render".into(),
line: 7,
cyclomatic: 5.0,
coverage: Some(50.0),
crap: 35.0,
crate_name: None,
},
baseline_crap: Some(20.0),
delta: Some(15.0),
status: DeltaStatus::Regressed,
previous_file: Some(PathBuf::from("src/old.rs")),
}],
removed: vec![],
};
let mut buf = Vec::new();
render_delta_github(&report, 30.0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("(moved from src/old.rs)"),
"annotation must surface the baseline location, got:\n{s}"
);
assert!(s.contains("::warning"), "must still emit warning");
}
#[test]
fn delta_github_skips_pure_moves() {
use crate::delta::{DeltaEntry, DeltaReport, DeltaStatus};
let report = DeltaReport {
entries: vec![DeltaEntry {
current: CrapEntry {
file: PathBuf::from("src/new.rs"),
function: "render".into(),
line: 1,
cyclomatic: 5.0,
coverage: Some(80.0),
crap: 5.0,
crate_name: None,
},
baseline_crap: Some(5.0),
delta: Some(0.0),
status: DeltaStatus::Moved,
previous_file: Some(PathBuf::from("src/old.rs")),
}],
removed: vec![],
};
let mut buf = Vec::new();
render_delta_github(&report, 30.0, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.is_empty(),
"pure moves must not emit warnings, got: {s:?}"
);
}
}