use super::structured::summary_line;
use super::{DeadCodeContext, DeadCodeReporter, group_by_category, location, reasons_clause};
use crate::reporter::{ReporterError, ReporterOptions};
use crate::shared::{Style, write_report_file};
use cpd_core::deadcode::Finding;
use std::fmt::Write;
use std::path::Path;
pub struct MarkdownReporter {
style: Style,
}
impl MarkdownReporter {
pub fn new(options: &ReporterOptions) -> Self {
Self {
style: Style::new(options.no_colors),
}
}
}
impl DeadCodeReporter for MarkdownReporter {
fn name(&self) -> &str {
"markdown"
}
fn report(
&self,
findings: &[Finding],
ctx: &DeadCodeContext<'_>,
output_dir: &Path,
) -> Result<(), ReporterError> {
let mut out = String::from("# Dead code report\n\n");
if findings.is_empty() {
out.push_str("No dead code found.\n\n");
}
let _ = writeln!(out, "{}\n", summary_line(ctx.stats));
if !findings.is_empty() {
out.push_str("| Category | Findings | Lines |\n|---|---:|---:|\n");
for (category, group) in group_by_category(findings) {
let lines: u32 = group.iter().map(|f| f.lines).sum();
let _ = writeln!(
out,
"| {} | {} | {} |",
category.title(),
group.len(),
lines
);
}
out.push('\n');
for (category, group) in group_by_category(findings) {
let _ = writeln!(out, "## {} ({})\n", category.title(), group.len());
out.push_str(
"| Location | Name | Confidence | Why not certain |\n|---|---|---|---|\n",
);
for finding in group {
let reasons = reasons_clause(finding);
let _ = writeln!(
out,
"| `{}` | {} | {}% | {} |",
location(finding),
if finding.name.is_empty() {
"—".to_string()
} else {
format!("`{}`", escape_pipes(&finding.name))
},
finding.confidence,
if reasons.is_empty() {
"—".to_string()
} else {
escape_pipes(&reasons)
},
);
}
out.push('\n');
}
}
let _ = writeln!(
out,
"<sub>Generated by [basta](https://jscpd.dev) on {}.</sub>",
ctx.stats.detection_date
);
write_report_file(
output_dir,
"basta-report.md",
out,
&self.style,
"Dead code Markdown",
)?;
Ok(())
}
}
pub struct AiReporter {
limit: usize,
}
impl AiReporter {
pub fn new(_options: &ReporterOptions) -> Self {
Self { limit: 200 }
}
}
impl DeadCodeReporter for AiReporter {
fn name(&self) -> &str {
"ai"
}
fn report(
&self,
findings: &[Finding],
ctx: &DeadCodeContext<'_>,
_output_dir: &Path,
) -> Result<(), ReporterError> {
if findings.is_empty() {
println!("basta: no dead code found in {} files.", ctx.stats.files);
return Ok(());
}
println!("basta dead code report — {}", summary_line(ctx.stats));
println!(
"Confidence is 0-100; anything below 90 has a listed reason it may be wrong. \
Verify a finding before deleting the code."
);
for finding in findings.iter().take(self.limit) {
let reasons = reasons_clause(finding);
println!(
"{} {} confidence={} {}{}",
finding.category.as_str(),
location(finding),
finding.confidence,
finding.message,
if reasons.is_empty() {
String::new()
} else {
format!(" (uncertain: {reasons})")
}
);
}
if findings.len() > self.limit {
println!(
"... and {} more; re-run with -r json for the full list.",
findings.len() - self.limit
);
}
Ok(())
}
}
pub struct SilentReporter;
impl DeadCodeReporter for SilentReporter {
fn name(&self) -> &str {
"silent"
}
fn report(
&self,
_findings: &[Finding],
_ctx: &DeadCodeContext<'_>,
_output_dir: &Path,
) -> Result<(), ReporterError> {
Ok(())
}
}
fn escape_pipes(text: &str) -> String {
text.replace('|', "\\|")
}
#[cfg(test)]
mod tests {
use super::super::fixtures;
use super::*;
use cpd_core::deadcode::{Category, Stats};
use std::path::PathBuf;
use std::time::Duration;
fn markdown(findings: &[Finding], stats: &Stats) -> String {
let dir = fixtures::unique_dir("markdown");
let options = ReporterOptions::new(PathBuf::from("/tmp"));
let ctx = DeadCodeContext::new(stats, Duration::ZERO);
MarkdownReporter::new(&options)
.report(findings, &ctx, &dir)
.unwrap();
let content = std::fs::read_to_string(dir.join("basta-report.md")).unwrap();
std::fs::remove_dir_all(&dir).ok();
content
}
#[test]
fn markdown_has_a_summary_table_and_a_section_per_category() {
let stats = fixtures::stats();
let md = markdown(&fixtures::findings(), &stats);
assert!(md.starts_with("# Dead code report"));
assert!(md.contains("| Category | Findings | Lines |"), "{md}");
assert!(md.contains("## Unused files (1)"), "{md}");
assert!(md.contains("## Unused exports (1)"), "{md}");
assert!(md.contains("`src/api.ts:10:1`"), "{md}");
}
#[test]
fn markdown_says_so_plainly_when_there_is_nothing_to_report() {
let stats = Stats::default();
let md = markdown(&[], &stats);
assert!(md.contains("No dead code found."), "{md}");
assert!(!md.contains("| Category |"), "no empty tables: {md}");
}
#[test]
fn a_pipe_in_a_message_does_not_break_the_table() {
let mut finding = fixtures::finding(Category::UnusedExport, "a.ts", "a|b", 85);
finding.reasons.clear();
let stats = fixtures::stats();
let md = markdown(&[finding], &stats);
assert!(md.contains(r"`a\|b`"), "{md}");
for line in md.lines().filter(|l| l.starts_with("| `a")) {
assert_eq!(
line.matches('|').count() - line.matches(r"\|").count(),
5,
"an escaped pipe must not add a column: {line}"
);
}
}
#[test]
fn the_ai_report_states_its_own_uncertainty() {
let stats = fixtures::stats();
let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
let options = ReporterOptions::new(PathBuf::from("/tmp"));
assert!(
AiReporter::new(&options)
.report(&fixtures::findings(), &ctx, Path::new("/tmp"))
.is_ok()
);
}
#[test]
fn the_ai_report_caps_how_much_it_prints() {
let findings: Vec<Finding> = (0..500)
.map(|i| fixtures::finding(Category::UnusedExport, &format!("src/f{i}.ts"), "x", 90))
.collect();
let stats = fixtures::stats();
let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
let options = ReporterOptions::new(PathBuf::from("/tmp"));
let reporter = AiReporter::new(&options);
assert_eq!(reporter.limit, 200);
assert!(reporter.report(&findings, &ctx, Path::new("/tmp")).is_ok());
}
#[test]
fn the_silent_reporter_writes_nothing_and_succeeds() {
let stats = fixtures::stats();
let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
assert!(
SilentReporter
.report(&fixtures::findings(), &ctx, Path::new("/nonexistent"))
.is_ok(),
"silence cannot fail, even with nowhere to write"
);
}
}