// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // // Sprint: SPRINT_20260105_002_005_TEST_cross_cutting // Task: CCUT-014 using System.Collections.Immutable; using System.Globalization; using System.Xml.Linq; namespace StellaOps.Testing.Coverage; /// /// Parses Cobertura XML coverage reports. /// public static class CoberturaParser { /// /// Parse a Cobertura XML file. /// /// Path to Cobertura XML file. /// Cancellation token. /// Parsed coverage report. public static async Task ParseFileAsync(string filePath, CancellationToken ct = default) { var xml = await File.ReadAllTextAsync(filePath, ct); return Parse(xml); } /// /// Parse a Cobertura XML string. /// /// Cobertura XML content. /// Parsed coverage report. public static CoverageReport Parse(string xml) { var doc = XDocument.Parse(xml); var coverage = doc.Root ?? throw new InvalidOperationException("Invalid Cobertura XML: no root element"); var files = new List(); // Parse overall coverage var lineCoverage = ParseDecimal(coverage.Attribute("line-rate")?.Value ?? "0"); var branchCoverage = ParseDecimal(coverage.Attribute("branch-rate")?.Value ?? "0"); // Parse timestamp var timestamp = coverage.Attribute("timestamp")?.Value; var generatedAt = timestamp != null ? DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp, CultureInfo.InvariantCulture)) : DateTimeOffset.UtcNow; // Parse packages -> classes -> files foreach (var package in coverage.Descendants("package")) { foreach (var cls in package.Descendants("class")) { var fileCoverage = ParseClass(cls); if (fileCoverage != null) { files.Add(fileCoverage); } } } return new CoverageReport( Files: [.. files], OverallLineCoverage: lineCoverage, OverallBranchCoverage: branchCoverage, GeneratedAt: generatedAt); } private static FileCoverage? ParseClass(XElement cls) { var filename = cls.Attribute("filename")?.Value; if (string.IsNullOrEmpty(filename)) { return null; } var lineCoverage = ParseDecimal(cls.Attribute("line-rate")?.Value ?? "0"); var branchCoverage = ParseDecimal(cls.Attribute("branch-rate")?.Value ?? "0"); var lines = new List(); var branches = new List(); var linesElement = cls.Element("lines"); if (linesElement != null) { foreach (var line in linesElement.Elements("line")) { var lineNumber = int.Parse(line.Attribute("number")?.Value ?? "0", CultureInfo.InvariantCulture); var hits = int.Parse(line.Attribute("hits")?.Value ?? "0", CultureInfo.InvariantCulture); var isBranch = line.Attribute("branch")?.Value == "true"; lines.Add(new LineCoverageData( LineNumber: lineNumber, HitCount: hits, IsCoverable: true)); // Parse branch conditions if present if (isBranch) { var conditionCoverage = line.Attribute("condition-coverage")?.Value; var conditions = line.Element("conditions"); if (conditions != null) { var branchIndex = 0; foreach (var condition in conditions.Elements("condition")) { var coverage = int.Parse( condition.Attribute("coverage")?.Value ?? "0", CultureInfo.InvariantCulture); branches.Add(new BranchCoverageData( Line: lineNumber, BranchId: $"{lineNumber}-{branchIndex}", Type: condition.Attribute("type")?.Value ?? "branch", HitCount: coverage > 0 ? 1 : 0)); branchIndex++; } } else if (conditionCoverage != null) { // Parse condition-coverage like "50% (1/2)" var parts = conditionCoverage.Split(['(', '/', ')'], StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2) { var covered = int.Parse(parts[0].TrimEnd('%'), CultureInfo.InvariantCulture); var total = int.Parse(parts[1], CultureInfo.InvariantCulture); for (int i = 0; i < total; i++) { branches.Add(new BranchCoverageData( Line: lineNumber, BranchId: $"{lineNumber}-{i}", Type: "branch", HitCount: i < (covered * total / 100) ? 1 : 0)); } } } } } } return new FileCoverage( Path: filename, LineCoverage: lineCoverage, BranchCoverage: branchCoverage, Lines: [.. lines], Branches: [.. branches]); } private static decimal ParseDecimal(string value) { if (decimal.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { return result; } return 0m; } }