diff-coverage 0.6.1

Diff-coverage, supercharged in Rust. Fast, memory-efficient coverage on changed lines for CI.
use std::io::{BufWriter, Write};

use owo_colors::OwoColorize;

use super::{CoverageReport, ReportGenerator};

pub fn render_to<W: Write + ?Sized>(
    report: &CoverageReport,
    out: &mut W,
    use_color: bool,
) -> std::io::Result<()> {
    let percent = report.coverage_percent();
    let percent_text = format!("{percent:.2}%");
    let percent_display = if use_color {
        colorize_percent(&percent_text, percent)
    } else {
        percent_text
    };

    let mut out = BufWriter::new(out);
    writeln!(
        out,
        "Changed lines covered: {}/{}",
        report.total_covered, report.total_changed
    )?;

    if report.uncovered_files.is_empty() {
        let message = "All changed lines are covered.";
        if use_color {
            writeln!(out, "{}", message.green().bold())?;
        } else {
            writeln!(out, "{message}")?;
        }
    } else {
        writeln!(out)?;
        writeln!(out, "Uncovered files:")?;
        for file in &report.uncovered_files {
            let percent = if file.changed_lines == 0 {
                100.0
            } else {
                (file.covered_lines as f64 / file.changed_lines as f64) * 100.0
            };
            let percent_text = format!("{percent:.2}%");
            let percent_display = if use_color {
                colorize_percent(&percent_text, percent)
            } else {
                percent_text
            };
            let lines = format_line_ranges(&file.uncovered_lines);
            writeln!(out, "{} ({}): {}", file.path, percent_display, lines)?;
        }
    }

    if !report.skipped_files.is_empty() {
        writeln!(out)?;
        let header =
            "Skipped paths from uncovered line calculation (matched --skip-coverage-path):";
        if use_color {
            writeln!(out, "{}", header.yellow().bold())?;
        } else {
            writeln!(out, "{header}")?;
        }
        for skipped in &report.skipped_files {
            writeln!(out, "- {} (matched {})", skipped.path, skipped.pattern)?;
        }
    }

    writeln!(out)?;
    writeln!(out, "-------------")?;
    writeln!(out, "Coverage for changed lines: {percent_display}")?;
    out.flush()
}

pub struct CliReportGenerator {
    pub use_color: bool,
}

impl ReportGenerator for CliReportGenerator {
    fn write_report(&self, report: &CoverageReport, out: &mut dyn Write) -> Result<(), String> {
        render_to(report, out, self.use_color).map_err(|err| err.to_string())
    }
}

pub(crate) fn colorize_percent(text: &str, percent: f64) -> String {
    if percent >= 90.0 {
        text.green().bold().to_string()
    } else if percent >= 70.0 {
        text.yellow().bold().to_string()
    } else {
        text.red().bold().to_string()
    }
}

fn format_line_ranges(lines: &[u32]) -> String {
    if lines.is_empty() {
        return String::new();
    }

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

    for &line in lines.iter().skip(1) {
        if line == end + 1 {
            end = line;
        } else {
            push_range(&mut ranges, start, end);
            start = line;
            end = line;
        }
    }

    push_range(&mut ranges, start, end);
    ranges.join(", ")
}

fn push_range(ranges: &mut Vec<String>, start: u32, end: u32) {
    if start == end {
        ranges.push(start.to_string());
    } else {
        ranges.push(format!("{start}-{end}"));
    }
}

#[cfg(test)]
mod tests {
    use super::{format_line_ranges, render_to};
    use crate::report::{CoverageReport, SkippedFile};

    #[test]
    fn formats_line_ranges() {
        assert_eq!(format_line_ranges(&[1, 3, 5]), "1, 3, 5");
        assert_eq!(format_line_ranges(&[1, 2, 5]), "1-2, 5");
        assert_eq!(format_line_ranges(&[1, 2, 3, 5]), "1-3, 5");
        assert_eq!(format_line_ranges(&[1, 3, 5, 6, 7, 8, 9]), "1, 3, 5-9");
    }

    #[test]
    fn includes_skipped_paths() {
        let report = CoverageReport {
            total_changed: 1,
            total_covered: 1,
            uncovered_files: Vec::new(),
            skipped_files: vec![SkippedFile {
                path: "pkg/mongodb/client.go".to_string(),
                pattern: "^pkg/mongodb".to_string(),
            }],
        };

        let mut out = Vec::new();
        render_to(&report, &mut out, false).expect("render");
        let text = String::from_utf8(out).expect("utf8");

        assert!(text.contains("Skipped paths"));
        assert!(text.contains("pkg/mongodb/client.go"));
        assert!(text.contains("^pkg/mongodb"));
    }
}