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" => line_rate = value.parse::<f64>().unwrap_or(0.0),
409 _ => {}
410 }
411 }
412
413 if !name.is_empty() {
414 current_functions.push(FunctionCoverage {
416 name,
417 line: 0, hits: if line_rate > 0.0 { 1 } else { 0 },
419 });
420 }
421 }
422 "line" => {
423 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 current_lines.insert(line_num, hits);
441
442 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 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 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 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 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, };
547
548 Ok(CoverageReport {
549 format: CoverageFormat::Cobertura,
550 summary,
551 files,
552 uncovered: None,
553 warnings,
554 })
555}
556
557pub 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#[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
811pub 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, total_lines,
840 covered_lines,
841 total_branches: None,
842 covered_branches: None,
843 uncovered_lines: file_data.missing_lines,
844 functions: Vec::new(), 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
871fn 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 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 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 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 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 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 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); 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], 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}