1use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23use serde::{Deserialize, Serialize};
24
25use crate::error::TldrError;
26use crate::TldrResult;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum CoverageFormat {
36 Cobertura,
38 Lcov,
40 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#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LineCoverage {
57 pub line: u32,
59 pub hits: u64,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct FunctionCoverage {
66 pub name: String,
68 pub line: u32,
70 pub hits: u64,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct FileCoverage {
77 pub path: String,
79 pub line_coverage: f64,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub branch_coverage: Option<f64>,
84 pub total_lines: u32,
86 pub covered_lines: u32,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub total_branches: Option<u32>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub covered_branches: Option<u32>,
94 #[serde(skip_serializing_if = "Vec::is_empty", default)]
96 pub uncovered_lines: Vec<u32>,
97 #[serde(skip_serializing_if = "Vec::is_empty", default)]
99 pub functions: Vec<FunctionCoverage>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub file_exists: Option<bool>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct UncoveredFunction {
108 pub file: String,
110 pub name: String,
112 pub line: u32,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct UncoveredLineRange {
119 pub file: String,
121 pub start: u32,
123 pub end: u32,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct UncoveredSummary {
130 #[serde(skip_serializing_if = "Vec::is_empty", default)]
132 pub functions: Vec<UncoveredFunction>,
133 #[serde(skip_serializing_if = "Vec::is_empty", default)]
135 pub line_ranges: Vec<UncoveredLineRange>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct CoverageSummary {
141 pub line_coverage: f64,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub branch_coverage: Option<f64>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub function_coverage: Option<f64>,
149 pub total_lines: u32,
151 pub covered_lines: u32,
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub total_branches: Option<u32>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub covered_branches: Option<u32>,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 pub total_functions: Option<u32>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub covered_functions: Option<u32>,
165 pub threshold_met: bool,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CoverageReport {
172 pub format: CoverageFormat,
174 pub summary: CoverageSummary,
176 #[serde(skip_serializing_if = "Vec::is_empty", default)]
178 pub files: Vec<FileCoverage>,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub uncovered: Option<UncoveredSummary>,
182 #[serde(skip_serializing_if = "Vec::is_empty", default)]
184 pub warnings: Vec<String>,
185}
186
187#[derive(Debug, Clone, Default)]
189pub struct CoverageOptions {
190 pub threshold: f64,
192 pub by_file: bool,
194 pub include_uncovered: bool,
196 pub filter: Vec<String>,
198 pub base_path: Option<PathBuf>,
200}
201
202pub fn parse_coverage(
217 path: &Path,
218 format: Option<CoverageFormat>,
219 options: &CoverageOptions,
220) -> TldrResult<CoverageReport> {
221 if !path.exists() {
223 return Err(TldrError::PathNotFound(path.to_path_buf()));
224 }
225
226 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 let detected_format = format.unwrap_or_else(|| detect_format(&content));
235
236 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 report.summary.threshold_met = report.summary.line_coverage >= options.threshold;
245
246 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 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 if options.include_uncovered {
272 report.uncovered = Some(build_uncovered_summary(&report.files));
273 }
274
275 if !options.by_file {
277 report.files.clear();
278 }
279
280 Ok(report)
281}
282
283pub fn detect_format(content: &str) -> CoverageFormat {
285 let trimmed = content.trim();
286
287 if trimmed.starts_with("<?xml") || trimmed.starts_with("<coverage") {
289 return CoverageFormat::Cobertura;
290 }
291
292 if trimmed.contains("SF:") && trimmed.contains("end_of_record") {
294 return CoverageFormat::Lcov;
295 }
296
297 if trimmed.starts_with('{') {
299 return CoverageFormat::CoveragePy;
300 }
301
302 CoverageFormat::Cobertura
304}
305
306pub 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 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 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 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 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 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 current_functions.push(FunctionCoverage {
418 name,
419 line: 0, hits: if line_rate > 0.0 { 1 } else { 0 },
421 });
422 }
423 }
424 "line" => {
425 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 current_lines.insert(line_num, hits);
443
444 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 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 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 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 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, };
543
544 Ok(CoverageReport {
545 format: CoverageFormat::Cobertura,
546 summary,
547 files,
548 uncovered: None,
549 warnings,
550 })
551}
552
553pub 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#[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
803pub 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, total_lines,
832 covered_lines,
833 total_branches: None,
834 covered_branches: None,
835 uncovered_lines: file_data.missing_lines,
836 functions: Vec::new(), 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
863fn 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 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 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 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 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 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 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); 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], 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}