mirror of
https://github.com/irongut/CodeCoverageSummary.git
synced 2026-06-10 06:50:46 +00:00
368 lines
18 KiB
C#
368 lines
18 KiB
C#
using CommandLine;
|
||
using Microsoft.Extensions.FileSystemGlobbing;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Xml.Linq;
|
||
|
||
namespace CodeCoverageSummary
|
||
{
|
||
internal static class Program
|
||
{
|
||
private static double lowerThreshold = 0.5;
|
||
private static double upperThreshold = 0.75;
|
||
|
||
private static int Main(string[] args)
|
||
{
|
||
return Parser.Default.ParseArguments<CommandLineOptions>(args)
|
||
.MapResult(o =>
|
||
{
|
||
try
|
||
{
|
||
// use glob patterns to match files
|
||
Matcher matcher = new();
|
||
matcher.AddIncludePatterns(o.Files.ToArray());
|
||
IEnumerable<string> matchingFiles = matcher.GetResultsInFullPath(".");
|
||
|
||
if (matchingFiles?.Any() == false)
|
||
{
|
||
Console.WriteLine("Error: No files found matching glob pattern.");
|
||
return -2; // error
|
||
}
|
||
|
||
// check files exist
|
||
foreach (var file in matchingFiles)
|
||
{
|
||
if (!File.Exists(file))
|
||
{
|
||
Console.WriteLine($"Error: Coverage file not found - {file}.");
|
||
return -2; // error
|
||
}
|
||
}
|
||
|
||
// parse code coverage file
|
||
CodeSummary summary = new();
|
||
foreach (var file in matchingFiles)
|
||
{
|
||
Console.WriteLine($"Coverage File: {file}");
|
||
summary = ParseTestResults(file, summary);
|
||
}
|
||
|
||
if (summary == null)
|
||
return -2; // error
|
||
|
||
summary.LineRate /= matchingFiles.Count();
|
||
summary.BranchRate /= matchingFiles.Count();
|
||
|
||
if (summary.Packages.Count == 0)
|
||
{
|
||
Console.WriteLine("Parsing Error: No packages found in coverage files.");
|
||
return -2; // error
|
||
}
|
||
else
|
||
{
|
||
// hide branch rate if metrics missing
|
||
bool hideBranchRate = o.HideBranchRate;
|
||
if (summary.BranchRate == 0 && summary.BranchesCovered == 0 && summary.BranchesValid == 0)
|
||
hideBranchRate = true;
|
||
|
||
// set health badge thresholds
|
||
if (!string.IsNullOrWhiteSpace(o.Thresholds))
|
||
SetThresholds(o.Thresholds);
|
||
|
||
// generate badge
|
||
string badgeUrl = o.Badge ? GenerateBadge(summary) : null;
|
||
|
||
// generate output
|
||
string output;
|
||
string fileExt;
|
||
if (o.Format.Equals("text", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
fileExt = "txt";
|
||
output = GenerateTextOutput(summary, badgeUrl, o.Indicators, hideBranchRate, o.HideComplexity);
|
||
if (o.FailBelowThreshold)
|
||
output += $"Minimum allowed line rate is {lowerThreshold * 100:N0}%{Environment.NewLine}";
|
||
}
|
||
else if (o.Format.Equals("md", StringComparison.OrdinalIgnoreCase) || o.Format.Equals("markdown", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
fileExt = "md";
|
||
output = GenerateMarkdownOutput(summary, badgeUrl, o.Indicators, hideBranchRate, o.HideComplexity);
|
||
if (o.FailBelowThreshold)
|
||
output += $"{Environment.NewLine}_Minimum allowed line rate is `{lowerThreshold * 100:N0}%`_{Environment.NewLine}";
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine("Error: Unknown output format.");
|
||
return -2; // error
|
||
}
|
||
|
||
// output
|
||
if (o.Output.Equals("console", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
Console.WriteLine();
|
||
Console.WriteLine(output);
|
||
}
|
||
else if (o.Output.Equals("file", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
File.WriteAllText($"code-coverage-results.{fileExt}", output);
|
||
}
|
||
else if (o.Output.Equals("both", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
Console.WriteLine();
|
||
Console.WriteLine(output);
|
||
File.WriteAllText($"code-coverage-results.{fileExt}", output);
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine("Error: Unknown output type.");
|
||
return -2; // error
|
||
}
|
||
|
||
if (o.FailBelowThreshold && summary.LineRate < lowerThreshold)
|
||
{
|
||
Console.WriteLine($"FAIL: Overall line rate below minimum threshold of {lowerThreshold * 100:N0}%.");
|
||
return -2;
|
||
}
|
||
|
||
return 0; // success
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"Error: {ex.GetType()} - {ex.Message}");
|
||
return -3; // unhandled error
|
||
}
|
||
},
|
||
_ => -1); // invalid arguments
|
||
}
|
||
|
||
private static CodeSummary ParseTestResults(string filename, CodeSummary summary)
|
||
{
|
||
if (summary == null)
|
||
return null;
|
||
|
||
try
|
||
{
|
||
string rss = File.ReadAllText(filename);
|
||
var xdoc = XDocument.Parse(rss);
|
||
|
||
// test coverage for solution
|
||
var coverage = from item in xdoc.Descendants("coverage")
|
||
select item;
|
||
|
||
if (!coverage.Any())
|
||
throw new Exception("Coverage file invalid, data not found");
|
||
|
||
var lineR = from item in coverage.Attributes()
|
||
where item.Name == "line-rate"
|
||
select item;
|
||
|
||
if (!lineR.Any())
|
||
throw new Exception("Overall line rate not found");
|
||
|
||
summary.LineRate += double.Parse(lineR.First().Value);
|
||
|
||
var linesCovered = from item in coverage.Attributes()
|
||
where item.Name == "lines-covered"
|
||
select item;
|
||
|
||
if (!linesCovered.Any())
|
||
throw new Exception("Overall lines covered not found");
|
||
|
||
summary.LinesCovered += int.Parse(linesCovered.First().Value);
|
||
|
||
var linesValid = from item in coverage.Attributes()
|
||
where item.Name == "lines-valid"
|
||
select item;
|
||
|
||
if (!linesValid.Any())
|
||
throw new Exception("Overall lines valid not found");
|
||
|
||
summary.LinesValid += int.Parse(linesValid.First().Value);
|
||
|
||
var branchR = from item in coverage.Attributes()
|
||
where item.Name == "branch-rate"
|
||
select item;
|
||
|
||
if (branchR.Any())
|
||
{
|
||
summary.BranchRate += double.TryParse(branchR.First().Value, out double bRate) ? bRate : 0;
|
||
|
||
var branchesCovered = from item in coverage.Attributes()
|
||
where item.Name == "branches-covered"
|
||
select item;
|
||
|
||
summary.BranchesCovered += int.TryParse(branchesCovered?.First().Value ?? "0", out int bCovered) ? bCovered : 0;
|
||
|
||
var branchesValid = from item in coverage.Attributes()
|
||
where item.Name == "branches-valid"
|
||
select item;
|
||
|
||
summary.BranchesValid += int.TryParse(branchesValid?.First().Value ?? "0", out int bValid) ? bValid : 0;
|
||
}
|
||
|
||
// test coverage for individual packages
|
||
var packages = from item in coverage.Descendants("package")
|
||
select item;
|
||
|
||
if (!packages.Any())
|
||
throw new Exception("No package data found");
|
||
|
||
int i = 1;
|
||
foreach (var item in packages)
|
||
{
|
||
CodeCoverage packageCoverage = new()
|
||
{
|
||
Name = string.IsNullOrWhiteSpace(item.Attribute("name")?.Value) ? $"{Path.GetFileNameWithoutExtension(filename)} Package {i}" : item.Attribute("name").Value,
|
||
LineRate = double.Parse(item.Attribute("line-rate")?.Value ?? "0"),
|
||
BranchRate = double.TryParse(item.Attribute("branch-rate")?.Value ?? "0", out double bRate) ? bRate : 0,
|
||
Complexity = double.TryParse(item.Attribute("complexity")?.Value ?? "0", out double complex) ? complex : 0
|
||
};
|
||
summary.Packages.Add(packageCoverage);
|
||
summary.Complexity += packageCoverage.Complexity;
|
||
i++;
|
||
}
|
||
|
||
return summary;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"Parsing Error: {ex.Message} - {filename}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private static void SetThresholds(string thresholds)
|
||
{
|
||
int lowerPercentage;
|
||
int upperPercentage = (int)(upperThreshold * 100);
|
||
int s = thresholds.IndexOf(" ");
|
||
if (s == 0)
|
||
{
|
||
throw new ArgumentException("Threshold parameter set incorrectly.");
|
||
}
|
||
else if (s < 0)
|
||
{
|
||
if (!int.TryParse(thresholds, out lowerPercentage))
|
||
throw new ArgumentException("Threshold parameter set incorrectly.");
|
||
}
|
||
else
|
||
{
|
||
if (!int.TryParse(thresholds.AsSpan(0, s), out lowerPercentage))
|
||
throw new ArgumentException("Threshold parameter set incorrectly.");
|
||
|
||
if (!int.TryParse(thresholds.AsSpan(s + 1), out upperPercentage))
|
||
throw new ArgumentException("Threshold parameter set incorrectly.");
|
||
}
|
||
lowerThreshold = lowerPercentage / 100.0;
|
||
upperThreshold = upperPercentage / 100.0;
|
||
|
||
if (lowerThreshold > 1.0)
|
||
lowerThreshold = 1.0;
|
||
|
||
if (lowerThreshold > upperThreshold)
|
||
upperThreshold = lowerThreshold + 0.1;
|
||
|
||
if (upperThreshold > 1.0)
|
||
upperThreshold = 1.0;
|
||
}
|
||
|
||
private static string GenerateBadge(CodeSummary summary)
|
||
{
|
||
string colour;
|
||
if (summary.LineRate < lowerThreshold)
|
||
{
|
||
colour = "critical";
|
||
}
|
||
else if (summary.LineRate < upperThreshold)
|
||
{
|
||
colour = "yellow";
|
||
}
|
||
else
|
||
{
|
||
colour = "success";
|
||
}
|
||
return $"https://img.shields.io/badge/Code%20Coverage-{summary.LineRate * 100:N0}%25-{colour}?style=flat";
|
||
}
|
||
|
||
private static string GenerateHealthIndicator(double rate)
|
||
{
|
||
if (rate < lowerThreshold)
|
||
{
|
||
return "❌";
|
||
}
|
||
else if (rate < upperThreshold)
|
||
{
|
||
return "➖";
|
||
}
|
||
else
|
||
{
|
||
return "✔";
|
||
}
|
||
}
|
||
|
||
private static string GenerateTextOutput(CodeSummary summary, string badgeUrl, bool indicators, bool hideBranchRate, bool hideComplexity)
|
||
{
|
||
StringBuilder textOutput = new();
|
||
|
||
if (!string.IsNullOrWhiteSpace(badgeUrl))
|
||
{
|
||
textOutput.AppendLine(badgeUrl)
|
||
.AppendLine();
|
||
}
|
||
|
||
foreach (CodeCoverage package in summary.Packages)
|
||
{
|
||
textOutput.Append($"{package.Name}: Line Rate = {package.LineRate * 100:N0}%")
|
||
.Append(hideBranchRate ? string.Empty : $", Branch Rate = {package.BranchRate * 100:N0}%")
|
||
.Append(hideComplexity ? string.Empty : (package.Complexity % 1 == 0) ? $", Complexity = {package.Complexity}" : $", Complexity = {package.Complexity:N4}")
|
||
.AppendLine(indicators ? $", {GenerateHealthIndicator(package.LineRate)}" : string.Empty);
|
||
}
|
||
|
||
textOutput.Append($"Summary: Line Rate = {summary.LineRate * 100:N0}% ({summary.LinesCovered} / {summary.LinesValid})")
|
||
.Append(hideBranchRate ? string.Empty : $", Branch Rate = {summary.BranchRate * 100:N0}% ({summary.BranchesCovered} / {summary.BranchesValid})")
|
||
.Append(hideComplexity ? string.Empty : (summary.Complexity % 1 == 0) ? $", Complexity = {summary.Complexity}" : $", Complexity = {summary.Complexity:N4}")
|
||
.AppendLine(indicators ? $", {GenerateHealthIndicator(summary.LineRate)}" : string.Empty);
|
||
|
||
return textOutput.ToString();
|
||
}
|
||
|
||
private static string GenerateMarkdownOutput(CodeSummary summary, string badgeUrl, bool indicators, bool hideBranchRate, bool hideComplexity)
|
||
{
|
||
StringBuilder markdownOutput = new();
|
||
|
||
if (!string.IsNullOrWhiteSpace(badgeUrl))
|
||
{
|
||
markdownOutput.AppendLine($"")
|
||
.AppendLine();
|
||
}
|
||
|
||
markdownOutput.Append("Package | Line Rate")
|
||
.Append(hideBranchRate ? string.Empty : " | Branch Rate")
|
||
.Append(hideComplexity ? string.Empty : " | Complexity")
|
||
.AppendLine(indicators ? " | Health" : string.Empty)
|
||
.Append("-------- | ---------")
|
||
.Append(hideBranchRate ? string.Empty : " | -----------")
|
||
.Append(hideComplexity ? string.Empty : " | ----------")
|
||
.AppendLine(indicators ? " | ------" : string.Empty);
|
||
|
||
foreach (CodeCoverage package in summary.Packages)
|
||
{
|
||
markdownOutput.Append($"{package.Name} | {package.LineRate * 100:N0}%")
|
||
.Append(hideBranchRate ? string.Empty : $" | {package.BranchRate * 100:N0}%")
|
||
.Append(hideComplexity ? string.Empty : (package.Complexity % 1 == 0) ? $" | {package.Complexity}" : $" | {package.Complexity:N4}" )
|
||
.AppendLine(indicators ? $" | {GenerateHealthIndicator(package.LineRate)}" : string.Empty);
|
||
}
|
||
|
||
markdownOutput.Append($"**Summary** | **{summary.LineRate * 100:N0}%** ({summary.LinesCovered} / {summary.LinesValid})")
|
||
.Append(hideBranchRate ? string.Empty : $" | **{summary.BranchRate * 100:N0}%** ({summary.BranchesCovered} / {summary.BranchesValid})")
|
||
.Append(hideComplexity ? string.Empty : (summary.Complexity % 1 == 0) ? $" | **{summary.Complexity}**" : $" | **{summary.Complexity:N4}**")
|
||
.AppendLine(indicators ? $" | {GenerateHealthIndicator(summary.LineRate)}" : string.Empty);
|
||
|
||
return markdownOutput.ToString();
|
||
}
|
||
}
|
||
}
|