165 lines
6.1 KiB
C#
165 lines
6.1 KiB
C#
// <copyright file="CoberturaParser.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
|
// </copyright>
|
|
// 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;
|
|
|
|
/// <summary>
|
|
/// Parses Cobertura XML coverage reports.
|
|
/// </summary>
|
|
public static class CoberturaParser
|
|
{
|
|
/// <summary>
|
|
/// Parse a Cobertura XML file.
|
|
/// </summary>
|
|
/// <param name="filePath">Path to Cobertura XML file.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
/// <returns>Parsed coverage report.</returns>
|
|
public static async Task<CoverageReport> ParseFileAsync(string filePath, CancellationToken ct = default)
|
|
{
|
|
var xml = await File.ReadAllTextAsync(filePath, ct);
|
|
return Parse(xml);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parse a Cobertura XML string.
|
|
/// </summary>
|
|
/// <param name="xml">Cobertura XML content.</param>
|
|
/// <returns>Parsed coverage report.</returns>
|
|
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<FileCoverage>();
|
|
|
|
// 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<LineCoverageData>();
|
|
var branches = new List<BranchCoverageData>();
|
|
|
|
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;
|
|
}
|
|
}
|