//
// 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;
}
}