Test Coverage Analyzer
Problem Statement
Build a test coverage analyzer that instruments Rust code to track which lines, branches, and functions are executed during test runs. Your analyzer should parse Rust source code, inject coverage tracking instrumentation, run tests, and generate detailed coverage reports showing which code paths are tested and which are not.
Your coverage analyzer should support:
- Line coverage tracking (which lines were executed)
- Branch coverage tracking (which if/match branches were taken)
- Function coverage (which functions were called)
- Coverage report generation (text, HTML, JSON formats)
- Highlighting untested code paths
- Integration with cargo test
Why Coverage Analysis Matters
The Testing Blind Spot
The Problem: You can have hundreds of tests and still miss critical bugs because you don’t know which code paths are untested. Without coverage analysis, you’re flying blind—tests might all pass while large portions of your codebase remain unexercised.
Real-world example:
#![allow(unused)]
fn main() {
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err("Division by zero".to_string()) // ← Never tested!
} else {
Ok(a / b)
}
}
#[test]
fn test_divide() {
assert_eq!(divide(10, 2), Ok(5)); // Only tests success path
}
// Test passes ✓ but error handling is completely untested!
}
Coverage Metrics Explained
Line Coverage: Percentage of executable lines that were run
Total lines: 100
Lines executed: 75
Line coverage: 75%
Branch Coverage: Percentage of decision branches (if/match) that were taken
if condition {
// Branch A
} else {
// Branch B ← Not tested
}
// Branch coverage: 50% (only A tested)
Function Coverage: Percentage of functions that were called
Total functions: 20
Functions called: 18
Function coverage: 90%
Why It Matters
Confidence vs Reality Gap:
- 100% passing tests != 100% working code
- Un-tested error paths are where production bugs hide
- Security vulnerabilities often lurk in untested branches
Example Impact:
Project A: 500 tests, 60% coverage
→ 40% of code never executed by tests
→ Production bugs: 12 per release
Project B: 300 tests, 95% coverage
→ Only 5% of code untested
→ Production bugs: 2 per release
6x reduction in bugs with better coverage!
Optimization Guide: Coverage reveals dead code
#![allow(unused)]
fn main() {
// Coverage shows this function is NEVER called
fn obsolete_feature() { // 0% coverage
// ... 500 lines of complex logic
}
// Can safely delete → smaller binary, faster compile
}
Use Cases
1. Development Workflow
- Find blind spots: Identify untested error paths before code review
- Regression prevention: Ensure new features have adequate tests
- Refactoring safety: Verify tests cover code being refactored
2. CI/CD Integration
- Quality gates: Fail build if coverage drops below threshold (e.g., 80%)
- PR checks: Show coverage diff for pull requests
- Trend tracking: Monitor coverage over time
3. Code Quality Assessment
- Tech debt identification: Low-coverage modules need attention
- Test quality metrics: High line coverage + low branch coverage = weak tests
- Security audits: Ensure security-critical paths are tested
4. Legacy Code Modernization
- Baseline establishment: Measure starting point before adding tests
- Incremental improvement: Track progress as tests are added
- Hot spot identification: Focus testing effort on high-complexity, low-coverage areas
Building the Project
Milestone 1: Source Code Parser
Goal: Parse Rust source files to extract functions, statements, and branch points that need coverage tracking.
Why we start here: Before we can track coverage, we need to understand the code structure. This milestone teaches basic parsing and AST (Abstract Syntax Tree) representation.
Architecture
Structs:
-
SourceFile- Represents a parsed Rust source file- Field:
path: PathBuf- File location - Field:
lines: Vec<String>- Source code lines - Field:
functions: Vec<FunctionInfo>- Parsed functions
- Field:
-
FunctionInfo- Information about a function- Field:
name: String- Function name - Field:
start_line: usize- First line of function - Field:
end_line: usize- Last line of function - Field:
statements: Vec<usize>- Line numbers of executable statements
- Field:
Functions:
parse_file(path: &Path) -> Result<SourceFile, Error>- Parse source filefind_functions(&self) -> Vec<FunctionInfo>- Extract function definitionsfind_statements(&self, start: usize, end: usize) -> Vec<usize>- Find executable linesis_executable(line: &str) -> bool- Check if line is executable (not comment/blank)
Starter Code:
#![allow(unused)]
fn main() {
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SourceFile {
pub path: PathBuf,
pub lines: Vec<String>,
pub functions: Vec<FunctionInfo>,
}
#[derive(Debug, Clone)]
pub struct FunctionInfo {
pub name: String,
pub start_line: usize,
pub end_line: usize,
pub statements: Vec<usize>,
}
impl SourceFile {
/// Parse a Rust source file
pub fn parse_file(path: &Path) -> Result<Self, std::io::Error> {
// TODO: Read file contents
// TODO: Split into lines
// TODO: Find all functions
// TODO: For each function, find executable statements
todo!("Implement file parsing")
}
fn find_functions(&self) -> Vec<FunctionInfo> {
// TODO: Search for "fn " patterns
// TODO: Track brace depth to find function end
// TODO: Extract function name
todo!("Implement function finding")
}
fn find_statements(&self, start: usize, end: usize) -> Vec<usize> {
// TODO: Iterate lines in function
// TODO: Filter out comments, blank lines, braces-only
// TODO: Return line numbers of executable statements
todo!("Implement statement finding")
}
fn is_executable(line: &str) -> bool {
// TODO: Trim whitespace
// TODO: Check if empty or comment-only
// TODO: Check if brace-only
todo!("Implement executable check")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_test_file(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file
}
#[test]
fn test_parse_simple_function() {
let content = r#"
fn add(a: i32, b: i32) -> i32 {
let result = a + b;
result
}
"#;
let file = create_test_file(content);
let source = SourceFile::parse_file(file.path()).unwrap();
assert_eq!(source.functions.len(), 1);
assert_eq!(source.functions[0].name, "add");
}
#[test]
fn test_multiple_functions() {
let content = r#"
fn foo() {
println!("foo");
}
fn bar() -> i32 {
42
}
"#;
let file = create_test_file(content);
let source = SourceFile::parse_file(file.path()).unwrap();
assert_eq!(source.functions.len(), 2);
}
#[test]
fn test_find_executable_lines() {
let content = r#"
fn test() {
// This is a comment
let x = 5;
let y = 10; // Executable with comment
}
"#;
let file = create_test_file(content);
let source = SourceFile::parse_file(file.path()).unwrap();
let func = &source.functions[0];
// Should find 2 let statements, not comments or blank lines
assert_eq!(func.statements.len(), 2);
}
#[test]
fn test_is_executable() {
assert!(SourceFile::is_executable("let x = 5;"));
assert!(SourceFile::is_executable(" return value;"));
assert!(!SourceFile::is_executable("// comment"));
assert!(!SourceFile::is_executable(""));
assert!(!SourceFile::is_executable(" "));
assert!(!SourceFile::is_executable("{"));
assert!(!SourceFile::is_executable("}"));
}
}
}
Check Your Understanding:
- Why do we track line numbers instead of just counting statements?
- What makes a line “executable” vs non-executable?
- Why do we need to track function boundaries?
Why Milestone 1 Isn’t Enough
Limitation: We can identify code structure, but we can’t track which lines actually execute during tests. We need instrumentation.
What we’re adding: Code instrumentation—injecting tracking calls into the source code so we can record execution at runtime.
Improvement:
- Capability: Can now track actual execution, not just structure
- Approach: Insert
record_line(N)calls before each executable statement - Challenge: Must preserve original line numbers for accurate reporting
Milestone 2: Code Instrumentation
Goal: Inject coverage tracking calls into source code without breaking it.
Why we need this: To track execution, we need to add recording statements. But naive injection can break the code by changing semantics or line numbers.
Architecture
Structs:
-
Instrumentor- Handles code instrumentation- Field:
coverage_map: Arc<Mutex<HashSet<usize>>>- Tracks executed lines
- Field:
-
InstrumentedCode- Result of instrumentation- Field:
original: SourceFile- Original source - Field:
instrumented: String- Instrumented code - Field:
line_mapping: HashMap<usize, usize>- New→ original line mapping
- Field:
Functions:
new() -> Instrumentor- Create instrumentor with shared coverage mapinstrument(&self, source: &SourceFile) -> InstrumentedCode- Inject trackingrecord_line(line: usize)- Runtime function to record executioninject_probe(line: &str, line_num: usize) -> String- Create tracking statement
Starter Code:
#![allow(unused)]
fn main() {
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
lazy_static::lazy_static! {
static ref COVERAGE_DATA: Arc<Mutex<HashSet<usize>>> =
Arc::new(Mutex::new(HashSet::new()));
}
pub struct Instrumentor {
coverage_map: Arc<Mutex<HashSet<usize>>>,
}
pub struct InstrumentedCode {
pub original: SourceFile,
pub instrumented: String,
pub line_mapping: HashMap<usize, usize>,
}
impl Instrumentor {
pub fn new() -> Self {
// TODO: Initialize with shared coverage map
todo!("Create instrumentor")
}
pub fn instrument(&self, source: &SourceFile) -> InstrumentedCode {
// TODO: For each executable line, inject record_line() call
// TODO: Build line mapping (instrumented -> original)
// TODO: Preserve original structure
todo!("Implement instrumentation")
}
fn inject_probe(line: &str, line_num: usize) -> String {
// TODO: Create line like: _coverage_record(42); original_line
// TODO: Preserve indentation
todo!("Create probe injection")
}
pub fn get_coverage(&self) -> HashSet<usize> {
// TODO: Return clone of executed lines
todo!("Return coverage data")
}
pub fn reset(&self) {
// TODO: Clear coverage data for next run
todo!("Reset coverage")
}
}
/// Runtime function called by instrumented code
pub fn record_line(line: usize) {
COVERAGE_DATA.lock().unwrap().insert(line);
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_instrument_simple_function() {
let source = SourceFile {
path: PathBuf::from("test.rs"),
lines: vec![
"fn add(a: i32, b: i32) -> i32 {".to_string(),
" let sum = a + b;".to_string(),
" sum".to_string(),
"}".to_string(),
],
functions: vec![FunctionInfo {
name: "add".to_string(),
start_line: 0,
end_line: 3,
statements: vec![1, 2],
}],
};
let instrumentor = Instrumentor::new();
let instrumented = instrumentor.instrument(&source);
// Should contain tracking calls
assert!(instrumented.instrumented.contains("record_line"));
// Should have mapping for each instrumented line
assert!(instrumented.line_mapping.len() > 0);
}
#[test]
fn test_preserve_indentation() {
let line = " let x = 5;";
let probe = Instrumentor::inject_probe(line, 10);
// Probe should maintain indentation
assert!(probe.starts_with(" "));
assert!(probe.contains("record_line(10)"));
}
#[test]
fn test_coverage_tracking() {
let instrumentor = Instrumentor::new();
instrumentor.reset();
record_line(5);
record_line(10);
record_line(5); // Duplicate
let coverage = instrumentor.get_coverage();
assert_eq!(coverage.len(), 2);
assert!(coverage.contains(&5));
assert!(coverage.contains(&10));
}
#[test]
fn test_reset_coverage() {
let instrumentor = Instrumentor::new();
record_line(1);
instrumentor.reset();
assert_eq!(instrumentor.get_coverage().len(), 0);
}
}
}
Why Milestone 2 Isn’t Enough
Limitation: We can instrument and track line execution, but we don’t track branches (if/else, match arms). This misses critical test gaps.
Example:
#![allow(unused)]
fn main() {
fn abs(x: i32) -> i32 {
if x < 0 { // Line covered ✓
-x // Branch NOT tested ✗
} else {
x // Branch tested ✓
}
}
// Line coverage: 100%, Branch coverage: 50%
}
What we’re adding: Branch tracking to detect which decision paths are exercised.
Improvement:
- Capability: Track both true and false branches of conditionals
- Metric: Branch coverage = (branches_taken / total_branches) * 100
- Insight: Can have 100% line coverage with 0% branch coverage of critical logic
Milestone 3: Branch Coverage Tracking
Goal: Track which branches of if/match statements are executed during tests.
Why this matters: Branch coverage reveals untested code paths that line coverage misses. Error handling, edge cases, and conditional logic are often only testable via branch coverage.
Architecture
Structs:
-
Branch- Represents a decision branch- Field:
line: usize- Line number of branch - Field:
branch_id: usize- Unique identifier - Field:
kind: BranchKind- Type of branch (if/else/match) - Field:
taken: bool- Whether this branch executed
- Field:
-
BranchKind- Type of branch- Variants:
IfTrue,IfFalse,MatchArm(usize)
- Variants:
Functions:
find_branches(source: &SourceFile) -> Vec<Branch>- Identify all branchesinstrument_branches(&mut self, branches: &[Branch])- Inject branch trackingrecord_branch(branch_id: usize)- Runtime branch recordingget_branch_coverage(&self) -> (usize, usize)- Return (taken, total)
Starter Code:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchKind {
IfTrue,
IfFalse,
MatchArm(usize),
}
#[derive(Debug, Clone)]
pub struct Branch {
pub line: usize,
pub branch_id: usize,
pub kind: BranchKind,
pub taken: bool,
}
lazy_static::lazy_static! {
static ref BRANCH_DATA: Arc<Mutex<HashSet<usize>>> =
Arc::new(Mutex::new(HashSet::new()));
}
impl Instrumentor {
pub fn find_branches(&self, source: &SourceFile) -> Vec<Branch> {
// TODO: Scan for "if " patterns
// TODO: Scan for "match " patterns
// TODO: Assign unique branch IDs
// TODO: Identify true/false branches for if
// TODO: Identify match arms
todo!("Find all branches")
}
pub fn instrument_branches(&mut self, branches: &[Branch]) -> String {
// TODO: For each if statement, inject:
// if condition { record_branch(ID_TRUE); ... }
// else { record_branch(ID_FALSE); ... }
// TODO: For each match arm, inject record_branch(ID_ARM_N)
todo!("Instrument branches")
}
pub fn get_branch_coverage(&self) -> (usize, usize) {
// TODO: Count how many unique branch IDs were recorded
// TODO: Return (branches_taken, total_branches)
todo!("Calculate branch coverage")
}
}
pub fn record_branch(branch_id: usize) {
BRANCH_DATA.lock().unwrap().insert(branch_id);
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_if_branches() {
let content = r#"
fn check(x: i32) -> bool {
if x > 0 {
true
} else {
false
}
}
"#;
let file = create_test_file(content);
let source = SourceFile::parse_file(file.path()).unwrap();
let instrumentor = Instrumentor::new();
let branches = instrumentor.find_branches(&source);
// Should find 2 branches: if-true and if-false
assert_eq!(branches.len(), 2);
assert!(branches.iter().any(|b| b.kind == BranchKind::IfTrue));
assert!(branches.iter().any(|b| b.kind == BranchKind::IfFalse));
}
#[test]
fn test_find_match_branches() {
let content = r#"
fn classify(x: i32) -> &'static str {
match x {
0 => "zero",
1..=10 => "small",
_ => "large",
}
}
"#;
let file = create_test_file(content);
let source = SourceFile::parse_file(file.path()).unwrap();
let instrumentor = Instrumentor::new();
let branches = instrumentor.find_branches(&source);
// Should find 3 match arms
assert_eq!(branches.len(), 3);
}
#[test]
fn test_branch_coverage_calculation() {
let instrumentor = Instrumentor::new();
// Simulate 3 total branches, 2 taken
record_branch(1);
record_branch(2);
// Branch 3 not taken
let (taken, total) = instrumentor.get_branch_coverage();
assert_eq!(taken, 2);
// Note: total must be passed in or tracked separately
}
#[test]
fn test_branch_instrumentation() {
let content = r#"
fn abs(x: i32) -> i32 {
if x < 0 {
-x
} else {
x
}
}
"#;
let file = create_test_file(content);
let source = SourceFile::parse_file(file.path()).unwrap();
let mut instrumentor = Instrumentor::new();
let branches = instrumentor.find_branches(&source);
let instrumented = instrumentor.instrument_branches(&branches);
// Should contain branch recording calls
assert!(instrumented.contains("record_branch"));
}
}
}
Why Milestone 3 Isn’t Enough
Limitation: We collect coverage data but have no way to visualize it. Raw numbers like “75% coverage” don’t tell you which lines are untested.
What we’re adding: Coverage report generation in multiple formats (text, HTML, JSON) with visual highlighting of tested/untested code.
Improvement:
- Capability: Human-readable reports with color coding
- Formats: Terminal output (with ANSI colors), HTML (for browsers), JSON (for tools)
- Actionability: Developers can immediately see what needs testing
Milestone 4: Coverage Report Generation
Goal: Generate comprehensive coverage reports showing tested and untested code paths.
Why this matters: Coverage data is useless without good reporting. Developers need to quickly identify gaps and prioritize testing effort.
Architecture
Structs:
-
CoverageReport- Complete coverage analysis- Field:
source: SourceFile- Original source - Field:
line_coverage: HashMap<usize, bool>- Line execution status - Field:
branch_coverage: Vec<Branch>- Branch execution status - Field:
function_coverage: HashMap<String, bool>- Function call status
- Field:
-
ReportFormat- Output format- Variants:
Text,Html,Json
- Variants:
Functions:
generate_report(&self, format: ReportFormat) -> String- Create reportcalculate_metrics(&self) -> CoverageMetrics- Compute percentagesformat_text(&self) -> String- Plain text with ANSI colorsformat_html(&self) -> String- HTML with CSS stylingformat_json(&self) -> String- JSON for tool integration
Starter Code:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone)]
pub struct CoverageReport {
pub source: SourceFile,
pub line_coverage: HashMap<usize, bool>,
pub branch_coverage: Vec<Branch>,
pub function_coverage: HashMap<String, bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoverageMetrics {
pub lines_covered: usize,
pub lines_total: usize,
pub line_percentage: f64,
pub branches_covered: usize,
pub branches_total: usize,
pub branch_percentage: f64,
pub functions_covered: usize,
pub functions_total: usize,
pub function_percentage: f64,
}
pub enum ReportFormat {
Text,
Html,
Json,
}
impl CoverageReport {
pub fn new(source: SourceFile, coverage: &HashSet<usize>,
branches: Vec<Branch>) -> Self {
// TODO: Build line_coverage map from executed lines
// TODO: Mark functions as covered if any line inside was executed
todo!("Create coverage report")
}
pub fn generate_report(&self, format: ReportFormat) -> String {
// TODO: Match on format and call appropriate formatter
todo!("Generate report")
}
pub fn calculate_metrics(&self) -> CoverageMetrics {
// TODO: Count covered vs total lines
// TODO: Count covered vs total branches
// TODO: Count covered vs total functions
// TODO: Calculate percentages
todo!("Calculate metrics")
}
fn format_text(&self) -> String {
// TODO: Create terminal output with ANSI colors
// TODO: Green for covered lines, red for uncovered
// TODO: Show line numbers and source code
todo!("Format as text")
}
fn format_html(&self) -> String {
// TODO: Generate HTML with CSS
// TODO: Syntax highlighting for Rust code
// TODO: Color-coded coverage
todo!("Format as HTML")
}
fn format_json(&self) -> String {
// TODO: Serialize metrics and coverage data to JSON
todo!("Format as JSON")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
fn create_sample_report() -> CoverageReport {
let source = SourceFile {
path: PathBuf::from("test.rs"),
lines: vec![
"fn add(a: i32, b: i32) -> i32 {".to_string(),
" a + b".to_string(),
"}".to_string(),
"fn unused() {".to_string(),
" println!(\"never called\");".to_string(),
"}".to_string(),
],
functions: vec![
FunctionInfo {
name: "add".to_string(),
start_line: 0,
end_line: 2,
statements: vec![1],
},
FunctionInfo {
name: "unused".to_string(),
start_line: 3,
end_line: 5,
statements: vec![4],
},
],
};
let mut coverage = HashSet::new();
coverage.insert(1); // Only line 1 executed
CoverageReport::new(source, &coverage, vec![])
}
#[test]
fn test_calculate_metrics() {
let report = create_sample_report();
let metrics = report.calculate_metrics();
// 1 line covered out of 2 executable lines
assert_eq!(metrics.lines_covered, 1);
assert_eq!(metrics.lines_total, 2);
assert_eq!(metrics.line_percentage, 50.0);
// 1 function covered out of 2
assert_eq!(metrics.functions_covered, 1);
assert_eq!(metrics.functions_total, 2);
}
#[test]
fn test_text_report_generation() {
let report = create_sample_report();
let text = report.format_text();
// Should contain coverage info
assert!(text.contains("Coverage"));
assert!(text.contains("50")); // 50% coverage
// Should show line numbers
assert!(text.contains("1"));
assert!(text.contains("4"));
}
#[test]
fn test_html_report_generation() {
let report = create_sample_report();
let html = report.format_html();
// Should be valid HTML
assert!(html.contains("<html"));
assert!(html.contains("</html>"));
// Should have CSS styling
assert!(html.contains("<style"));
// Should show code
assert!(html.contains("add"));
}
#[test]
fn test_json_report_generation() {
let report = create_sample_report();
let json = report.format_json();
// Should be valid JSON
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
// Should contain metrics
assert!(parsed.get("line_percentage").is_some());
assert!(parsed.get("lines_covered").is_some());
}
}
}
Why Milestone 4 Isn’t Enough
Limitation: Our analyzer only works on single files and requires manual instrumentation. Real projects have dozens of files and need automatic integration.
What we’re adding: Cargo integration to automatically analyze entire projects and generate coverage reports with a single command.
Improvement:
- Automation: Single command analyzes entire project
- Scale: Handles multi-file projects with dependencies
- Integration: Works with
cargo testworkflow - Speed: Parallel processing of multiple files
Milestone 5: Cargo Integration and Multi-File Support
Goal: Integrate with Cargo to automatically analyze entire projects and handle multiple source files.
Why this matters: Real projects aren’t single files. We need to handle dependencies, test modules, and generate project-wide coverage reports.
Architecture
Structs:
-
ProjectAnalyzer- Analyzes entire Cargo project- Field:
project_root: PathBuf- Project directory - Field:
source_files: Vec<SourceFile>- All parsed files - Field:
aggregate_coverage: HashMap<PathBuf, CoverageReport>- Per-file reports
- Field:
-
AnalysisConfig- Configuration options- Field:
min_coverage: f64- Minimum acceptable coverage percentage - Field:
exclude_patterns: Vec<String>- Files to exclude (e.g., “tests/*”) - Field:
report_format: ReportFormat- Output format
- Field:
Functions:
new(project_root: &Path) -> Self- Initialize analyzerdiscover_source_files(&self) -> Vec<PathBuf>- Find all .rs filesanalyze_project(&mut self) -> ProjectCoverageReport- Analyze all filesrun_instrumented_tests(&self) -> Result<(), Error>- Execute tests with coveragegenerate_aggregate_report(&self) -> String- Project-wide report
Starter Code:
#![allow(unused)]
fn main() {
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
pub struct ProjectAnalyzer {
project_root: PathBuf,
source_files: Vec<SourceFile>,
aggregate_coverage: HashMap<PathBuf, CoverageReport>,
}
pub struct AnalysisConfig {
pub min_coverage: f64,
pub exclude_patterns: Vec<String>,
pub report_format: ReportFormat,
}
pub struct ProjectCoverageReport {
pub total_lines_covered: usize,
pub total_lines: usize,
pub total_branches_covered: usize,
pub total_branches: usize,
pub file_reports: HashMap<PathBuf, CoverageReport>,
}
impl ProjectAnalyzer {
pub fn new(project_root: &Path) -> Self {
// TODO: Initialize with project root
// TODO: Verify it's a valid Cargo project (has Cargo.toml)
todo!("Create project analyzer")
}
pub fn discover_source_files(&self) -> Vec<PathBuf> {
// TODO: Walk directory tree starting from examples/
// TODO: Find all .rs files
// TODO: Exclude test files if configured
// TODO: Filter by exclude patterns
todo!("Discover source files")
}
pub fn analyze_project(&mut self) -> ProjectCoverageReport {
// TODO: Parse all source files
// TODO: Instrument all files
// TODO: Run tests with instrumentation
// TODO: Collect coverage data
// TODO: Generate per-file reports
// TODO: Aggregate into project report
todo!("Analyze entire project")
}
fn run_instrumented_tests(&self) -> Result<(), std::io::Error> {
// TODO: Execute `cargo test` with instrumented code
// TODO: Capture coverage data during test run
// TODO: Handle test failures gracefully
todo!("Run instrumented tests")
}
pub fn generate_aggregate_report(&self, config: &AnalysisConfig) -> String {
// TODO: Combine all file reports
// TODO: Calculate project-wide metrics
// TODO: Format according to config
// TODO: Highlight files below min_coverage threshold
todo!("Generate aggregate report")
}
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
fn create_test_project() -> TempDir {
let dir = TempDir::new().unwrap();
let project_root = dir.path();
// Create Cargo.toml
fs::write(
project_root.join("Cargo.toml"),
r#"
[package]
name = "test_project"
version = "0.1.0"
"#
).unwrap();
// Create examples directory
fs::create_dir(project_root.join("examples")).unwrap();
// Create lib.rs
fs::write(
project_root.join("examples/lib.rs"),
r#"
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
}
"#
).unwrap();
// Create module file
fs::write(
project_root.join("examples/math.rs"),
r#"
pub fn multiply(a: i32, b: i32) -> i32 {
a * b
}
"#
).unwrap();
dir
}
#[test]
fn test_discover_source_files() {
let project = create_test_project();
let analyzer = ProjectAnalyzer::new(project.path());
let files = analyzer.discover_source_files();
// Should find lib.rs and math.rs
assert_eq!(files.len(), 2);
assert!(files.iter().any(|p| p.ends_with("lib.rs")));
assert!(files.iter().any(|p| p.ends_with("math.rs")));
}
#[test]
fn test_project_analysis() {
let project = create_test_project();
let mut analyzer = ProjectAnalyzer::new(project.path());
let report = analyzer.analyze_project();
// Should analyze both files
assert_eq!(report.file_reports.len(), 2);
// Should have aggregate metrics
assert!(report.total_lines > 0);
}
#[test]
fn test_exclude_patterns() {
let project = create_test_project();
let config = AnalysisConfig {
min_coverage: 80.0,
exclude_patterns: vec!["tests/*".to_string()],
report_format: ReportFormat::Text,
};
let analyzer = ProjectAnalyzer::new(project.path());
let files = analyzer.discover_source_files();
// Test files should be excluded
assert!(!files.iter().any(|p| p.to_str().unwrap().contains("tests")));
}
#[test]
fn test_aggregate_report_format() {
let project = create_test_project();
let mut analyzer = ProjectAnalyzer::new(project.path());
analyzer.analyze_project();
let config = AnalysisConfig {
min_coverage: 80.0,
exclude_patterns: vec![],
report_format: ReportFormat::Text,
};
let report = analyzer.generate_aggregate_report(&config);
// Should contain project summary
assert!(report.contains("Project Coverage"));
assert!(report.contains("Total"));
// Should list individual files
assert!(report.contains("lib.rs"));
assert!(report.contains("math.rs"));
}
}
}
Why Milestone 5 Isn’t Enough
Limitation: Analysis is sequential—each file is processed one at a time. For large projects with hundreds of files, this is slow.
What we’re adding: Parallel processing using Rayon to analyze multiple files concurrently.
Improvement:
- Speed: 4-8x faster on multi-core systems
- Scalability: Handles large projects efficiently
- Efficiency: Utilizes all CPU cores
- Optimization: Shows the power of Rust’s fearless concurrency
Milestone 6: Parallel Analysis with Rayon
Goal: Parallelize file analysis to dramatically speed up coverage reporting for large projects.
Why this matters: In production, coverage analysis can take minutes on large codebases. Parallelization reduces this to seconds, making it practical for CI/CD pipelines.
Architecture
Changes:
- Modify
analyze_project()to use parallel iterators - Thread-safe coverage data collection
- Concurrent report generation
Functions:
parallel_analyze(&mut self) -> ProjectCoverageReport- Parallel analysismerge_coverage_data(reports: Vec<CoverageReport>) -> ProjectCoverageReport- Combine results- Benchmark comparison:
sequential_analysis()vsparallel_analysis()
Starter Code:
#![allow(unused)]
fn main() {
use rayon::prelude::*;
use std::sync::{Arc, Mutex};
impl ProjectAnalyzer {
pub fn parallel_analyze(&mut self) -> ProjectCoverageReport {
// TODO: Use rayon's par_iter to process files in parallel
// TODO: Collect results in thread-safe manner
// TODO: Merge coverage data
todo!("Implement parallel analysis")
}
fn analyze_file_parallel(
&self,
path: &Path,
coverage_collector: Arc<Mutex<HashMap<PathBuf, HashSet<usize>>>>
) -> CoverageReport {
// TODO: Parse and instrument file
// TODO: Store coverage data in shared collector
// TODO: Return report
todo!("Analyze single file in parallel")
}
fn merge_coverage_data(reports: Vec<CoverageReport>) -> ProjectCoverageReport {
// TODO: Sum up all line counts
// TODO: Sum up all branch counts
// TODO: Calculate aggregate percentages
todo!("Merge parallel results")
}
}
// Performance comparison
pub fn benchmark_analysis_methods(project_root: &Path) {
use std::time::Instant;
let mut analyzer = ProjectAnalyzer::new(project_root);
// Sequential
let start = Instant::now();
let _ = analyzer.analyze_project();
let sequential_time = start.elapsed();
// Parallel
let start = Instant::now();
let _ = analyzer.parallel_analyze();
let parallel_time = start.elapsed();
println!("Sequential: {:?}", sequential_time);
println!("Parallel: {:?}", parallel_time);
println!("Speedup: {:.2}x", sequential_time.as_secs_f64() / parallel_time.as_secs_f64());
}
}
Checkpoint Tests:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parallel_analysis_correctness() {
let project = create_test_project();
let mut analyzer = ProjectAnalyzer::new(project.path());
let sequential = analyzer.analyze_project();
let parallel = analyzer.parallel_analyze();
// Results should match
assert_eq!(
sequential.total_lines_covered,
parallel.total_lines_covered
);
assert_eq!(
sequential.total_lines,
parallel.total_lines
);
}
#[test]
fn test_parallel_speedup() {
// Create project with many files
let project = create_large_test_project(50); // 50 files
let mut analyzer = ProjectAnalyzer::new(project.path());
let start = std::time::Instant::now();
let _ = analyzer.analyze_project();
let seq_time = start.elapsed();
let start = std::time::Instant::now();
let _ = analyzer.parallel_analyze();
let par_time = start.elapsed();
// Parallel should be faster (at least 1.5x on 4+ cores)
assert!(par_time < seq_time);
let speedup = seq_time.as_secs_f64() / par_time.as_secs_f64();
println!("Speedup: {:.2}x", speedup);
assert!(speedup > 1.5);
}
#[test]
fn test_thread_safety() {
// Ensure no data races in parallel execution
let project = create_test_project();
let analyzer = ProjectAnalyzer::new(project.path());
let coverage_collector = Arc::new(Mutex::new(HashMap::new()));
// Run analysis from multiple threads
let handles: Vec<_> = (0..10)
.map(|_| {
let analyzer = analyzer.clone();
let collector = Arc::clone(&coverage_collector);
std::thread::spawn(move || {
analyzer.analyze_file_parallel(
&PathBuf::from("examples/lib.rs"),
collector
)
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
// No panics = thread-safe
}
}
}
Testing Strategies
1. Unit Tests
Test individual components in isolation:
- Parser: Verify correct extraction of functions, statements, branches
- Instrumentor: Ensure probes are correctly injected
- Reporter: Validate report format and metrics calculation
2. Integration Tests
Test components working together:
- End-to-end: Parse → Instrument → Execute → Report
- Multi-file: Verify correct handling of project structure
- Error handling: Invalid Rust code, missing files, etc.
3. Property-Based Tests
Use proptest to verify invariants:
- Instrumentation preserves semantics (same test results)
- Coverage percentage always between 0-100%
- Line numbers in reports match original source
4. Performance Tests
Benchmark critical operations:
- Parsing speed (lines/second)
- Instrumentation overhead
- Sequential vs parallel analysis speedup
- Memory usage for large projects
5. Real-World Tests
Test on actual Rust projects:
- Run on small open-source projects
- Compare results with llvm-cov or tarpaulin
- Verify accuracy of coverage reports
Complete Working Example
//==============================================================================
// Coverage Analyzer - Complete Implementation
//==============================================================================
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rayon::prelude::*;
use serde::{Serialize, Deserialize};
//==============================================================================
// Part 1: Source File Parsing
//==============================================================================
#[derive(Debug, Clone)]
pub struct SourceFile {
pub path: PathBuf,
pub lines: Vec<String>,
pub functions: Vec<FunctionInfo>,
}
#[derive(Debug, Clone)]
pub struct FunctionInfo {
pub name: String,
pub start_line: usize,
pub end_line: usize,
pub statements: Vec<usize>,
}
impl SourceFile {
pub fn parse_file(path: &Path) -> Result<Self, std::io::Error> {
let content = fs::read_to_string(path)?;
let lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let mut source = SourceFile {
path: path.to_path_buf(),
lines: lines.clone(),
functions: vec![],
};
source.functions = source.find_functions();
Ok(source)
}
fn find_functions(&self) -> Vec<FunctionInfo> {
let mut functions = Vec::new();
let mut brace_depth = 0;
let mut in_function = false;
let mut func_start = 0;
let mut func_name = String::new();
for (i, line) in self.lines.iter().enumerate() {
let trimmed = line.trim();
// Look for function definitions
if trimmed.starts_with("fn ") && !in_function {
in_function = true;
func_start = i;
// Extract function name
if let Some(name_end) = trimmed.find('(') {
func_name = trimmed[3..name_end].trim().to_string();
}
}
// Track braces
brace_depth += line.matches('{').count() as i32;
brace_depth -= line.matches('}').count() as i32;
// Function ends when braces balance
if in_function && brace_depth == 0 {
let statements = self.find_statements(func_start, i);
functions.push(FunctionInfo {
name: func_name.clone(),
start_line: func_start,
end_line: i,
statements,
});
in_function = false;
}
}
functions
}
fn find_statements(&self, start: usize, end: usize) -> Vec<usize> {
(start..=end)
.filter(|&i| Self::is_executable(&self.lines[i]))
.collect()
}
fn is_executable(line: &str) -> bool {
let trimmed = line.trim();
// Filter out non-executable lines
!trimmed.is_empty()
&& !trimmed.starts_with("//")
&& !trimmed.starts_with("/*")
&& !trimmed.starts_with("*")
&& !trimmed.starts_with("*/")
&& trimmed != "{"
&& trimmed != "}"
&& !trimmed.starts_with("fn ")
&& !trimmed.starts_with("pub fn ")
&& !trimmed.starts_with("#[")
}
}
//==============================================================================
// Part 2: Code Instrumentation
//==============================================================================
lazy_static::lazy_static! {
static ref COVERAGE_DATA: Arc<Mutex<HashSet<usize>>> =
Arc::new(Mutex::new(HashSet::new()));
static ref BRANCH_DATA: Arc<Mutex<HashSet<usize>>> =
Arc::new(Mutex::new(HashSet::new()));
}
pub struct Instrumentor {
coverage_map: Arc<Mutex<HashSet<usize>>>,
branch_map: Arc<Mutex<HashSet<usize>>>,
next_branch_id: usize,
}
pub struct InstrumentedCode {
pub original: SourceFile,
pub instrumented: String,
pub line_mapping: HashMap<usize, usize>,
pub branches: Vec<Branch>,
}
impl Instrumentor {
pub fn new() -> Self {
Instrumentor {
coverage_map: Arc::clone(&COVERAGE_DATA),
branch_map: Arc::clone(&BRANCH_DATA),
next_branch_id: 0,
}
}
pub fn instrument(&mut self, source: &SourceFile) -> InstrumentedCode {
let mut instrumented_lines = Vec::new();
let mut line_mapping = HashMap::new();
let branches = self.find_branches(source);
for (original_line_num, line) in source.lines.iter().enumerate() {
let current_instrumented_line = instrumented_lines.len();
line_mapping.insert(current_instrumented_line, original_line_num);
// Check if this line is executable
if source.functions.iter().any(|f| f.statements.contains(&original_line_num)) {
// Inject coverage probe
let indent = line.len() - line.trim_start().len();
let probe = format!(
"{}record_line({});",
" ".repeat(indent),
original_line_num
);
instrumented_lines.push(probe);
}
instrumented_lines.push(line.clone());
}
InstrumentedCode {
original: source.clone(),
instrumented: instrumented_lines.join("\n"),
line_mapping,
branches,
}
}
pub fn find_branches(&mut self, source: &SourceFile) -> Vec<Branch> {
let mut branches = Vec::new();
for (i, line) in source.lines.iter().enumerate() {
let trimmed = line.trim();
// Find if statements
if trimmed.starts_with("if ") || trimmed.contains(" if ") {
let true_id = self.next_branch_id;
self.next_branch_id += 1;
let false_id = self.next_branch_id;
self.next_branch_id += 1;
branches.push(Branch {
line: i,
branch_id: true_id,
kind: BranchKind::IfTrue,
taken: false,
});
branches.push(Branch {
line: i,
branch_id: false_id,
kind: BranchKind::IfFalse,
taken: false,
});
}
// Find match arms (simplified)
if trimmed.starts_with("match ") {
let arm_id = self.next_branch_id;
self.next_branch_id += 1;
branches.push(Branch {
line: i,
branch_id: arm_id,
kind: BranchKind::MatchArm(0),
taken: false,
});
}
}
branches
}
pub fn get_coverage(&self) -> HashSet<usize> {
self.coverage_map.lock().unwrap().clone()
}
pub fn get_branch_coverage(&self) -> (usize, usize) {
let taken = self.branch_map.lock().unwrap().len();
(taken, self.next_branch_id)
}
pub fn reset(&self) {
self.coverage_map.lock().unwrap().clear();
self.branch_map.lock().unwrap().clear();
}
}
pub fn record_line(line: usize) {
COVERAGE_DATA.lock().unwrap().insert(line);
}
pub fn record_branch(branch_id: usize) {
BRANCH_DATA.lock().unwrap().insert(branch_id);
}
//==============================================================================
// Part 3: Branch Tracking
//==============================================================================
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchKind {
IfTrue,
IfFalse,
MatchArm(usize),
}
#[derive(Debug, Clone)]
pub struct Branch {
pub line: usize,
pub branch_id: usize,
pub kind: BranchKind,
pub taken: bool,
}
//==============================================================================
// Part 4: Coverage Reporting
//==============================================================================
#[derive(Debug, Clone)]
pub struct CoverageReport {
pub source: SourceFile,
pub line_coverage: HashMap<usize, bool>,
pub branch_coverage: Vec<Branch>,
pub function_coverage: HashMap<String, bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CoverageMetrics {
pub lines_covered: usize,
pub lines_total: usize,
pub line_percentage: f64,
pub branches_covered: usize,
pub branches_total: usize,
pub branch_percentage: f64,
pub functions_covered: usize,
pub functions_total: usize,
pub function_percentage: f64,
}
pub enum ReportFormat {
Text,
Html,
Json,
}
impl CoverageReport {
pub fn new(
source: SourceFile,
coverage: &HashSet<usize>,
mut branches: Vec<Branch>,
branch_coverage: &HashSet<usize>,
) -> Self {
// Build line coverage map
let mut line_coverage = HashMap::new();
for func in &source.functions {
for &line_num in &func.statements {
let covered = coverage.contains(&line_num);
line_coverage.insert(line_num, covered);
}
}
// Update branch taken status
for branch in &mut branches {
branch.taken = branch_coverage.contains(&branch.branch_id);
}
// Build function coverage map
let mut function_coverage = HashMap::new();
for func in &source.functions {
let covered = func.statements.iter().any(|line| coverage.contains(line));
function_coverage.insert(func.name.clone(), covered);
}
CoverageReport {
source,
line_coverage,
branch_coverage: branches,
function_coverage,
}
}
pub fn calculate_metrics(&self) -> CoverageMetrics {
let lines_total: usize = self.source.functions.iter()
.map(|f| f.statements.len())
.sum();
let lines_covered = self.line_coverage.values()
.filter(|&&covered| covered)
.count();
let line_percentage = if lines_total > 0 {
(lines_covered as f64 / lines_total as f64) * 100.0
} else {
0.0
};
let branches_total = self.branch_coverage.len();
let branches_covered = self.branch_coverage.iter()
.filter(|b| b.taken)
.count();
let branch_percentage = if branches_total > 0 {
(branches_covered as f64 / branches_total as f64) * 100.0
} else {
0.0
};
let functions_total = self.function_coverage.len();
let functions_covered = self.function_coverage.values()
.filter(|&&covered| covered)
.count();
let function_percentage = if functions_total > 0 {
(functions_covered as f64 / functions_total as f64) * 100.0
} else {
0.0
};
CoverageMetrics {
lines_covered,
lines_total,
line_percentage,
branches_covered,
branches_total,
branch_percentage,
functions_covered,
functions_total,
function_percentage,
}
}
pub fn generate_report(&self, format: ReportFormat) -> String {
match format {
ReportFormat::Text => self.format_text(),
ReportFormat::Html => self.format_html(),
ReportFormat::Json => self.format_json(),
}
}
fn format_text(&self) -> String {
let metrics = self.calculate_metrics();
let mut output = String::new();
output.push_str(&format!("Coverage Report: {}\n", self.source.path.display()));
output.push_str(&format!("{'=':=<60}\n"));
output.push_str(&format!(
"Lines: {}/{} ({:.1}%)\n",
metrics.lines_covered, metrics.lines_total, metrics.line_percentage
));
output.push_str(&format!(
"Branches: {}/{} ({:.1}%)\n",
metrics.branches_covered, metrics.branches_total, metrics.branch_percentage
));
output.push_str(&format!(
"Functions: {}/{} ({:.1}%)\n\n",
metrics.functions_covered, metrics.functions_total, metrics.function_percentage
));
// Show source with coverage annotations
for (i, line) in self.source.lines.iter().enumerate() {
let marker = if let Some(&covered) = self.line_coverage.get(&i) {
if covered { "✓" } else { "✗" }
} else {
" "
};
output.push_str(&format!("{:4} {} {}\n", i + 1, marker, line));
}
output
}
fn format_html(&self) -> String {
let metrics = self.calculate_metrics();
format!(
r#"<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: monospace; }}
.covered {{ background-color: #c8e6c9; }}
.uncovered {{ background-color: #ffcdd2; }}
.neutral {{ background-color: #f5f5f5; }}
.metrics {{ margin: 20px; padding: 10px; border: 1px solid #ccc; }}
</style>
</head>
<body>
<div class="metrics">
<h2>Coverage Report: {}</h2>
<p>Lines: {}/{} ({:.1}%)</p>
<p>Branches: {}/{} ({:.1}%)</p>
<p>Functions: {}/{} ({:.1}%)</p>
</div>
<pre>{}</pre>
</body>
</html>"#,
self.source.path.display(),
metrics.lines_covered,
metrics.lines_total,
metrics.line_percentage,
metrics.branches_covered,
metrics.branches_total,
metrics.branch_percentage,
metrics.functions_covered,
metrics.functions_total,
metrics.function_percentage,
self.format_source_html()
)
}
fn format_source_html(&self) -> String {
self.source
.lines
.iter()
.enumerate()
.map(|(i, line)| {
let class = if let Some(&covered) = self.line_coverage.get(&i) {
if covered {
"covered"
} else {
"uncovered"
}
} else {
"neutral"
};
format!(
r#"<div class="{}">{:4} {}</div>"#,
class,
i + 1,
html_escape::encode_text(line)
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_json(&self) -> String {
let metrics = self.calculate_metrics();
serde_json::to_string_pretty(&metrics).unwrap()
}
}
//==============================================================================
// Part 5: Project Analysis
//==============================================================================
pub struct ProjectAnalyzer {
project_root: PathBuf,
source_files: Vec<SourceFile>,
aggregate_coverage: HashMap<PathBuf, CoverageReport>,
}
pub struct AnalysisConfig {
pub min_coverage: f64,
pub exclude_patterns: Vec<String>,
pub report_format: ReportFormat,
}
pub struct ProjectCoverageReport {
pub total_lines_covered: usize,
pub total_lines: usize,
pub total_branches_covered: usize,
pub total_branches: usize,
pub file_reports: HashMap<PathBuf, CoverageReport>,
}
impl ProjectAnalyzer {
pub fn new(project_root: &Path) -> Self {
ProjectAnalyzer {
project_root: project_root.to_path_buf(),
source_files: vec![],
aggregate_coverage: HashMap::new(),
}
}
pub fn discover_source_files(&self) -> Vec<PathBuf> {
walkdir::WalkDir::new(self.project_root.join("examples"))
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
.map(|e| e.path().to_path_buf())
.collect()
}
pub fn analyze_project(&mut self) -> ProjectCoverageReport {
let files = self.discover_source_files();
let mut file_reports = HashMap::new();
for file_path in files {
if let Ok(source) = SourceFile::parse_file(&file_path) {
let mut instrumentor = Instrumentor::new();
let instrumented = instrumentor.instrument(&source);
// In a real implementation, we would:
// 1. Write instrumented code to temp file
// 2. Compile and run tests
// 3. Collect coverage data
// For this example, we'll use simulated coverage
let coverage = instrumentor.get_coverage();
let (_, _) = instrumentor.get_branch_coverage();
let branch_coverage = instrumentor.branch_map.lock().unwrap().clone();
let report = CoverageReport::new(
source,
&coverage,
instrumented.branches,
&branch_coverage,
);
file_reports.insert(file_path, report);
}
}
let mut total_lines_covered = 0;
let mut total_lines = 0;
let mut total_branches_covered = 0;
let mut total_branches = 0;
for report in file_reports.values() {
let metrics = report.calculate_metrics();
total_lines_covered += metrics.lines_covered;
total_lines += metrics.lines_total;
total_branches_covered += metrics.branches_covered;
total_branches += metrics.branches_total;
}
ProjectCoverageReport {
total_lines_covered,
total_lines,
total_branches_covered,
total_branches,
file_reports,
}
}
pub fn parallel_analyze(&mut self) -> ProjectCoverageReport {
let files = self.discover_source_files();
let file_reports: HashMap<PathBuf, CoverageReport> = files
.par_iter()
.filter_map(|file_path| {
SourceFile::parse_file(file_path).ok().map(|source| {
let mut instrumentor = Instrumentor::new();
let instrumented = instrumentor.instrument(&source);
let coverage = instrumentor.get_coverage();
let branch_coverage = instrumentor.branch_map.lock().unwrap().clone();
let report = CoverageReport::new(
source,
&coverage,
instrumented.branches,
&branch_coverage,
);
(file_path.clone(), report)
})
})
.collect();
let (total_lines_covered, total_lines, total_branches_covered, total_branches) =
file_reports
.par_iter()
.map(|(_, report)| {
let metrics = report.calculate_metrics();
(
metrics.lines_covered,
metrics.lines_total,
metrics.branches_covered,
metrics.branches_total,
)
})
.reduce(
|| (0, 0, 0, 0),
|a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3),
);
ProjectCoverageReport {
total_lines_covered,
total_lines,
total_branches_covered,
total_branches,
file_reports,
}
}
}
//==============================================================================
// Example Usage
//==============================================================================
fn main() {
println!("=== Test Coverage Analyzer ===\n");
// Example 1: Analyze a single file
println!("Example 1: Single File Analysis");
let source = SourceFile::parse_file(Path::new("examples/lib.rs")).unwrap();
println!("Found {} functions", source.functions.len());
let mut instrumentor = Instrumentor::new();
let instrumented = instrumentor.instrument(&source);
println!("Instrumented {} lines\n", instrumented.instrumented.lines().count());
// Example 2: Generate coverage report
println!("Example 2: Coverage Report");
let coverage = HashSet::new(); // Simulated: no lines executed
let branch_coverage = HashSet::new();
let report = CoverageReport::new(
source.clone(),
&coverage,
instrumented.branches,
&branch_coverage,
);
let metrics = report.calculate_metrics();
println!("Coverage: {:.1}%", metrics.line_percentage);
println!("Report:\n{}", report.generate_report(ReportFormat::Text));
// Example 3: Project-wide analysis
println!("\nExample 3: Project Analysis");
let mut analyzer = ProjectAnalyzer::new(Path::new("."));
let project_report = analyzer.analyze_project();
println!(
"Project Coverage: {}/{}lines ({:.1}%)",
project_report.total_lines_covered,
project_report.total_lines,
(project_report.total_lines_covered as f64 / project_report.total_lines as f64) * 100.0
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_parsing() {
let content = r#"
fn add(a: i32, b: i32) -> i32 {
a + b
}
"#;
let temp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp.path(), content).unwrap();
let source = SourceFile::parse_file(temp.path()).unwrap();
assert_eq!(source.functions.len(), 1);
assert_eq!(source.functions[0].name, "add");
}
#[test]
fn test_coverage_tracking() {
record_line(1);
record_line(5);
let coverage = COVERAGE_DATA.lock().unwrap();
assert!(coverage.contains(&1));
assert!(coverage.contains(&5));
assert!(!coverage.contains(&10));
}
#[test]
fn test_metrics_calculation() {
let source = SourceFile {
path: PathBuf::from("test.rs"),
lines: vec!["fn test() {".to_string(), "let x = 1;".to_string(), "}".to_string()],
functions: vec![FunctionInfo {
name: "test".to_string(),
start_line: 0,
end_line: 2,
statements: vec![1],
}],
};
let mut coverage = HashSet::new();
coverage.insert(1);
let report = CoverageReport::new(source, &coverage, vec![], &HashSet::new());
let metrics = report.calculate_metrics();
assert_eq!(metrics.lines_covered, 1);
assert_eq!(metrics.lines_total, 1);
assert_eq!(metrics.line_percentage, 100.0);
}
}
This complete implementation demonstrates:
- Part 1: Parsing Rust source files to extract structure
- Part 2: Instrumenting code with coverage tracking probes
- Part 3: Branch coverage detection and tracking
- Part 4: Multi-format report generation (text, HTML, JSON)
- Part 5: Project-wide analysis with parallel processing
The analyzer progresses from simple line tracking to comprehensive coverage analysis with professional reporting capabilities.