covrs 0.2.1

Code coverage ingestion and reporting
Documentation
//! Output formatting for diff coverage results.

use std::collections::HashMap;
use std::fmt::Write;

use crate::model::{rate, FileDiffCoverage};

/// Aggregated diff coverage data, ready to be formatted.
pub struct DiffCoverageReport {
    /// Number of added lines per file from the diff.
    pub diff_files: usize,
    /// Total number of added lines across all files.
    pub diff_lines: usize,
    /// Per-file coverage detail (only files with at least one instrumentable line).
    pub files: Vec<FileDiffCoverage>,
    /// Total instrumentable diff lines that are covered.
    pub total_covered: usize,
    /// Total instrumentable diff lines.
    pub total_instrumentable: usize,
    /// Overall project line coverage rate (if available).
    pub total_rate: Option<f64>,
    /// Commit SHA to display.
    pub sha: Option<String>,
}

impl DiffCoverageReport {
    /// Format using a specific formatter.
    #[must_use]
    pub fn format(&self, formatter: &dyn ReportFormatter) -> String {
        formatter.format(self)
    }

    /// Format as plain text.
    #[must_use]
    pub fn format_text(&self) -> String {
        self.format(&TextFormatter)
    }

    /// Format as markdown.
    #[must_use]
    pub fn format_markdown(&self) -> String {
        self.format(&MarkdownFormatter)
    }
}

/// Trait for formatting diff coverage reports.
pub trait ReportFormatter {
    /// Format the report to a string.
    fn format(&self, report: &DiffCoverageReport) -> String;
}

/// Plain text formatter.
pub struct TextFormatter;

impl ReportFormatter for TextFormatter {
    fn format(&self, report: &DiffCoverageReport) -> String {
        let mut out = String::new();

        if report.diff_files == 0 {
            out.push_str("No added lines found in diff.\n");
            return out;
        }

        if report.total_instrumentable == 0 {
            let lines = report.diff_lines;
            let files = report.diff_files;
            writeln!(
                out,
                "{lines} lines added across {files} files — none are instrumentable."
            )
            .unwrap();
            return out;
        }

        let pct = rate(
            report.total_covered as u64,
            report.total_instrumentable as u64,
        ) * 100.0;
        let covered = report.total_covered;
        let total = report.total_instrumentable;
        writeln!(
            out,
            "Diff coverage: {pct:.1}% ({covered}/{total} lines covered)"
        )
        .unwrap();

        let mut files_with_misses: Vec<_> = report
            .files
            .iter()
            .filter(|f| !f.missed_lines.is_empty())
            .collect();
        files_with_misses.sort_by(|a, b| a.rate().partial_cmp(&b.rate()).unwrap());
        if !files_with_misses.is_empty() {
            out.push('\n');
            for f in &files_with_misses {
                let file_total = f.total();
                let file_covered = f.covered_lines.len();
                let file_rate = f.rate() * 100.0;
                let path = &f.path;
                let missed = format_line_ranges(&f.missed_lines);
                writeln!(
                    out,
                    "  {path}  {file_covered}/{file_total} ({file_rate:.1}%)  missed: {missed}",
                )
                .unwrap();
            }
        }

        if let Some(rate) = report.total_rate {
            out.push('\n');
            let pct = rate * 100.0;
            writeln!(out, "Full project coverage: {pct:.1}%").unwrap();
        }

        out
    }
}

/// Markdown formatter.
pub struct MarkdownFormatter;

impl ReportFormatter for MarkdownFormatter {
    fn format(&self, report: &DiffCoverageReport) -> String {
        let mut md = String::new();

        let diff_rate = rate(
            report.total_covered as u64,
            report.total_instrumentable as u64,
        ) * 100.0;

        writeln!(md, "## Diff Coverage: {diff_rate:.1}%\n").unwrap();

        let covered = report.total_covered;
        let total = report.total_instrumentable;
        write!(md, "**{covered}** of **{total}** diff lines covered").unwrap();
        if let Some(ref sha) = report.sha {
            let short_sha = if sha.len() > 7 { &sha[..7] } else { sha };
            write!(md, " ({short_sha})").unwrap();
        }
        md.push('\n');

        let mut files_with_misses: Vec<&FileDiffCoverage> = report
            .files
            .iter()
            .filter(|f| !f.missed_lines.is_empty())
            .collect();
        files_with_misses.sort_by(|a, b| a.rate().partial_cmp(&b.rate()).unwrap());

        if files_with_misses.is_empty() {
            md.push_str("\nAll diff lines are covered! 🎉\n");
        } else {
            md.push_str("\n| File | Missed | Diff | \n");
            md.push_str("|:-----|-------:|------:|\n");

            for f in &files_with_misses {
                let file_rate = f.rate() * 100.0;
                let path = &f.path;
                let missed_count = f.missed_lines.len();
                writeln!(md, "| `{path}` | {missed_count} | {file_rate:.0}% |").unwrap();
            }

            md.push_str("\n<details>\n<summary>Missed lines</summary>\n\n");

            for f in &files_with_misses {
                let path = &f.path;
                let ranges = format_line_ranges(&f.missed_lines);
                writeln!(md, "**`{path}`**: {ranges}\n").unwrap();
            }

            md.push_str("</details>\n");
        }

        md.push('\n');
        if let Some(rate) = report.total_rate {
            let pct = rate * 100.0;
            writeln!(md, "<sub>Full project coverage: **{pct:.1}%**</sub>").unwrap();
        }
        md.push_str("<sub>[covrs](https://github.com/scttnlsn/covrs)</sub>\n");

        md
    }
}

/// Build a [`DiffCoverageReport`] from parsed diff lines and a database connection.
pub fn build_report(
    conn: &rusqlite::Connection,
    diff_lines: &HashMap<String, Vec<u32>>,
    sha: Option<&str>,
) -> anyhow::Result<DiffCoverageReport> {
    let diff_files = diff_lines.len();
    let diff_line_count: usize = diff_lines.values().map(|v| v.len()).sum();

    let (files, total_covered, total_instrumentable) = if diff_lines.is_empty() {
        (vec![], 0, 0)
    } else {
        crate::db::diff_coverage_detail(conn, diff_lines)?
    };

    let total_rate = match crate::db::get_summary(conn) {
        Ok(s) if s.total_lines > 0 => Some(s.line_rate()),
        Ok(_) => None,
        Err(e) => {
            eprintln!("Warning: could not compute project coverage: {e}");
            None
        }
    };

    Ok(DiffCoverageReport {
        diff_files,
        diff_lines: diff_line_count,
        files,
        total_covered,
        total_instrumentable,
        total_rate,
        sha: sha.map(|s| s.to_owned()),
    })
}

/// Format line numbers into compact range notation, e.g. "1, 3-5, 8".
#[must_use]
pub fn format_line_ranges(lines: &[u32]) -> String {
    if lines.is_empty() {
        return String::new();
    }

    let mut ranges: Vec<String> = Vec::new();
    let mut start = lines[0];
    let mut end = lines[0];

    for &line in &lines[1..] {
        if line == end + 1 {
            end = line;
        } else {
            if start == end {
                ranges.push(start.to_string());
            } else {
                ranges.push(format!("{start}-{end}"));
            }
            start = line;
            end = line;
        }
    }

    if start == end {
        ranges.push(start.to_string());
    } else {
        ranges.push(format!("{start}-{end}"));
    }

    ranges.join(", ")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_format_line_ranges_empty() {
        assert_eq!(format_line_ranges(&[]), "");
    }

    #[test]
    fn test_format_line_ranges_single() {
        assert_eq!(format_line_ranges(&[5]), "5");
    }

    #[test]
    fn test_format_line_ranges_consecutive() {
        assert_eq!(format_line_ranges(&[1, 2, 3]), "1-3");
    }

    #[test]
    fn test_format_line_ranges_mixed() {
        assert_eq!(format_line_ranges(&[1, 3, 4, 5, 10]), "1, 3-5, 10");
    }

    #[test]
    fn test_format_markdown_all_covered() {
        let report = DiffCoverageReport {
            diff_files: 1,
            diff_lines: 10,
            files: vec![],
            total_covered: 10,
            total_instrumentable: 10,
            total_rate: Some(0.85),
            sha: Some("abc1234def".to_string()),
        };
        let body = report.format_markdown();
        assert!(body.contains("Diff Coverage: 100.0%"));
        assert!(body.contains("All diff lines are covered!"));
        assert!(body.contains("85.0%"));
        assert!(body.contains("[covrs](https://github.com/scttnlsn/covrs)"));
        assert!(body.contains("abc1234"));
    }

    #[test]
    fn test_format_markdown_with_misses() {
        let report = DiffCoverageReport {
            diff_files: 1,
            diff_lines: 5,
            files: vec![FileDiffCoverage {
                path: "src/foo.rs".to_string(),
                covered_lines: vec![1, 2, 3],
                missed_lines: vec![5, 6],
            }],
            total_covered: 3,
            total_instrumentable: 5,
            total_rate: None,
            sha: None,
        };
        let body = report.format_markdown();
        assert!(body.contains("60.0%"));
        assert!(body.contains("src/foo.rs"));
        assert!(body.contains("5-6"));
        assert!(body.contains("Missed lines"));
    }

    #[test]
    fn test_format_with_trait() {
        let report = DiffCoverageReport {
            diff_files: 1,
            diff_lines: 5,
            files: vec![],
            total_covered: 5,
            total_instrumentable: 5,
            total_rate: None,
            sha: None,
        };

        // Test using the trait directly
        let text = report.format(&TextFormatter);
        assert!(text.contains("Diff coverage: 100.0%"));

        let md = report.format(&MarkdownFormatter);
        assert!(md.contains("Diff Coverage: 100.0%"));
    }
}