diff-coverage 0.6.1

Diff-coverage, supercharged in Rust. Fast, memory-efficient coverage on changed lines for CI.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::Write;

use serde::Serialize;

use super::{CoverageReport, ReportGenerator};

pub struct GitlabReportGenerator;

#[derive(Serialize)]
struct CodeQualityIssue {
    description: String,
    fingerprint: String,
    severity: String,
    location: IssueLocation,
}

#[derive(Serialize)]
struct IssueLocation {
    path: String,
    lines: IssueLines,
}

#[derive(Serialize)]
struct IssueLines {
    begin: u32,
    end: u32,
}

pub fn render_report(report: &CoverageReport) -> Result<String, String> {
    let mut issues = Vec::new();

    for file in &report.uncovered_files {
        for line in &file.uncovered_lines {
            issues.push(CodeQualityIssue {
                description: format!("Uncovered changed line {line}."),
                fingerprint: fingerprint_for(&file.path, *line),
                severity: "minor".to_string(),
                location: IssueLocation {
                    path: file.path.clone(),
                    lines: IssueLines {
                        begin: *line,
                        end: *line,
                    },
                },
            });
        }
    }

    let mut payload = serde_json::to_string_pretty(&issues).map_err(|err| err.to_string())?;
    payload.push('\n');
    Ok(payload)
}

impl ReportGenerator for GitlabReportGenerator {
    fn write_report(&self, report: &CoverageReport, out: &mut dyn Write) -> Result<(), String> {
        let payload = render_report(report)?;
        out.write_all(payload.as_bytes())
            .map_err(|err| err.to_string())
    }
}

fn fingerprint_for(path: &str, line: u32) -> String {
    let mut hasher = DefaultHasher::new();
    path.hash(&mut hasher);
    line.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

#[cfg(test)]
mod tests {
    use super::GitlabReportGenerator;
    use crate::report::ReportGenerator;
    use crate::report::{CoverageReport, UncoveredFile};
    use serde_json::Value;

    #[test]
    fn writes_gitlab_code_quality_json() {
        let report = CoverageReport {
            total_changed: 2,
            total_covered: 0,
            uncovered_files: vec![UncoveredFile {
                path: "src/foo.rs".to_string(),
                uncovered_lines: vec![3, 7],
                covered_lines: 0,
                changed_lines: 2,
            }],
            skipped_files: Vec::new(),
        };

        let mut out = Vec::new();
        GitlabReportGenerator
            .write_report(&report, &mut out)
            .expect("write report");

        let content = String::from_utf8(out).expect("utf8");
        assert!(content.ends_with('\n'));
        let payload: Vec<Value> = serde_json::from_str(&content).expect("parse json");
        assert_eq!(payload.len(), 2);

        let first = payload[0].as_object().expect("issue object");
        let description = first
            .get("description")
            .and_then(Value::as_str)
            .expect("description");
        assert!(description.contains("Uncovered changed line"));
        let severity = first
            .get("severity")
            .and_then(Value::as_str)
            .expect("severity");
        assert_eq!(severity, "minor");

        let fingerprint = first
            .get("fingerprint")
            .and_then(Value::as_str)
            .expect("fingerprint");
        assert_eq!(fingerprint.len(), 16);
        assert!(fingerprint.chars().all(|ch| ch.is_ascii_hexdigit()));

        let location = first
            .get("location")
            .and_then(Value::as_object)
            .expect("location");
        let path_value = location
            .get("path")
            .and_then(Value::as_str)
            .expect("location path");
        assert_eq!(path_value, "src/foo.rs");
        let lines = location
            .get("lines")
            .and_then(Value::as_object)
            .expect("lines");
        assert_eq!(lines.get("begin").and_then(Value::as_u64), Some(3));
        assert_eq!(lines.get("end").and_then(Value::as_u64), Some(3));
    }
}