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" => line_rate = value.parse::<f64>().unwrap_or(0.0),
409                                _ => {}
410                            }
411                        }
412
413                        if !name.is_empty() {
414                            // We'll get the line number from the first line inside
415                            current_functions.push(FunctionCoverage {
416                                name,
417                                line: 0, // Will be updated from line elements
418                                hits: if line_rate > 0.0 { 1 } else { 0 },
419                            });
420                        }
421                    }
422                    "line" => {
423                        // Parse line coverage
424                        let mut line_num: u32 = 0;
425                        let mut hits: u64 = 0;
426
427                        for attr in e.attributes().filter_map(|a| a.ok()) {
428                            let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("");
429                            let value = std::str::from_utf8(&attr.value).unwrap_or("");
430
431                            match key {
432                                "number" => line_num = value.parse().unwrap_or(0),
433                                "hits" => hits = value.parse().unwrap_or(0),
434                                _ => {}
435                            }
436                        }
437
438                        if line_num > 0 {
439                            // Use the last value if there are conflicts (per spec)
440                            current_lines.insert(line_num, hits);
441
442                            // Update function line if this is the first line
443                            if let Some(func) = current_functions.last_mut() {
444                                if func.line == 0 {
445                                    func.line = line_num;
446                                    func.hits = hits;
447                                }
448                            }
449                        }
450                    }
451                    _ => {}
452                }
453            }
454            Ok(Event::End(e)) => {
455                let name_bytes = e.name();
456                let tag_name = std::str::from_utf8(name_bytes.as_ref()).unwrap_or("");
457
458                if tag_name == "class" {
459                    // Finalize current file
460                    if let Some(mut file) = current_file.take() {
461                        file.total_lines = current_lines.len() as u32;
462                        file.covered_lines =
463                            current_lines.values().filter(|&&h| h > 0).count() as u32;
464                        file.uncovered_lines = current_lines
465                            .iter()
466                            .filter(|(_, &h)| h == 0)
467                            .map(|(&l, _)| l)
468                            .collect();
469                        file.uncovered_lines.sort();
470                        file.functions = std::mem::take(&mut current_functions);
471
472                        // Recalculate line coverage from actual data
473                        if file.total_lines > 0 {
474                            file.line_coverage =
475                                (file.covered_lines as f64 / file.total_lines as f64) * 100.0;
476                        }
477
478                        files.push(file);
479                    }
480                }
481            }
482            Ok(Event::Eof) => break,
483            Err(e) => {
484                return Err(TldrError::CoverageParseError {
485                    format: "cobertura".to_string(),
486                    detail: format!(
487                        "XML parse error at position {}: {}",
488                        reader.buffer_position(),
489                        e
490                    ),
491                });
492            }
493            _ => {}
494        }
495        buf.clear();
496    }
497
498    // Calculate summary
499    let (total_lines, covered_lines) = match (root_lines_valid, root_lines_covered) {
500        (Some(valid), Some(covered)) => (valid, covered),
501        _ => files.iter().fold((0u32, 0u32), |(tl, cl), f| {
502            (tl + f.total_lines, cl + f.covered_lines)
503        }),
504    };
505
506    let line_coverage = root_line_rate.unwrap_or_else(|| {
507        if total_lines > 0 {
508            (covered_lines as f64 / total_lines as f64) * 100.0
509        } else {
510            0.0
511        }
512    });
513
514    // Count functions
515    let (total_functions, covered_functions): (u32, u32) =
516        files.iter().fold((0, 0), |(tf, cf), f| {
517            let covered = f.functions.iter().filter(|func| func.hits > 0).count() as u32;
518            (tf + f.functions.len() as u32, cf + covered)
519        });
520
521    let function_coverage = if total_functions > 0 {
522        Some((covered_functions as f64 / total_functions as f64) * 100.0)
523    } else {
524        None
525    };
526
527    let summary = CoverageSummary {
528        line_coverage,
529        branch_coverage: root_branch_rate,
530        function_coverage,
531        total_lines,
532        covered_lines,
533        total_branches: None,
534        covered_branches: None,
535        total_functions: if total_functions > 0 {
536            Some(total_functions)
537        } else {
538            None
539        },
540        covered_functions: if total_functions > 0 {
541            Some(covered_functions)
542        } else {
543            None
544        },
545        threshold_met: false, // Will be set by parse_coverage
546    };
547
548    Ok(CoverageReport {
549        format: CoverageFormat::Cobertura,
550        summary,
551        files,
552        uncovered: None,
553        warnings,
554    })
555}
556
557// =============================================================================
558// LCOV Parser
559// =============================================================================
560
561/// Parse LCOV format
562pub fn parse_lcov(content: &str) -> TldrResult<CoverageReport> {
563    let mut files: Vec<FileCoverage> = Vec::new();
564    let warnings: Vec<String> = Vec::new();
565    let mut state = LcovParseState::default();
566
567    for line in content.lines().map(str::trim) {
568        if let Some(path) = line.strip_prefix("SF:") {
569            state.reset(path.to_string());
570            continue;
571        }
572        if let Some(payload) = line.strip_prefix("FN:") {
573            state.parse_function_definition(payload);
574            continue;
575        }
576        if let Some(payload) = line.strip_prefix("FNDA:") {
577            state.parse_function_hits(payload);
578            continue;
579        }
580        if let Some(payload) = line.strip_prefix("DA:") {
581            state.parse_line_hits(payload);
582            continue;
583        }
584        if let Some(payload) = line.strip_prefix("LF:") {
585            state.lf = payload.parse().unwrap_or(0);
586            continue;
587        }
588        if let Some(payload) = line.strip_prefix("LH:") {
589            state.lh = payload.parse().unwrap_or(0);
590            continue;
591        }
592        if let Some(payload) = line.strip_prefix("BRF:") {
593            state.brf = payload.parse().ok();
594            continue;
595        }
596        if let Some(payload) = line.strip_prefix("BRH:") {
597            state.brh = payload.parse().ok();
598            continue;
599        }
600        if line == "end_of_record" {
601            if let Some(file_coverage) = state.finalize_current_file() {
602                files.push(file_coverage);
603            }
604        }
605    }
606
607    let summary = summarize_lcov_files(&files);
608
609    Ok(CoverageReport {
610        format: CoverageFormat::Lcov,
611        summary,
612        files,
613        uncovered: None,
614        warnings,
615    })
616}
617
618#[derive(Default)]
619struct LcovParseState {
620    current_file: Option<String>,
621    current_lines: HashMap<u32, u64>,
622    current_functions: Vec<FunctionCoverage>,
623    lf: u32,
624    lh: u32,
625    brf: Option<u32>,
626    brh: Option<u32>,
627}
628
629impl LcovParseState {
630    fn reset(&mut self, file_path: String) {
631        self.current_file = Some(file_path);
632        self.current_lines.clear();
633        self.current_functions.clear();
634        self.lf = 0;
635        self.lh = 0;
636        self.brf = None;
637        self.brh = None;
638    }
639
640    fn parse_function_definition(&mut self, payload: &str) {
641        let parts: Vec<&str> = payload.splitn(2, ',').collect();
642        if parts.len() != 2 {
643            return;
644        }
645        let Ok(line_num) = parts[0].parse::<u32>() else {
646            return;
647        };
648        self.current_functions.push(FunctionCoverage {
649            name: parts[1].to_string(),
650            line: line_num,
651            hits: 0,
652        });
653    }
654
655    fn parse_function_hits(&mut self, payload: &str) {
656        let parts: Vec<&str> = payload.splitn(2, ',').collect();
657        if parts.len() != 2 {
658            return;
659        }
660        let Ok(hits) = parts[0].parse::<u64>() else {
661            return;
662        };
663        if let Some(func) = self
664            .current_functions
665            .iter_mut()
666            .find(|f| f.name == parts[1])
667        {
668            func.hits = hits;
669        }
670    }
671
672    fn parse_line_hits(&mut self, payload: &str) {
673        let parts: Vec<&str> = payload.splitn(2, ',').collect();
674        if parts.len() < 2 {
675            return;
676        }
677        let (Ok(line_num), Ok(hits)) = (parts[0].parse::<u32>(), parts[1].parse::<u64>()) else {
678            return;
679        };
680        self.current_lines.insert(line_num, hits);
681    }
682
683    fn finalize_current_file(&mut self) -> Option<FileCoverage> {
684        let path = self.current_file.take()?;
685        let total_lines = if self.lf > 0 {
686            self.lf
687        } else {
688            self.current_lines.len() as u32
689        };
690        let covered_lines = if self.lh > 0 {
691            self.lh
692        } else {
693            self.current_lines.values().filter(|&&h| h > 0).count() as u32
694        };
695        let line_coverage = if total_lines > 0 {
696            (covered_lines as f64 / total_lines as f64) * 100.0
697        } else {
698            0.0
699        };
700        let branch_coverage = match (self.brf, self.brh) {
701            (Some(total), Some(hit)) if total > 0 => Some((hit as f64 / total as f64) * 100.0),
702            _ => None,
703        };
704        let uncovered_lines: Vec<u32> = self
705            .current_lines
706            .iter()
707            .filter(|(_, &hits)| hits == 0)
708            .map(|(&line, _)| line)
709            .collect();
710
711        Some(FileCoverage {
712            path,
713            line_coverage,
714            branch_coverage,
715            total_lines,
716            covered_lines,
717            total_branches: self.brf,
718            covered_branches: self.brh,
719            uncovered_lines,
720            functions: std::mem::take(&mut self.current_functions),
721            file_exists: None,
722        })
723    }
724}
725
726fn summarize_lcov_files(files: &[FileCoverage]) -> CoverageSummary {
727    let (total_lines, covered_lines) = files.iter().fold((0u32, 0u32), |(tl, cl), file| {
728        (tl + file.total_lines, cl + file.covered_lines)
729    });
730    let line_coverage = if total_lines > 0 {
731        (covered_lines as f64 / total_lines as f64) * 100.0
732    } else {
733        0.0
734    };
735
736    let (total_branches, covered_branches) = files.iter().fold((0u32, 0u32), |(tb, cb), file| {
737        (
738            tb + file.total_branches.unwrap_or(0),
739            cb + file.covered_branches.unwrap_or(0),
740        )
741    });
742    let branch_coverage = if total_branches > 0 {
743        Some((covered_branches as f64 / total_branches as f64) * 100.0)
744    } else {
745        None
746    };
747
748    let (total_functions, covered_functions) = files.iter().fold((0u32, 0u32), |(tf, cf), file| {
749        let covered = file.functions.iter().filter(|func| func.hits > 0).count() as u32;
750        (tf + file.functions.len() as u32, cf + covered)
751    });
752    let function_coverage = if total_functions > 0 {
753        Some((covered_functions as f64 / total_functions as f64) * 100.0)
754    } else {
755        None
756    };
757
758    CoverageSummary {
759        line_coverage,
760        branch_coverage,
761        function_coverage,
762        total_lines,
763        covered_lines,
764        total_branches: (total_branches > 0).then_some(total_branches),
765        covered_branches: (covered_branches > 0).then_some(covered_branches),
766        total_functions: (total_functions > 0).then_some(total_functions),
767        covered_functions: (total_functions > 0).then_some(covered_functions),
768        threshold_met: false,
769    }
770}
771
772// =============================================================================
773// coverage.py JSON Parser
774// =============================================================================
775
776/// Intermediate structure for coverage.py JSON
777#[derive(Debug, Deserialize)]
778struct CoveragePyJson {
779    #[serde(default)]
780    files: HashMap<String, CoveragePyFile>,
781    #[serde(default)]
782    totals: CoveragePyTotals,
783}
784
785#[derive(Debug, Default, Deserialize)]
786struct CoveragePyFile {
787    #[serde(default)]
788    executed_lines: Vec<u32>,
789    #[serde(default)]
790    missing_lines: Vec<u32>,
791    #[serde(default)]
792    summary: Option<CoveragePyFileSummary>,
793}
794
795#[derive(Debug, Default, Deserialize)]
796struct CoveragePyFileSummary {
797    #[serde(default)]
798    percent_covered: f64,
799}
800
801#[derive(Debug, Default, Deserialize)]
802struct CoveragePyTotals {
803    #[serde(default)]
804    covered_lines: u32,
805    #[serde(default)]
806    num_statements: u32,
807    #[serde(default)]
808    percent_covered: f64,
809}
810
811/// Parse coverage.py JSON format
812pub fn parse_coverage_py_json(json_str: &str) -> TldrResult<CoverageReport> {
813    let parsed: CoveragePyJson =
814        serde_json::from_str(json_str).map_err(|e| TldrError::CoverageParseError {
815            format: "coveragepy".to_string(),
816            detail: format!("JSON parse error: {}", e),
817        })?;
818
819    let mut files: Vec<FileCoverage> = Vec::new();
820    let warnings: Vec<String> = Vec::new();
821
822    for (path, file_data) in parsed.files {
823        let total_lines =
824            file_data.executed_lines.len() as u32 + file_data.missing_lines.len() as u32;
825        let covered_lines = file_data.executed_lines.len() as u32;
826
827        let line_coverage = if let Some(summary) = &file_data.summary {
828            summary.percent_covered
829        } else if total_lines > 0 {
830            (covered_lines as f64 / total_lines as f64) * 100.0
831        } else {
832            0.0
833        };
834
835        files.push(FileCoverage {
836            path,
837            line_coverage,
838            branch_coverage: None, // coverage.py JSON doesn't include branch by default
839            total_lines,
840            covered_lines,
841            total_branches: None,
842            covered_branches: None,
843            uncovered_lines: file_data.missing_lines,
844            functions: Vec::new(), // coverage.py JSON doesn't include function data by default
845            file_exists: None,
846        });
847    }
848
849    let summary = CoverageSummary {
850        line_coverage: parsed.totals.percent_covered,
851        branch_coverage: None,
852        function_coverage: None,
853        total_lines: parsed.totals.num_statements,
854        covered_lines: parsed.totals.covered_lines,
855        total_branches: None,
856        covered_branches: None,
857        total_functions: None,
858        covered_functions: None,
859        threshold_met: false,
860    };
861
862    Ok(CoverageReport {
863        format: CoverageFormat::CoveragePy,
864        summary,
865        files,
866        uncovered: None,
867        warnings,
868    })
869}
870
871// =============================================================================
872// Helper Functions
873// =============================================================================
874
875/// Build summary of uncovered code
876fn build_uncovered_summary(files: &[FileCoverage]) -> UncoveredSummary {
877    let mut uncovered_functions: Vec<UncoveredFunction> = Vec::new();
878    let mut line_ranges: Vec<UncoveredLineRange> = Vec::new();
879
880    for file in files {
881        // Collect uncovered functions
882        for func in &file.functions {
883            if func.hits == 0 {
884                uncovered_functions.push(UncoveredFunction {
885                    file: file.path.clone(),
886                    name: func.name.clone(),
887                    line: func.line,
888                });
889            }
890        }
891
892        // Consolidate uncovered lines into ranges
893        if !file.uncovered_lines.is_empty() {
894            let mut sorted_lines: Vec<u32> = file.uncovered_lines.clone();
895            sorted_lines.sort();
896
897            let mut start = sorted_lines[0];
898            let mut end = start;
899
900            for &line in &sorted_lines[1..] {
901                if line == end + 1 {
902                    end = line;
903                } else {
904                    line_ranges.push(UncoveredLineRange {
905                        file: file.path.clone(),
906                        start,
907                        end,
908                    });
909                    start = line;
910                    end = line;
911                }
912            }
913
914            // Push the last range
915            line_ranges.push(UncoveredLineRange {
916                file: file.path.clone(),
917                start,
918                end,
919            });
920        }
921    }
922
923    UncoveredSummary {
924        functions: uncovered_functions,
925        line_ranges,
926    }
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    #[test]
934    fn test_detect_format_cobertura() {
935        let xml = r#"<?xml version="1.0" ?><coverage></coverage>"#;
936        assert_eq!(detect_format(xml), CoverageFormat::Cobertura);
937    }
938
939    #[test]
940    fn test_detect_format_lcov() {
941        let lcov = "TN:test\nSF:/path/file.py\nDA:1,1\nend_of_record";
942        assert_eq!(detect_format(lcov), CoverageFormat::Lcov);
943    }
944
945    #[test]
946    fn test_detect_format_coveragepy() {
947        let json = r#"{"meta": {}, "files": {}}"#;
948        assert_eq!(detect_format(json), CoverageFormat::CoveragePy);
949    }
950
951    #[test]
952    fn test_parse_cobertura_basic() {
953        // Test without root line-rate to verify recalculation from actual line data
954        let xml = r#"<?xml version="1.0" ?>
955<coverage>
956    <packages>
957        <package name="pkg">
958            <classes>
959                <class filename="src/test.py">
960                    <methods>
961                        <method name="func1" line-rate="1.0" />
962                    </methods>
963                    <lines>
964                        <line number="1" hits="5"/>
965                        <line number="2" hits="0"/>
966                    </lines>
967                </class>
968            </classes>
969        </package>
970    </packages>
971</coverage>"#;
972
973        let report = parse_cobertura(xml).expect("Should parse");
974        // 1 of 2 lines covered = 50%
975        assert!(
976            (report.summary.line_coverage - 50.0).abs() < 1.0,
977            "Expected ~50%, got {}",
978            report.summary.line_coverage
979        );
980        assert_eq!(report.files.len(), 1);
981        assert_eq!(report.files[0].path, "src/test.py");
982        // Verify per-file coverage also recalculated
983        assert!(
984            (report.files[0].line_coverage - 50.0).abs() < 1.0,
985            "File coverage should be ~50%, got {}",
986            report.files[0].line_coverage
987        );
988    }
989
990    #[test]
991    fn test_parse_lcov_basic() {
992        let lcov = r#"TN:test
993SF:/path/test.py
994FN:10,func1
995FNDA:5,func1
996DA:1,5
997DA:2,0
998DA:3,3
999LF:3
1000LH:2
1001end_of_record"#;
1002
1003        let report = parse_lcov(lcov).expect("Should parse");
1004        assert!((report.summary.line_coverage - 66.67).abs() < 1.0); // 2 of 3 lines
1005        assert_eq!(report.files.len(), 1);
1006        assert_eq!(report.files[0].functions.len(), 1);
1007        assert_eq!(report.files[0].functions[0].hits, 5);
1008    }
1009
1010    #[test]
1011    fn test_parse_coveragepy_basic() {
1012        let json = r#"{
1013            "meta": {"version": "7.0"},
1014            "files": {
1015                "src/test.py": {
1016                    "executed_lines": [1, 2, 3],
1017                    "missing_lines": [4, 5]
1018                }
1019            },
1020            "totals": {
1021                "covered_lines": 3,
1022                "num_statements": 5,
1023                "percent_covered": 60.0
1024            }
1025        }"#;
1026
1027        let report = parse_coverage_py_json(json).expect("Should parse");
1028        assert!((report.summary.line_coverage - 60.0).abs() < 0.1);
1029        assert_eq!(report.files.len(), 1);
1030    }
1031
1032    #[test]
1033    fn test_coverage_range_consolidation() {
1034        let files = vec![FileCoverage {
1035            path: "test.py".to_string(),
1036            line_coverage: 50.0,
1037            branch_coverage: None,
1038            total_lines: 10,
1039            covered_lines: 5,
1040            total_branches: None,
1041            covered_branches: None,
1042            uncovered_lines: vec![1, 2, 3, 7, 8, 10], // Should become [1-3], [7-8], [10-10]
1043            functions: Vec::new(),
1044            file_exists: None,
1045        }];
1046
1047        let summary = build_uncovered_summary(&files);
1048        assert_eq!(summary.line_ranges.len(), 3);
1049        assert_eq!(summary.line_ranges[0].start, 1);
1050        assert_eq!(summary.line_ranges[0].end, 3);
1051        assert_eq!(summary.line_ranges[1].start, 7);
1052        assert_eq!(summary.line_ranges[1].end, 8);
1053        assert_eq!(summary.line_ranges[2].start, 10);
1054        assert_eq!(summary.line_ranges[2].end, 10);
1055    }
1056
1057    #[test]
1058    fn test_empty_coverage_report() {
1059        let json = r#"{
1060            "meta": {"version": "7.0"},
1061            "files": {},
1062            "totals": {
1063                "covered_lines": 0,
1064                "num_statements": 0,
1065                "percent_covered": 0.0
1066            }
1067        }"#;
1068
1069        let report = parse_coverage_py_json(json).expect("Should parse empty report");
1070        assert!((report.summary.line_coverage - 0.0).abs() < 0.1);
1071        assert_eq!(report.files.len(), 0);
1072    }
1073
1074    #[test]
1075    fn test_malformed_xml_error() {
1076        let bad_xml = r#"<?xml version="1.0" ?>
1077<coverage>
1078    <packages>
1079        <package>
1080            <!-- Missing closing tag"#;
1081
1082        let result = parse_cobertura(bad_xml);
1083        assert!(result.is_err());
1084        if let Err(TldrError::CoverageParseError { format, detail }) = result {
1085            assert_eq!(format, "cobertura");
1086            assert!(detail.contains("XML parse error"));
1087        }
1088    }
1089}