Skip to main content

tldr_core/quality/
coverage.rs

1//! Coverage report parsing module
2//!
3//! Parses coverage reports in multiple formats:
4//! - Cobertura XML (GitLab/Jenkins standard)
5//! - LCOV (llvm-cov, gcov)
6//! - coverage.py JSON
7//!
8//! # Security
9//! The XML parser (quick-xml) does NOT support DTD/external entities by default,
10//! making it safe from XXE attacks (CM-4 mitigation).
11//!
12//! # Example
13//! ```ignore
14//! use tldr_core::quality::coverage::{parse_coverage, CoverageFormat};
15//!
16//! let report = parse_coverage(Path::new("coverage.xml"), None)?;
17//! println!("Line coverage: {:.1}%", report.summary.line_coverage);
18//! ```
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::TldrError;
26use crate::TldrResult;
27
28// =============================================================================
29// Types
30// =============================================================================
31
32/// Supported coverage report formats
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum CoverageFormat {
36    /// Cobertura XML format
37    Cobertura,
38    /// LCOV format (llvm-cov, gcov)
39    Lcov,
40    /// coverage.py JSON format
41    CoveragePy,
42}
43
44impl std::fmt::Display for CoverageFormat {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            CoverageFormat::Cobertura => write!(f, "cobertura"),
48            CoverageFormat::Lcov => write!(f, "lcov"),
49            CoverageFormat::CoveragePy => write!(f, "coveragepy"),
50        }
51    }
52}
53
54/// Line coverage information
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LineCoverage {
57    /// Line number (1-based)
58    pub line: u32,
59    /// Number of times this line was executed
60    pub hits: u64,
61}
62
63/// Function coverage information
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct FunctionCoverage {
66    /// Function name
67    pub name: String,
68    /// Starting line number
69    pub line: u32,
70    /// Number of times the function was executed
71    pub hits: u64,
72}
73
74/// Coverage data for a single file
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct FileCoverage {
77    /// File path (as recorded in the coverage report)
78    pub path: String,
79    /// Line coverage percentage (0.0 - 100.0)
80    pub line_coverage: f64,
81    /// Branch coverage percentage (0.0 - 100.0), if available
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub branch_coverage: Option<f64>,
84    /// Total lines tracked
85    pub total_lines: u32,
86    /// Covered lines count
87    pub covered_lines: u32,
88    /// Total branches (if tracked)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub total_branches: Option<u32>,
91    /// Covered branches count (if tracked)
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub covered_branches: Option<u32>,
94    /// List of uncovered line numbers
95    #[serde(skip_serializing_if = "Vec::is_empty", default)]
96    pub uncovered_lines: Vec<u32>,
97    /// Function coverage data
98    #[serde(skip_serializing_if = "Vec::is_empty", default)]
99    pub functions: Vec<FunctionCoverage>,
100    /// Whether this file exists on disk
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub file_exists: Option<bool>,
103}
104
105/// Uncovered function information for reporting
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct UncoveredFunction {
108    /// File containing the function
109    pub file: String,
110    /// Function name
111    pub name: String,
112    /// Starting line number
113    pub line: u32,
114}
115
116/// Range of uncovered lines for compact reporting
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct UncoveredLineRange {
119    /// File containing the uncovered lines
120    pub file: String,
121    /// Start of uncovered range (1-based, inclusive)
122    pub start: u32,
123    /// End of uncovered range (1-based, inclusive)
124    pub end: u32,
125}
126
127/// Summary of uncovered code
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct UncoveredSummary {
130    /// List of uncovered functions
131    #[serde(skip_serializing_if = "Vec::is_empty", default)]
132    pub functions: Vec<UncoveredFunction>,
133    /// Consolidated uncovered line ranges
134    #[serde(skip_serializing_if = "Vec::is_empty", default)]
135    pub line_ranges: Vec<UncoveredLineRange>,
136}
137
138/// Summary statistics for the coverage report
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct CoverageSummary {
141    /// Overall line coverage percentage (0.0 - 100.0)
142    pub line_coverage: f64,
143    /// Overall branch coverage percentage, if available
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub branch_coverage: Option<f64>,
146    /// Overall function coverage percentage, if available
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub function_coverage: Option<f64>,
149    /// Total lines across all files
150    pub total_lines: u32,
151    /// Total covered lines
152    pub covered_lines: u32,
153    /// Total branches (if available)
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub total_branches: Option<u32>,
156    /// Covered branches (if available)
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub covered_branches: Option<u32>,
159    /// Total functions (if available)
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub total_functions: Option<u32>,
162    /// Covered functions (if available)
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub covered_functions: Option<u32>,
165    /// Whether the threshold was met
166    pub threshold_met: bool,
167}
168
169/// Complete coverage report
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CoverageReport {
172    /// Format of the source report
173    pub format: CoverageFormat,
174    /// Overall summary statistics
175    pub summary: CoverageSummary,
176    /// Per-file coverage data
177    #[serde(skip_serializing_if = "Vec::is_empty", default)]
178    pub files: Vec<FileCoverage>,
179    /// Uncovered code summary
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub uncovered: Option<UncoveredSummary>,
182    /// Warnings encountered during parsing
183    #[serde(skip_serializing_if = "Vec::is_empty", default)]
184    pub warnings: Vec<String>,
185}
186
187/// Options for coverage parsing
188#[derive(Debug, Clone, Default)]
189pub struct CoverageOptions {
190    /// Minimum coverage threshold (0.0 - 100.0)
191    pub threshold: f64,
192    /// Include per-file breakdown
193    pub by_file: bool,
194    /// Include uncovered code details
195    pub include_uncovered: bool,
196    /// Filter to files matching these patterns
197    pub filter: Vec<String>,
198    /// Base path for resolving file paths
199    pub base_path: Option<PathBuf>,
200}
201
202// =============================================================================
203// Main API
204// =============================================================================
205
206/// Parse a coverage report file
207///
208/// # Arguments
209/// * `path` - Path to the coverage report file
210/// * `format` - Optional format hint (auto-detect if None)
211/// * `options` - Parsing options
212///
213/// # Returns
214/// * `Ok(CoverageReport)` - Parsed coverage data
215/// * `Err(TldrError)` - On file not found or parse errors
216pub fn parse_coverage(
217    path: &Path,
218    format: Option<CoverageFormat>,
219    options: &CoverageOptions,
220) -> TldrResult<CoverageReport> {
221    // Check file exists
222    if !path.exists() {
223        return Err(TldrError::PathNotFound(path.to_path_buf()));
224    }
225
226    // Read file content
227    let content = std::fs::read_to_string(path).map_err(|e| TldrError::ParseError {
228        file: path.to_path_buf(),
229        line: None,
230        message: format!("Failed to read file: {}", e),
231    })?;
232
233    // Auto-detect format if not specified
234    let detected_format = format.unwrap_or_else(|| detect_format(&content));
235
236    // Parse based on format
237    let mut report = match detected_format {
238        CoverageFormat::Cobertura => parse_cobertura(&content)?,
239        CoverageFormat::Lcov => parse_lcov(&content)?,
240        CoverageFormat::CoveragePy => parse_coverage_py_json(&content)?,
241    };
242
243    // Apply options
244    report.summary.threshold_met = report.summary.line_coverage >= options.threshold;
245
246    // Filter files if patterns specified
247    if !options.filter.is_empty() {
248        report.files.retain(|f| {
249            options
250                .filter
251                .iter()
252                .any(|pattern| f.path.contains(pattern))
253        });
254    }
255
256    // Check file existence and add warnings
257    if let Some(base_path) = &options.base_path {
258        for file in &mut report.files {
259            let full_path = base_path.join(&file.path);
260            let exists = full_path.exists();
261            file.file_exists = Some(exists);
262            if !exists {
263                report
264                    .warnings
265                    .push(format!("File not found on disk: {}", file.path));
266            }
267        }
268    }
269
270    // Build uncovered summary if requested
271    if options.include_uncovered {
272        report.uncovered = Some(build_uncovered_summary(&report.files));
273    }
274
275    // Clear per-file data if not requested
276    if !options.by_file {
277        report.files.clear();
278    }
279
280    Ok(report)
281}
282
283/// Detect coverage format from file content
284pub fn detect_format(content: &str) -> CoverageFormat {
285    let trimmed = content.trim();
286
287    // Check for XML (Cobertura)
288    if trimmed.starts_with("<?xml") || trimmed.starts_with("<coverage") {
289        return CoverageFormat::Cobertura;
290    }
291
292    // Check for LCOV format markers
293    if trimmed.contains("SF:") && trimmed.contains("end_of_record") {
294        return CoverageFormat::Lcov;
295    }
296
297    // Check for JSON (coverage.py)
298    if trimmed.starts_with('{') {
299        return CoverageFormat::CoveragePy;
300    }
301
302    // Default to Cobertura as it's most common
303    CoverageFormat::Cobertura
304}
305
306// =============================================================================
307// Cobertura XML Parser
308// =============================================================================
309
310/// Parse Cobertura XML format
311pub fn parse_cobertura(xml: &str) -> TldrResult<CoverageReport> {
312    use quick_xml::events::Event;
313    use quick_xml::Reader;
314
315    let mut reader = Reader::from_str(xml);
316    reader.config_mut().trim_text(true);
317
318    let mut files: Vec<FileCoverage> = Vec::new();
319    let warnings: Vec<String> = Vec::new();
320
321    // Root-level attributes
322    let mut root_line_rate: Option<f64> = None;
323    let mut root_branch_rate: Option<f64> = None;
324    let mut root_lines_valid: Option<u32> = None;
325    let mut root_lines_covered: Option<u32> = None;
326
327    // Current file being parsed
328    let mut current_file: Option<FileCoverage> = None;
329    let mut current_lines: HashMap<u32, u64> = HashMap::new();
330    let mut current_functions: Vec<FunctionCoverage> = Vec::new();
331
332    let mut buf = Vec::new();
333
334    loop {
335        match reader.read_event_into(&mut buf) {
336            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
337                let tag_name = e.name();
338                let tag_name_str = std::str::from_utf8(tag_name.as_ref()).unwrap_or("");
339
340                match tag_name_str {
341                    "coverage" => {
342                        // Parse root attributes
343                        for attr in e.attributes().filter_map(|a| a.ok()) {
344                            let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
345                            let value = std::str::from_utf8(&attr.value).unwrap_or("");
346
347                            match key {
348                                "line-rate" => {
349                                    root_line_rate = value.parse::<f64>().ok().map(|r| r * 100.0)
350                                }
351                                "branch-rate" => {
352                                    root_branch_rate = value.parse::<f64>().ok().map(|r| r * 100.0)
353                                }
354                                "lines-valid" => root_lines_valid = value.parse().ok(),
355                                "lines-covered" => root_lines_covered = value.parse().ok(),
356                                _ => {}
357                            }
358                        }
359                    }
360                    "class" => {
361                        // Start a new file/class
362                        let mut filename = String::new();
363                        let mut line_rate = 0.0;
364                        let mut branch_rate: Option<f64> = None;
365
366                        for attr in e.attributes().filter_map(|a| a.ok()) {
367                            let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
368                            let value = std::str::from_utf8(&attr.value).unwrap_or("");
369
370                            match key {
371                                "filename" => filename = value.to_string(),
372                                "line-rate" => {
373                                    line_rate = value.parse::<f64>().unwrap_or(0.0) * 100.0
374                                }
375                                "branch-rate" => {
376                                    branch_rate = value.parse::<f64>().ok().map(|r| r * 100.0)
377                                }
378                                _ => {}
379                            }
380                        }
381
382                        current_file = Some(FileCoverage {
383                            path: filename,
384                            line_coverage: line_rate,
385                            branch_coverage: branch_rate,
386                            total_lines: 0,
387                            covered_lines: 0,
388                            total_branches: None,
389                            covered_branches: None,
390                            uncovered_lines: Vec::new(),
391                            functions: Vec::new(),
392                            file_exists: None,
393                        });
394                        current_lines.clear();
395                        current_functions.clear();
396                    }
397                    "method" => {
398                        // Parse method/function coverage
399                        let mut name = String::new();
400                        let mut line_rate = 0.0;
401
402                        for attr in e.attributes().filter_map(|a| a.ok()) {
403                            let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
404                            let value = std::str::from_utf8(&attr.value).unwrap_or("");
405
406                            match key {
407                                "name" => name = value.to_string(),
408                                "line-rate" => {
409                                    line_rate = value.parse::<f64>().unwrap_or(0.0)
410                                }
411                                _ => {}
412                            }
413                        }
414
415                        if !name.is_empty() {
416                            // We'll get the line number from the first line inside
417                            current_functions.push(FunctionCoverage {
418                                name,
419                                line: 0, // Will be updated from line elements
420                                hits: if line_rate > 0.0 { 1 } else { 0 },
421                            });
422                        }
423                    }
424                    "line" => {
425                        // Parse line coverage
426                        let mut line_num: u32 = 0;
427                        let mut hits: u64 = 0;
428
429                        for attr in e.attributes().filter_map(|a| a.ok()) {
430                            let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
431                            let value = std::str::from_utf8(&attr.value).unwrap_or("");
432
433                            match key {
434                                "number" => line_num = value.parse().unwrap_or(0),
435                                "hits" => hits = value.parse().unwrap_or(0),
436                                _ => {}
437                            }
438                        }
439
440                        if line_num > 0 {
441                            // Use the last value if there are conflicts (per spec)
442                            current_lines.insert(line_num, hits);
443
444                            // Update function line if this is the first line
445                            if let Some(func) = current_functions.last_mut() {
446                                if func.line == 0 {
447                                    func.line = line_num;
448                                    func.hits = hits;
449                                }
450                            }
451                        }
452                    }
453                    _ => {}
454                }
455            }
456            Ok(Event::End(e)) => {
457                let name_bytes = e.name();
458                let tag_name = std::str::from_utf8(name_bytes.as_ref()).unwrap_or("");
459
460                if tag_name == "class" {
461                    // Finalize current file
462                    if let Some(mut file) = current_file.take() {
463                        file.total_lines = current_lines.len() as u32;
464                        file.covered_lines = current_lines.values().filter(|&&h| h > 0).count() as u32;
465                        file.uncovered_lines = current_lines
466                            .iter()
467                            .filter(|(_, &h)| h == 0)
468                            .map(|(&l, _)| l)
469                            .collect();
470                        file.uncovered_lines.sort();
471                        file.functions = std::mem::take(&mut current_functions);
472
473                        // Recalculate line coverage from actual data
474                        if file.total_lines > 0 {
475                            file.line_coverage =
476                                (file.covered_lines as f64 / file.total_lines as f64) * 100.0;
477                        }
478
479                        files.push(file);
480                    }
481                }
482            }
483            Ok(Event::Eof) => break,
484            Err(e) => {
485                return Err(TldrError::CoverageParseError {
486                    format: "cobertura".to_string(),
487                    detail: format!("XML parse error at position {}: {}", reader.buffer_position(), e),
488                });
489            }
490            _ => {}
491        }
492        buf.clear();
493    }
494
495    // Calculate summary
496    let (total_lines, covered_lines) = match (root_lines_valid, root_lines_covered) {
497        (Some(valid), Some(covered)) => (valid, covered),
498        _ => files.iter().fold((0u32, 0u32), |(tl, cl), f| {
499            (tl + f.total_lines, cl + f.covered_lines)
500        }),
501    };
502
503    let line_coverage = root_line_rate.unwrap_or_else(|| {
504        if total_lines > 0 {
505            (covered_lines as f64 / total_lines as f64) * 100.0
506        } else {
507            0.0
508        }
509    });
510
511    // Count functions
512    let (total_functions, covered_functions): (u32, u32) = files.iter().fold((0, 0), |(tf, cf), f| {
513        let covered = f.functions.iter().filter(|func| func.hits > 0).count() as u32;
514        (tf + f.functions.len() as u32, cf + covered)
515    });
516
517    let function_coverage = if total_functions > 0 {
518        Some((covered_functions as f64 / total_functions as f64) * 100.0)
519    } else {
520        None
521    };
522
523    let summary = CoverageSummary {
524        line_coverage,
525        branch_coverage: root_branch_rate,
526        function_coverage,
527        total_lines,
528        covered_lines,
529        total_branches: None,
530        covered_branches: None,
531        total_functions: if total_functions > 0 {
532            Some(total_functions)
533        } else {
534            None
535        },
536        covered_functions: if total_functions > 0 {
537            Some(covered_functions)
538        } else {
539            None
540        },
541        threshold_met: false, // Will be set by parse_coverage
542    };
543
544    Ok(CoverageReport {
545        format: CoverageFormat::Cobertura,
546        summary,
547        files,
548        uncovered: None,
549        warnings,
550    })
551}
552
553// =============================================================================
554// LCOV Parser
555// =============================================================================
556
557/// Parse LCOV format
558pub fn parse_lcov(content: &str) -> TldrResult<CoverageReport> {
559    let mut files: Vec<FileCoverage> = Vec::new();
560    let warnings: Vec<String> = Vec::new();
561    let mut state = LcovParseState::default();
562
563    for line in content.lines().map(str::trim) {
564        if let Some(path) = line.strip_prefix("SF:") {
565            state.reset(path.to_string());
566            continue;
567        }
568        if let Some(payload) = line.strip_prefix("FN:") {
569            state.parse_function_definition(payload);
570            continue;
571        }
572        if let Some(payload) = line.strip_prefix("FNDA:") {
573            state.parse_function_hits(payload);
574            continue;
575        }
576        if let Some(payload) = line.strip_prefix("DA:") {
577            state.parse_line_hits(payload);
578            continue;
579        }
580        if let Some(payload) = line.strip_prefix("LF:") {
581            state.lf = payload.parse().unwrap_or(0);
582            continue;
583        }
584        if let Some(payload) = line.strip_prefix("LH:") {
585            state.lh = payload.parse().unwrap_or(0);
586            continue;
587        }
588        if let Some(payload) = line.strip_prefix("BRF:") {
589            state.brf = payload.parse().ok();
590            continue;
591        }
592        if let Some(payload) = line.strip_prefix("BRH:") {
593            state.brh = payload.parse().ok();
594            continue;
595        }
596        if line == "end_of_record" {
597            if let Some(file_coverage) = state.finalize_current_file() {
598                files.push(file_coverage);
599            }
600        }
601    }
602
603    let summary = summarize_lcov_files(&files);
604
605    Ok(CoverageReport {
606        format: CoverageFormat::Lcov,
607        summary,
608        files,
609        uncovered: None,
610        warnings,
611    })
612}
613
614#[derive(Default)]
615struct LcovParseState {
616    current_file: Option<String>,
617    current_lines: HashMap<u32, u64>,
618    current_functions: Vec<FunctionCoverage>,
619    lf: u32,
620    lh: u32,
621    brf: Option<u32>,
622    brh: Option<u32>,
623}
624
625impl LcovParseState {
626    fn reset(&mut self, file_path: String) {
627        self.current_file = Some(file_path);
628        self.current_lines.clear();
629        self.current_functions.clear();
630        self.lf = 0;
631        self.lh = 0;
632        self.brf = None;
633        self.brh = None;
634    }
635
636    fn parse_function_definition(&mut self, payload: &str) {
637        let parts: Vec<&str> = payload.splitn(2, ',').collect();
638        if parts.len() != 2 {
639            return;
640        }
641        let Ok(line_num) = parts[0].parse::<u32>() else {
642            return;
643        };
644        self.current_functions.push(FunctionCoverage {
645            name: parts[1].to_string(),
646            line: line_num,
647            hits: 0,
648        });
649    }
650
651    fn parse_function_hits(&mut self, payload: &str) {
652        let parts: Vec<&str> = payload.splitn(2, ',').collect();
653        if parts.len() != 2 {
654            return;
655        }
656        let Ok(hits) = parts[0].parse::<u64>() else {
657            return;
658        };
659        if let Some(func) = self.current_functions.iter_mut().find(|f| f.name == parts[1]) {
660            func.hits = hits;
661        }
662    }
663
664    fn parse_line_hits(&mut self, payload: &str) {
665        let parts: Vec<&str> = payload.splitn(2, ',').collect();
666        if parts.len() < 2 {
667            return;
668        }
669        let (Ok(line_num), Ok(hits)) = (parts[0].parse::<u32>(), parts[1].parse::<u64>()) else {
670            return;
671        };
672        self.current_lines.insert(line_num, hits);
673    }
674
675    fn finalize_current_file(&mut self) -> Option<FileCoverage> {
676        let path = self.current_file.take()?;
677        let total_lines = if self.lf > 0 {
678            self.lf
679        } else {
680            self.current_lines.len() as u32
681        };
682        let covered_lines = if self.lh > 0 {
683            self.lh
684        } else {
685            self.current_lines.values().filter(|&&h| h > 0).count() as u32
686        };
687        let line_coverage = if total_lines > 0 {
688            (covered_lines as f64 / total_lines as f64) * 100.0
689        } else {
690            0.0
691        };
692        let branch_coverage = match (self.brf, self.brh) {
693            (Some(total), Some(hit)) if total > 0 => Some((hit as f64 / total as f64) * 100.0),
694            _ => None,
695        };
696        let uncovered_lines: Vec<u32> = self
697            .current_lines
698            .iter()
699            .filter(|(_, &hits)| hits == 0)
700            .map(|(&line, _)| line)
701            .collect();
702
703        Some(FileCoverage {
704            path,
705            line_coverage,
706            branch_coverage,
707            total_lines,
708            covered_lines,
709            total_branches: self.brf,
710            covered_branches: self.brh,
711            uncovered_lines,
712            functions: std::mem::take(&mut self.current_functions),
713            file_exists: None,
714        })
715    }
716}
717
718fn summarize_lcov_files(files: &[FileCoverage]) -> CoverageSummary {
719    let (total_lines, covered_lines) = files
720        .iter()
721        .fold((0u32, 0u32), |(tl, cl), file| (tl + file.total_lines, cl + file.covered_lines));
722    let line_coverage = if total_lines > 0 {
723        (covered_lines as f64 / total_lines as f64) * 100.0
724    } else {
725        0.0
726    };
727
728    let (total_branches, covered_branches) = files.iter().fold((0u32, 0u32), |(tb, cb), file| {
729        (
730            tb + file.total_branches.unwrap_or(0),
731            cb + file.covered_branches.unwrap_or(0),
732        )
733    });
734    let branch_coverage = if total_branches > 0 {
735        Some((covered_branches as f64 / total_branches as f64) * 100.0)
736    } else {
737        None
738    };
739
740    let (total_functions, covered_functions) = files.iter().fold((0u32, 0u32), |(tf, cf), file| {
741        let covered = file.functions.iter().filter(|func| func.hits > 0).count() as u32;
742        (tf + file.functions.len() as u32, cf + covered)
743    });
744    let function_coverage = if total_functions > 0 {
745        Some((covered_functions as f64 / total_functions as f64) * 100.0)
746    } else {
747        None
748    };
749
750    CoverageSummary {
751        line_coverage,
752        branch_coverage,
753        function_coverage,
754        total_lines,
755        covered_lines,
756        total_branches: (total_branches > 0).then_some(total_branches),
757        covered_branches: (covered_branches > 0).then_some(covered_branches),
758        total_functions: (total_functions > 0).then_some(total_functions),
759        covered_functions: (total_functions > 0).then_some(covered_functions),
760        threshold_met: false,
761    }
762}
763
764// =============================================================================
765// coverage.py JSON Parser
766// =============================================================================
767
768/// Intermediate structure for coverage.py JSON
769#[derive(Debug, Deserialize)]
770struct CoveragePyJson {
771    #[serde(default)]
772    files: HashMap<String, CoveragePyFile>,
773    #[serde(default)]
774    totals: CoveragePyTotals,
775}
776
777#[derive(Debug, Default, Deserialize)]
778struct CoveragePyFile {
779    #[serde(default)]
780    executed_lines: Vec<u32>,
781    #[serde(default)]
782    missing_lines: Vec<u32>,
783    #[serde(default)]
784    summary: Option<CoveragePyFileSummary>,
785}
786
787#[derive(Debug, Default, Deserialize)]
788struct CoveragePyFileSummary {
789    #[serde(default)]
790    percent_covered: f64,
791}
792
793#[derive(Debug, Default, Deserialize)]
794struct CoveragePyTotals {
795    #[serde(default)]
796    covered_lines: u32,
797    #[serde(default)]
798    num_statements: u32,
799    #[serde(default)]
800    percent_covered: f64,
801}
802
803/// Parse coverage.py JSON format
804pub fn parse_coverage_py_json(json_str: &str) -> TldrResult<CoverageReport> {
805    let parsed: CoveragePyJson = serde_json::from_str(json_str).map_err(|e| {
806        TldrError::CoverageParseError {
807            format: "coveragepy".to_string(),
808            detail: format!("JSON parse error: {}", e),
809        }
810    })?;
811
812    let mut files: Vec<FileCoverage> = Vec::new();
813    let warnings: Vec<String> = Vec::new();
814
815    for (path, file_data) in parsed.files {
816        let total_lines = file_data.executed_lines.len() as u32 + file_data.missing_lines.len() as u32;
817        let covered_lines = file_data.executed_lines.len() as u32;
818
819        let line_coverage = if let Some(summary) = &file_data.summary {
820            summary.percent_covered
821        } else if total_lines > 0 {
822            (covered_lines as f64 / total_lines as f64) * 100.0
823        } else {
824            0.0
825        };
826
827        files.push(FileCoverage {
828            path,
829            line_coverage,
830            branch_coverage: None, // coverage.py JSON doesn't include branch by default
831            total_lines,
832            covered_lines,
833            total_branches: None,
834            covered_branches: None,
835            uncovered_lines: file_data.missing_lines,
836            functions: Vec::new(), // coverage.py JSON doesn't include function data by default
837            file_exists: None,
838        });
839    }
840
841    let summary = CoverageSummary {
842        line_coverage: parsed.totals.percent_covered,
843        branch_coverage: None,
844        function_coverage: None,
845        total_lines: parsed.totals.num_statements,
846        covered_lines: parsed.totals.covered_lines,
847        total_branches: None,
848        covered_branches: None,
849        total_functions: None,
850        covered_functions: None,
851        threshold_met: false,
852    };
853
854    Ok(CoverageReport {
855        format: CoverageFormat::CoveragePy,
856        summary,
857        files,
858        uncovered: None,
859        warnings,
860    })
861}
862
863// =============================================================================
864// Helper Functions
865// =============================================================================
866
867/// Build summary of uncovered code
868fn build_uncovered_summary(files: &[FileCoverage]) -> UncoveredSummary {
869    let mut uncovered_functions: Vec<UncoveredFunction> = Vec::new();
870    let mut line_ranges: Vec<UncoveredLineRange> = Vec::new();
871
872    for file in files {
873        // Collect uncovered functions
874        for func in &file.functions {
875            if func.hits == 0 {
876                uncovered_functions.push(UncoveredFunction {
877                    file: file.path.clone(),
878                    name: func.name.clone(),
879                    line: func.line,
880                });
881            }
882        }
883
884        // Consolidate uncovered lines into ranges
885        if !file.uncovered_lines.is_empty() {
886            let mut sorted_lines: Vec<u32> = file.uncovered_lines.clone();
887            sorted_lines.sort();
888
889            let mut start = sorted_lines[0];
890            let mut end = start;
891
892            for &line in &sorted_lines[1..] {
893                if line == end + 1 {
894                    end = line;
895                } else {
896                    line_ranges.push(UncoveredLineRange {
897                        file: file.path.clone(),
898                        start,
899                        end,
900                    });
901                    start = line;
902                    end = line;
903                }
904            }
905
906            // Push the last range
907            line_ranges.push(UncoveredLineRange {
908                file: file.path.clone(),
909                start,
910                end,
911            });
912        }
913    }
914
915    UncoveredSummary {
916        functions: uncovered_functions,
917        line_ranges,
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    #[test]
926    fn test_detect_format_cobertura() {
927        let xml = r#"<?xml version="1.0" ?><coverage></coverage>"#;
928        assert_eq!(detect_format(xml), CoverageFormat::Cobertura);
929    }
930
931    #[test]
932    fn test_detect_format_lcov() {
933        let lcov = "TN:test\nSF:/path/file.py\nDA:1,1\nend_of_record";
934        assert_eq!(detect_format(lcov), CoverageFormat::Lcov);
935    }
936
937    #[test]
938    fn test_detect_format_coveragepy() {
939        let json = r#"{"meta": {}, "files": {}}"#;
940        assert_eq!(detect_format(json), CoverageFormat::CoveragePy);
941    }
942
943    #[test]
944    fn test_parse_cobertura_basic() {
945        // Test without root line-rate to verify recalculation from actual line data
946        let xml = r#"<?xml version="1.0" ?>
947<coverage>
948    <packages>
949        <package name="pkg">
950            <classes>
951                <class filename="src/test.py">
952                    <methods>
953                        <method name="func1" line-rate="1.0" />
954                    </methods>
955                    <lines>
956                        <line number="1" hits="5"/>
957                        <line number="2" hits="0"/>
958                    </lines>
959                </class>
960            </classes>
961        </package>
962    </packages>
963</coverage>"#;
964
965        let report = parse_cobertura(xml).expect("Should parse");
966        // 1 of 2 lines covered = 50%
967        assert!(
968            (report.summary.line_coverage - 50.0).abs() < 1.0,
969            "Expected ~50%, got {}",
970            report.summary.line_coverage
971        );
972        assert_eq!(report.files.len(), 1);
973        assert_eq!(report.files[0].path, "src/test.py");
974        // Verify per-file coverage also recalculated
975        assert!(
976            (report.files[0].line_coverage - 50.0).abs() < 1.0,
977            "File coverage should be ~50%, got {}",
978            report.files[0].line_coverage
979        );
980    }
981
982    #[test]
983    fn test_parse_lcov_basic() {
984        let lcov = r#"TN:test
985SF:/path/test.py
986FN:10,func1
987FNDA:5,func1
988DA:1,5
989DA:2,0
990DA:3,3
991LF:3
992LH:2
993end_of_record"#;
994
995        let report = parse_lcov(lcov).expect("Should parse");
996        assert!((report.summary.line_coverage - 66.67).abs() < 1.0); // 2 of 3 lines
997        assert_eq!(report.files.len(), 1);
998        assert_eq!(report.files[0].functions.len(), 1);
999        assert_eq!(report.files[0].functions[0].hits, 5);
1000    }
1001
1002    #[test]
1003    fn test_parse_coveragepy_basic() {
1004        let json = r#"{
1005            "meta": {"version": "7.0"},
1006            "files": {
1007                "src/test.py": {
1008                    "executed_lines": [1, 2, 3],
1009                    "missing_lines": [4, 5]
1010                }
1011            },
1012            "totals": {
1013                "covered_lines": 3,
1014                "num_statements": 5,
1015                "percent_covered": 60.0
1016            }
1017        }"#;
1018
1019        let report = parse_coverage_py_json(json).expect("Should parse");
1020        assert!((report.summary.line_coverage - 60.0).abs() < 0.1);
1021        assert_eq!(report.files.len(), 1);
1022    }
1023
1024    #[test]
1025    fn test_coverage_range_consolidation() {
1026        let files = vec![FileCoverage {
1027            path: "test.py".to_string(),
1028            line_coverage: 50.0,
1029            branch_coverage: None,
1030            total_lines: 10,
1031            covered_lines: 5,
1032            total_branches: None,
1033            covered_branches: None,
1034            uncovered_lines: vec![1, 2, 3, 7, 8, 10], // Should become [1-3], [7-8], [10-10]
1035            functions: Vec::new(),
1036            file_exists: None,
1037        }];
1038
1039        let summary = build_uncovered_summary(&files);
1040        assert_eq!(summary.line_ranges.len(), 3);
1041        assert_eq!(summary.line_ranges[0].start, 1);
1042        assert_eq!(summary.line_ranges[0].end, 3);
1043        assert_eq!(summary.line_ranges[1].start, 7);
1044        assert_eq!(summary.line_ranges[1].end, 8);
1045        assert_eq!(summary.line_ranges[2].start, 10);
1046        assert_eq!(summary.line_ranges[2].end, 10);
1047    }
1048
1049    #[test]
1050    fn test_empty_coverage_report() {
1051        let json = r#"{
1052            "meta": {"version": "7.0"},
1053            "files": {},
1054            "totals": {
1055                "covered_lines": 0,
1056                "num_statements": 0,
1057                "percent_covered": 0.0
1058            }
1059        }"#;
1060
1061        let report = parse_coverage_py_json(json).expect("Should parse empty report");
1062        assert!((report.summary.line_coverage - 0.0).abs() < 0.1);
1063        assert_eq!(report.files.len(), 0);
1064    }
1065
1066    #[test]
1067    fn test_malformed_xml_error() {
1068        let bad_xml = r#"<?xml version="1.0" ?>
1069<coverage>
1070    <packages>
1071        <package>
1072            <!-- Missing closing tag"#;
1073
1074        let result = parse_cobertura(bad_xml);
1075        assert!(result.is_err());
1076        if let Err(TldrError::CoverageParseError { format, detail }) = result {
1077            assert_eq!(format, "cobertura");
1078            assert!(detail.contains("XML parse error"));
1079        }
1080    }
1081}