use std::path::Path;
use super::lcov::{FunctionCoverage, LcovData};
#[derive(Debug, Clone, PartialEq)]
pub enum CoverageGap {
Precise {
uncovered_lines: Vec<usize>,
instrumented_lines: u32,
percentage: f64,
},
Estimated {
percentage: f64,
total_lines: u32,
estimated_uncovered: u32,
},
Unknown { total_lines: u32 },
}
impl CoverageGap {
pub fn format(&self) -> String {
match self {
CoverageGap::Precise {
uncovered_lines,
instrumented_lines,
percentage,
} => {
let count = uncovered_lines.len();
if count == 0 {
"Fully covered".to_string()
} else if count == 1 {
format!(
"1 line uncovered ({:.0}% gap) - line {}",
percentage, uncovered_lines[0]
)
} else {
format!(
"{} lines uncovered ({:.0}% gap of {} instrumented lines) - lines {}",
count,
percentage,
instrumented_lines,
format_line_ranges(uncovered_lines)
)
}
}
CoverageGap::Estimated {
percentage,
estimated_uncovered,
..
} => {
if *percentage >= 99.0 {
format!("~100% gap (estimated, {} lines)", estimated_uncovered)
} else if *percentage < 5.0 {
format!("~{}% gap (mostly covered)", *percentage as u32)
} else {
format!(
"~{}% gap (estimated, ~{} lines)",
*percentage as u32, estimated_uncovered
)
}
}
CoverageGap::Unknown { total_lines } => {
format!("Coverage data unavailable ({} lines)", total_lines)
}
}
}
pub fn percentage(&self) -> f64 {
match self {
CoverageGap::Precise { percentage, .. } => *percentage,
CoverageGap::Estimated { percentage, .. } => *percentage,
CoverageGap::Unknown { .. } => 100.0,
}
}
pub fn uncovered_count(&self) -> u32 {
match self {
CoverageGap::Precise {
uncovered_lines, ..
} => uncovered_lines.len() as u32,
CoverageGap::Estimated {
estimated_uncovered,
..
} => *estimated_uncovered,
CoverageGap::Unknown { total_lines } => *total_lines,
}
}
pub fn uncovered_lines(&self) -> Option<&[usize]> {
match self {
CoverageGap::Precise {
uncovered_lines, ..
} => Some(uncovered_lines),
_ => None,
}
}
}
fn format_line_ranges(lines: &[usize]) -> String {
if lines.is_empty() {
return String::new();
}
let mut sorted = lines.to_vec();
sorted.sort_unstable();
let mut ranges = vec![];
let mut range_start = sorted[0];
let mut range_end = sorted[0];
for &line in sorted.iter().skip(1) {
if line == range_end + 1 {
range_end = line;
} else {
if range_start == range_end {
ranges.push(format!("{}", range_start));
} else {
ranges.push(format!("{}-{}", range_start, range_end));
}
range_start = line;
range_end = line;
}
}
if range_start == range_end {
ranges.push(format!("{}", range_start));
} else {
ranges.push(format!("{}-{}", range_start, range_end));
}
ranges.join(", ")
}
#[derive(Debug, Clone, Default)]
pub struct LineCoverageData {
pub covered_lines: u32,
pub uncovered_lines: Vec<usize>,
}
pub fn calculate_coverage_gap(
coverage_pct: f64,
function_length: u32,
file: &Path,
function_name: &str,
start_line: usize,
coverage_data: Option<&LcovData>,
) -> CoverageGap {
if let Some(gap) = precise_coverage_gap(coverage_data, file, function_name, start_line) {
return gap;
}
estimated_coverage_gap(coverage_pct, function_length)
}
fn precise_coverage_gap(
coverage_data: Option<&LcovData>,
file: &Path,
function_name: &str,
start_line: usize,
) -> Option<CoverageGap> {
let data = coverage_data?;
let uncovered_lines = data.get_function_uncovered_lines(file, function_name, start_line)?;
let function = find_function_coverage(data, file, function_name, start_line)?;
Some(build_precise_gap(uncovered_lines, function))
}
fn find_function_coverage<'a>(
data: &'a LcovData,
file: &Path,
function_name: &str,
start_line: usize,
) -> Option<&'a FunctionCoverage> {
data.functions
.get(file)?
.iter()
.find(|function| function_matches(function, function_name, start_line))
}
fn function_matches(function: &FunctionCoverage, function_name: &str, start_line: usize) -> bool {
function.name == function_name || function.start_line == start_line
}
fn build_precise_gap(uncovered_lines: Vec<usize>, function: &FunctionCoverage) -> CoverageGap {
let uncovered_count = uncovered_lines.len();
let instrumented_lines = instrumented_lines(function, uncovered_count);
let percentage = gap_percentage(uncovered_count, instrumented_lines);
CoverageGap::Precise {
uncovered_lines,
instrumented_lines,
percentage,
}
}
fn instrumented_lines(function: &FunctionCoverage, uncovered_count: usize) -> u32 {
let known_uncovered = function.uncovered_lines.len() as u32;
let estimated_covered = (function.coverage_percentage / 100.0
* (function.uncovered_lines.len() as f64 + uncovered_count as f64))
as u32;
(known_uncovered + estimated_covered).max(uncovered_count as u32)
}
fn gap_percentage(uncovered_count: usize, instrumented_lines: u32) -> f64 {
match instrumented_lines {
0 => 0.0,
total => (uncovered_count as f64 / total as f64) * 100.0,
}
}
fn estimated_coverage_gap(coverage_pct: f64, function_length: u32) -> CoverageGap {
let percentage = (1.0 - coverage_pct) * 100.0;
let estimated_uncovered = (function_length as f64 * (percentage / 100.0)) as u32;
CoverageGap::Estimated {
percentage,
total_lines: function_length,
estimated_uncovered,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::risk::lcov::NormalizedFunctionName;
fn test_function_coverage(
coverage_percentage: f64,
uncovered_lines: Vec<usize>,
) -> FunctionCoverage {
FunctionCoverage {
name: "target".to_string(),
start_line: 10,
execution_count: 1,
coverage_percentage,
uncovered_lines,
normalized: NormalizedFunctionName::simple("target"),
}
}
#[test]
fn test_format_line_ranges_single() {
let lines = vec![52];
assert_eq!(format_line_ranges(&lines), "52");
}
#[test]
fn test_format_line_ranges_contiguous() {
let lines = vec![10, 11, 12];
assert_eq!(format_line_ranges(&lines), "10-12");
}
#[test]
fn test_format_line_ranges_mixed() {
let lines = vec![10, 11, 12, 15, 20, 21];
assert_eq!(format_line_ranges(&lines), "10-12, 15, 20-21");
}
#[test]
fn test_format_line_ranges_non_contiguous() {
let lines = vec![10, 15, 20];
assert_eq!(format_line_ranges(&lines), "10, 15, 20");
}
#[test]
fn test_format_line_ranges_empty() {
let lines: Vec<usize> = vec![];
assert_eq!(format_line_ranges(&lines), "");
}
#[test]
fn test_format_line_ranges_unsorted() {
let lines = vec![20, 10, 11, 15, 12];
assert_eq!(format_line_ranges(&lines), "10-12, 15, 20");
}
#[test]
fn test_coverage_gap_precise_single_line() {
let gap = CoverageGap::Precise {
uncovered_lines: vec![52],
instrumented_lines: 9,
percentage: 11.1,
};
assert_eq!(gap.format(), "1 line uncovered (11% gap) - line 52");
assert!((gap.percentage() - 11.1).abs() < 0.1);
assert_eq!(gap.uncovered_count(), 1);
assert_eq!(gap.uncovered_lines(), Some(&[52][..]));
}
#[test]
fn test_coverage_gap_precise_multiple_lines() {
let gap = CoverageGap::Precise {
uncovered_lines: vec![10, 11, 12, 15],
instrumented_lines: 20,
percentage: 20.0,
};
let formatted = gap.format();
assert!(formatted.contains("4 lines uncovered"));
assert!(formatted.contains("20% gap"));
assert!(formatted.contains("10-12, 15"));
assert_eq!(gap.uncovered_count(), 4);
}
#[test]
fn test_coverage_gap_precise_fully_covered() {
let gap = CoverageGap::Precise {
uncovered_lines: vec![],
instrumented_lines: 10,
percentage: 0.0,
};
assert_eq!(gap.format(), "Fully covered");
assert_eq!(gap.percentage(), 0.0);
assert_eq!(gap.uncovered_count(), 0);
}
#[test]
fn test_coverage_gap_estimated() {
let gap = CoverageGap::Estimated {
percentage: 50.0,
total_lines: 20,
estimated_uncovered: 10,
};
assert!(gap.format().contains("~50% gap"));
assert!(gap.format().contains("~10 lines"));
assert_eq!(gap.percentage(), 50.0);
assert_eq!(gap.uncovered_count(), 10);
assert_eq!(gap.uncovered_lines(), None);
}
#[test]
fn test_coverage_gap_estimated_high() {
let gap = CoverageGap::Estimated {
percentage: 99.5,
total_lines: 20,
estimated_uncovered: 20,
};
assert!(gap.format().contains("~100% gap"));
assert_eq!(gap.percentage(), 99.5);
}
#[test]
fn test_coverage_gap_estimated_low() {
let gap = CoverageGap::Estimated {
percentage: 3.0,
total_lines: 100,
estimated_uncovered: 3,
};
assert!(gap.format().contains("~3% gap"));
assert!(gap.format().contains("mostly covered"));
}
#[test]
fn test_coverage_gap_unknown() {
let gap = CoverageGap::Unknown { total_lines: 15 };
assert!(gap.format().contains("Coverage data unavailable"));
assert!(gap.format().contains("15 lines"));
assert_eq!(gap.percentage(), 100.0);
assert_eq!(gap.uncovered_count(), 15);
assert_eq!(gap.uncovered_lines(), None);
}
#[test]
fn test_estimated_coverage_gap_from_function_percentage() {
let gap = estimated_coverage_gap(0.75, 40);
assert_eq!(
gap,
CoverageGap::Estimated {
percentage: 25.0,
total_lines: 40,
estimated_uncovered: 10,
}
);
}
#[test]
fn test_build_precise_gap_uses_instrumented_lines() {
let function = test_function_coverage(50.0, vec![12, 13]);
let gap = build_precise_gap(vec![12, 13], &function);
assert_eq!(
gap,
CoverageGap::Precise {
uncovered_lines: vec![12, 13],
instrumented_lines: 4,
percentage: 50.0,
}
);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn gap_percentage_always_between_0_and_100(
uncovered in 0u32..100,
covered in 0u32..100,
) {
let total = uncovered + covered;
if total == 0 {
return Ok(()); }
let percentage = (uncovered as f64 / total as f64) * 100.0;
prop_assert!((0.0..=100.0).contains(&percentage));
}
#[test]
fn gap_formatting_never_panics(
uncovered_lines in prop::collection::vec(1usize..1000, 0..50),
total in 1u32..100,
) {
let gap = CoverageGap::Precise {
uncovered_lines: uncovered_lines.clone(),
instrumented_lines: total,
percentage: (uncovered_lines.len() as f64 / total as f64) * 100.0,
};
let formatted = gap.format();
prop_assert!(!formatted.is_empty());
}
#[test]
fn zero_uncovered_lines_reports_full_coverage(
total in 1u32..100,
) {
let gap = CoverageGap::Precise {
uncovered_lines: vec![],
instrumented_lines: total,
percentage: 0.0,
};
prop_assert!(gap.format().contains("Fully covered"));
}
#[test]
fn all_lines_uncovered_reports_100_percent(
line_count in 1u32..50,
) {
let uncovered: Vec<usize> = (1..=line_count as usize).collect();
let gap = CoverageGap::Precise {
uncovered_lines: uncovered.clone(),
instrumented_lines: line_count,
percentage: 100.0,
};
let formatted = gap.format();
prop_assert!(formatted.contains("100"));
}
#[test]
fn line_range_formatting_stable(
mut lines in prop::collection::vec(1usize..1000, 1..30),
) {
lines.sort_unstable();
lines.dedup();
if lines.is_empty() {
return Ok(());
}
let formatted = format_line_ranges(&lines);
prop_assert!(formatted.contains(&lines[0].to_string()));
prop_assert!(!formatted.contains("..")); }
}
}