cpd-reporter 0.1.2

Output format reporters for cpd
Documentation
use crate::context::ReportContext;
use crate::reporter::{Reporter, ReporterError, ReporterOptions};
use cpd_core::models::CpdClone;
use std::{fs, path::Path};

pub struct MarkdownReporter {
    no_colors: bool,
}

fn stat_row(format: &str, row: &cpd_core::models::StatRow) -> String {
    format!(
        "| {} | {} | {} | {} | {} | {} ({:.2}%) | {} ({:.2}%) |",
        format,
        row.sources,
        row.lines,
        row.tokens,
        row.clones,
        row.duplicated_lines,
        row.percentage,
        row.duplicated_tokens,
        row.percentage_tokens,
    )
}

impl MarkdownReporter {
    pub fn new(opts: &ReporterOptions) -> Self {
        Self {
            no_colors: opts.no_colors,
        }
    }
}

impl Reporter for MarkdownReporter {
    fn name(&self) -> &str {
        "markdown"
    }

    fn report(
        &self,
        clones: &[CpdClone],
        ctx: &ReportContext,
        output_dir: &Path,
    ) -> Result<(), ReporterError> {
        fs::create_dir_all(output_dir)?;
        let path = output_dir.join("jscpd-report.md");

        let total = &ctx.stats.total;
        let format_count = ctx.stats.formats.len();

        let mut md = String::new();
        md.push_str("# Copy/paste detection report\n\n");
        md.push_str(&format!(
            "> Duplications detection: Found {} exact clones with {}({:.2}%) duplicated lines in {} ({} formats) files.\n\n",
            clones.len(),
            total.duplicated_lines,
            total.percentage,
            total.sources,
            format_count,
        ));

        let header = "| Format | Files analyzed | Total lines | Total tokens | Clones found | Duplicated lines | Duplicated tokens |";
        let sep = "|--------|---------------|-------------|--------------|--------------|------------------|-------------------|";
        md.push_str(&format!("{}\n{}\n", header, sep));

        let mut format_names: Vec<&str> = ctx.stats.formats.keys().map(|s| s.as_str()).collect();
        format_names.sort();
        for fmt in &format_names {
            if let Some(row) = ctx.stats.formats.get(*fmt) {
                md.push_str(&format!("{}\n", stat_row(fmt, row)));
            }
        }
        md.push_str(&format!("{}\n", stat_row("**Total:**", total)));

        fs::write(&path, md)?;

        let path_display = path.display();
        if self.no_colors {
            println!("Markdown report saved to {}", path_display);
        } else {
            println!("\x1b[32mMarkdown report saved to {}\x1b[39m", path_display);
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::ReportContext;
    use cpd_core::models::{StatRow, Statistics};
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::time::Duration;

    fn tmp_dir() -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "cpd-md-{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.subsec_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&dir).ok();
        dir
    }

    fn make_stats() -> Statistics {
        let mut formats = HashMap::new();
        formats.insert(
            "javascript".to_string(),
            StatRow {
                lines: 100,
                tokens: 500,
                sources: 5,
                clones: 2,
                duplicated_lines: 20,
                duplicated_tokens: 100,
                percentage: 20.0,
                percentage_tokens: 20.0,
            },
        );
        Statistics {
            total: StatRow {
                lines: 100,
                tokens: 500,
                sources: 5,
                clones: 2,
                duplicated_lines: 20,
                duplicated_tokens: 100,
                percentage: 20.0,
                percentage_tokens: 20.0,
            },
            formats,
            detection_date: "2026-01-01T00:00:00Z".to_string(),
        }
    }

    #[test]
    fn markdown_empty_clones_returns_ok() {
        let dir = tmp_dir();
        let opts = ReporterOptions::new(dir.clone());
        let reporter = MarkdownReporter::new(&opts);
        let stats = make_stats();
        let ctx = ReportContext::new(&stats, Duration::from_millis(100));
        let result = reporter.report(&[], &ctx, &dir);
        assert!(result.is_ok());
    }

    #[test]
    fn markdown_contains_detection_date() {
        let dir = tmp_dir();
        let opts = ReporterOptions::new(dir.clone());
        let reporter = MarkdownReporter::new(&opts);
        let stats = make_stats();
        let ctx = ReportContext::new(&stats, Duration::from_millis(100));
        reporter.report(&[], &ctx, &dir).unwrap();
        let content = std::fs::read_to_string(dir.join("jscpd-report.md")).unwrap();
        assert!(content.contains("Duplications detection"));
    }

    #[test]
    fn markdown_report_has_table_header() {
        let dir = tmp_dir();
        let opts = ReporterOptions::new(dir.clone());
        let reporter = MarkdownReporter::new(&opts);
        let stats = make_stats();
        let ctx = ReportContext::new(&stats, Duration::from_millis(100));
        reporter.report(&[], &ctx, &dir).unwrap();
        let content = std::fs::read_to_string(dir.join("jscpd-report.md")).unwrap();
        assert!(content.contains("Format"));
        assert!(content.contains("Files analyzed"));
        assert!(content.contains("Clones found"));
    }

    #[test]
    fn markdown_contains_format_row() {
        let dir = tmp_dir();
        let opts = ReporterOptions::new(dir.clone());
        let reporter = MarkdownReporter::new(&opts);
        let stats = make_stats();
        let ctx = ReportContext::new(&stats, Duration::from_millis(100));
        reporter.report(&[], &ctx, &dir).unwrap();
        let content = std::fs::read_to_string(dir.join("jscpd-report.md")).unwrap();
        assert!(content.contains("javascript"));
    }
}