use super::links::{SourceLinks, linkify};
use super::per_crate::write_per_crate_markdown;
use super::types::{Grade, delta_display, format_location_with_prev};
use super::write_pr_comment_marker;
use crate::delta::{DeltaEntry, DeltaReport, DeltaStatus};
use crate::merge::CrapEntry;
use anyhow::Result;
use std::io::Write;
fn write_markdown_absolute_heading(
crappy: usize,
threshold: f64,
out: &mut dyn Write,
) -> Result<()> {
if crappy == 0 {
writeln!(out, "## ✅ No CRAP threshold violations")?;
} else {
writeln!(
out,
"## ⚠️ {crappy} function(s) exceed CRAP threshold {threshold}"
)?;
}
writeln!(out)?;
Ok(())
}
fn write_markdown_absolute_summary(
crappy: usize,
total: usize,
threshold: f64,
out: &mut dyn Write,
) -> Result<()> {
writeln!(out)?;
if crappy == 0 {
writeln!(
out,
"✓ {total} function(s) analyzed; none exceed CRAP threshold {threshold}."
)?;
} else {
writeln!(
out,
"✗ {crappy}/{total} function(s) exceed CRAP threshold {threshold}."
)?;
}
Ok(())
}
fn write_markdown_entries_table(
entries: &[CrapEntry],
threshold: f64,
links: Option<&SourceLinks>,
out: &mut dyn Write,
) -> Result<()> {
writeln!(out, "| | CRAP | CC | Cov % | Function | Location |")?;
writeln!(out, "|---|---:|---:|---:|---|---|")?;
for entry in entries {
let grade = Grade::of(entry.crap, threshold);
let cov = match entry.coverage {
Some(p) => format!("{p:.1}"),
None => "—".to_string(),
};
let func = linkify(
format!("`{}`", entry.function),
links,
&entry.file,
entry.line,
);
let loc = linkify(
format!("`{}:{}`", entry.file.display(), entry.line),
links,
&entry.file,
entry.line,
);
writeln!(
out,
"| {} | {:.1} | {} | {} | {} | {} |",
grade.icon(),
entry.crap,
entry.cyclomatic as usize,
cov,
func,
loc,
)?;
}
Ok(())
}
pub(crate) fn render_markdown(
entries: &[CrapEntry],
threshold: f64,
links: Option<&SourceLinks>,
out: &mut dyn Write,
) -> Result<()> {
write_pr_comment_marker(out)?;
if entries.is_empty() {
writeln!(out, "_No functions found._")?;
return Ok(());
}
let crappy = super::crappy_count(entries, threshold);
write_markdown_absolute_heading(crappy, threshold, out)?;
write_per_crate_markdown(entries, threshold, out)?;
write_markdown_entries_table(entries, threshold, links, out)?;
write_markdown_absolute_summary(crappy, entries.len(), threshold, out)
}
fn write_markdown_removed(
removed: &[crate::delta::RemovedEntry],
out: &mut dyn Write,
) -> Result<()> {
writeln!(out)?;
writeln!(out, "**Removed since baseline:**")?;
for r in removed {
writeln!(out, "- `{}` (was {:.1})", r.function, r.baseline_crap)?;
}
Ok(())
}
fn write_markdown_delta_heading(
regressions: usize,
out: &mut dyn Write,
) -> Result<()> {
if regressions == 0 {
writeln!(out, "## ✅ No CRAP regressions")?;
} else {
writeln!(out, "## ⚠️ {regressions} CRAP regression(s) detected")?;
}
writeln!(out)?;
Ok(())
}
fn write_delta_entries_table(
entries: &[DeltaEntry],
threshold: f64,
links: Option<&SourceLinks>,
out: &mut dyn Write,
) -> Result<()> {
writeln!(out, "| | CRAP | Δ | CC | Cov % | Function | Location |")?;
writeln!(out, "|---|---:|---:|---:|---:|---|---|")?;
for de in entries {
let e = &de.current;
let grade = Grade::of(e.crap, threshold);
let cov = e.coverage.map_or("—".to_string(), |p| format!("{p:.1}"));
let func = linkify(format!("`{}`", e.function), links, &e.file, e.line);
let loc_text = format_location_with_prev(&e.file, e.line, de.previous_file.as_deref());
let loc = linkify(loc_text, links, &e.file, e.line);
writeln!(
out,
"| {} | {:.1} | {} | {} | {} | {} | {} |",
grade.icon(),
e.crap,
delta_display(de),
e.cyclomatic as usize,
cov,
func,
loc,
)?;
}
Ok(())
}
fn write_markdown_delta_stats(
report: &DeltaReport,
out: &mut dyn Write,
) -> Result<()> {
let regressed = report
.entries
.iter()
.filter(|e| e.status == DeltaStatus::Regressed)
.count();
let improved = report
.entries
.iter()
.filter(|e| e.status == DeltaStatus::Improved)
.count();
let new = report
.entries
.iter()
.filter(|e| e.status == DeltaStatus::New)
.count();
let moved = report
.entries
.iter()
.filter(|e| e.status == DeltaStatus::Moved)
.count();
let unchanged = report
.entries
.iter()
.filter(|e| e.status == DeltaStatus::Unchanged)
.count();
writeln!(out)?;
writeln!(
out,
"↑ {regressed} regressed · ↓ {improved} improved · ★ {new} new · ↔ {moved} moved · · {unchanged} unchanged · — {} removed",
report.removed.len(),
)?;
Ok(())
}
pub(crate) fn render_delta_markdown(
report: &DeltaReport,
threshold: f64,
links: Option<&SourceLinks>,
out: &mut dyn Write,
) -> Result<()> {
write_pr_comment_marker(out)?;
if report.entries.is_empty() && report.removed.is_empty() {
writeln!(out, "_No functions found._")?;
return Ok(());
}
write_markdown_delta_heading(report.regression_count(), out)?;
write_delta_entries_table(&report.entries, threshold, links, out)?;
if !report.removed.is_empty() {
write_markdown_removed(&report.removed, out)?;
}
write_markdown_delta_stats(report, out)
}
#[cfg(test)]
mod tests {
use super::super::{Format, render};
use super::*;
use std::path::PathBuf;
#[test]
fn markdown_format_also_emits_links() {
let entries = vec![CrapEntry {
file: PathBuf::from("src/a.rs"),
function: "foo".into(),
line: 7,
cyclomatic: 1.0,
coverage: Some(50.0),
crap: 5.0,
crate_name: None,
}];
let links = SourceLinks::new("https://github.com/o/r".into(), "main".into());
let mut buf = Vec::new();
render(&entries, 30.0, Format::Markdown, Some(&links), &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("[`foo`](https://github.com/o/r/blob/main/src/a.rs#L7)"),
"markdown format must link Function:\n{s}"
);
assert!(
s.contains("[`src/a.rs:7`](https://github.com/o/r/blob/main/src/a.rs#L7)"),
"markdown format must link Location:\n{s}"
);
}
#[test]
fn delta_markdown_stats_counts_moved_correctly() {
use crate::delta::{DeltaEntry, DeltaReport, DeltaStatus};
let mk_entry = |fn_name: &str, status: DeltaStatus| DeltaEntry {
current: CrapEntry {
file: PathBuf::from("src/a.rs"),
function: fn_name.into(),
line: 1,
cyclomatic: 1.0,
coverage: Some(100.0),
crap: 1.0,
crate_name: None,
},
baseline_crap: Some(1.0),
delta: Some(0.0),
status,
previous_file: None,
};
let report = DeltaReport {
entries: vec![
mk_entry("moved_fn", DeltaStatus::Moved),
mk_entry("u1", DeltaStatus::Unchanged),
mk_entry("u2", DeltaStatus::Unchanged),
mk_entry("u3", DeltaStatus::Unchanged),
],
removed: vec![],
};
let mut buf = Vec::new();
render_delta_markdown(&report, 30.0, None, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("↔ 1 moved"),
"markdown stats line must report 1 moved, not 3:\n{s}"
);
assert!(
!s.contains("↔ 3 moved"),
"markdown stats line must NOT count non-moved as moved:\n{s}"
);
}
#[test]
fn delta_markdown_location_uses_format_location_with_prev_helper() {
use crate::delta::{DeltaEntry, DeltaReport, DeltaStatus};
let regular = DeltaEntry {
current: CrapEntry {
file: PathBuf::from("src/a.rs"),
function: "fn_a".into(),
line: 7,
cyclomatic: 1.0,
coverage: Some(100.0),
crap: 1.0,
crate_name: None,
},
baseline_crap: Some(1.0),
delta: Some(0.0),
status: DeltaStatus::Unchanged,
previous_file: None,
};
let moved = DeltaEntry {
current: CrapEntry {
file: PathBuf::from("src/new.rs"),
function: "fn_b".into(),
line: 42,
cyclomatic: 1.0,
coverage: Some(100.0),
crap: 1.0,
crate_name: None,
},
baseline_crap: Some(1.0),
delta: Some(0.0),
status: DeltaStatus::Moved,
previous_file: Some(PathBuf::from("src/old.rs")),
};
let report = DeltaReport {
entries: vec![regular, moved],
removed: vec![],
};
let mut buf = Vec::new();
render_delta_markdown(&report, 30.0, None, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("`src/a.rs:7`"),
"non-moved row must show `<file>:<line>` location, got:\n{s}"
);
assert!(
s.contains("`src/new.rs:42` ← `src/old.rs`"),
"moved row must show `<new>:<line> ← <prev>` location, got:\n{s}"
);
}
}