cpd-reporter 0.1.21

Output format reporters for cpd
Documentation
//! Prose-shaped dead-code reports: Markdown for a pull request, a compact
//! form for a language model, and silence for when the exit code is the
//! whole answer.

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;

/// A Markdown report, shaped to be pasted into a pull request.
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(())
    }
}

/// A compact report for a language model: every finding on one line, no
/// decoration, and the instruction a model needs to act on it safely.
pub struct AiReporter {
    limit: usize,
}

impl AiReporter {
    pub fn new(_options: &ReporterOptions) -> Self {
        // A model reading this has a context budget; a 5000-finding dump helps
        // nobody. The count in the trailer says how much was cut.
        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(())
    }
}

/// Writes nothing. The exit code carries the result.
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(())
    }
}

/// A `|` inside a Markdown table cell ends the cell, so it has to be escaped
/// even though it is legal in an identifier in neither language basta reads —
/// a message or a reason can still contain one.
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"
        );
    }
}