#![allow(dead_code)]
use std::path::Path;
use anyhow::Result;
use super::benchmark::ComparisonReport;
use super::coverage::CoverageReport;
use super::results::TestSummary;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CiFormat {
#[default]
GitHubActions,
GitLabCI,
AzureDevOps,
Jenkins,
TeamCity,
Generic,
}
impl std::str::FromStr for CiFormat {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"github" | "github-actions" | "gha" => Ok(CiFormat::GitHubActions),
"gitlab" | "gitlab-ci" => Ok(CiFormat::GitLabCI),
"azure" | "azure-devops" | "ado" => Ok(CiFormat::AzureDevOps),
"jenkins" => Ok(CiFormat::Jenkins),
"teamcity" => Ok(CiFormat::TeamCity),
"generic" | "plain" => Ok(CiFormat::Generic),
_ => anyhow::bail!(
"Unknown CI format: {}. Valid formats: github, gitlab, azure, jenkins, teamcity, generic",
s
),
}
}
}
impl CiFormat {
pub fn detect() -> Self {
if std::env::var("GITHUB_ACTIONS").is_ok() {
CiFormat::GitHubActions
} else if std::env::var("GITLAB_CI").is_ok() {
CiFormat::GitLabCI
} else if std::env::var("TF_BUILD").is_ok() {
CiFormat::AzureDevOps
} else if std::env::var("JENKINS_URL").is_ok() {
CiFormat::Jenkins
} else if std::env::var("TEAMCITY_VERSION").is_ok() {
CiFormat::TeamCity
} else {
CiFormat::Generic
}
}
}
pub struct CiReporter {
format: CiFormat,
}
impl CiReporter {
pub fn new(format: CiFormat) -> Self {
Self { format }
}
pub fn auto_detect() -> Self {
Self::new(CiFormat::detect())
}
pub fn report_tests(&self, summary: &TestSummary) {
match self.format {
CiFormat::GitHubActions => self.report_tests_github(summary),
CiFormat::GitLabCI => self.report_tests_gitlab(summary),
CiFormat::AzureDevOps => self.report_tests_azure(summary),
CiFormat::Jenkins => self.report_tests_jenkins(summary),
CiFormat::TeamCity => self.report_tests_teamcity(summary),
CiFormat::Generic => self.report_tests_generic(summary),
}
}
fn report_tests_github(&self, summary: &TestSummary) {
println!("::set-output name=tests_total::{}", summary.total);
println!("::set-output name=tests_passed::{}", summary.passed);
println!("::set-output name=tests_failed::{}", summary.failed);
println!("::set-output name=tests_skipped::{}", summary.skipped);
println!(
"::set-output name=tests_pass_rate::{:.1}",
summary.pass_rate * 100.0
);
println!("::group::Test Results");
println!("## Test Results");
println!();
println!("| Metric | Value |");
println!("|--------|-------|");
println!("| Total | {} |", summary.total);
println!("| ✅ Passed | {} |", summary.passed);
println!("| ❌ Failed | {} |", summary.failed);
println!("| ⏭️ Skipped | {} |", summary.skipped);
println!("| Pass Rate | {:.1}% |", summary.pass_rate * 100.0);
println!("::endgroup::");
for test in summary.failed_tests() {
let message = test.message.as_deref().unwrap_or("Test failed");
if let Some(ref trace) = test.stack_trace {
if let Some((file, line)) = parse_file_line(trace) {
println!(
"::error file={},line={}::Test failed: {}",
file,
line,
test.full_name()
);
continue;
}
}
println!(
"::error::Test failed: {} - {}",
test.full_name(),
message.lines().next().unwrap_or("")
);
}
if !summary.all_passed() {
println!(
"::error::{} test(s) failed",
summary.failed + summary.errors
);
}
}
fn report_tests_gitlab(&self, summary: &TestSummary) {
println!("## Test Results");
println!();
println!("- Total: {}", summary.total);
println!("- Passed: {}", summary.passed);
println!("- Failed: {}", summary.failed);
println!("- Skipped: {}", summary.skipped);
println!("- Pass Rate: {:.1}%", summary.pass_rate * 100.0);
if summary.failed > 0 {
println!();
println!("### Failed Tests");
for test in summary.failed_tests() {
println!("- `{}`", test.full_name());
if let Some(ref msg) = test.message {
println!(" - {}", msg.lines().next().unwrap_or(""));
}
}
}
}
fn report_tests_azure(&self, summary: &TestSummary) {
println!(
"##vso[task.setvariable variable=tests_total]{}",
summary.total
);
println!(
"##vso[task.setvariable variable=tests_passed]{}",
summary.passed
);
println!(
"##vso[task.setvariable variable=tests_failed]{}",
summary.failed
);
println!("##[section]Test Results");
println!("Total: {}", summary.total);
println!("Passed: {}", summary.passed);
println!("Failed: {}", summary.failed);
println!("Skipped: {}", summary.skipped);
for test in summary.failed_tests() {
let message = test.message.as_deref().unwrap_or("Test failed");
println!(
"##vso[task.logissue type=error]Test failed: {} - {}",
test.full_name(),
message.lines().next().unwrap_or("")
);
}
if !summary.all_passed() {
println!(
"##vso[task.complete result=Failed;]{} test(s) failed",
summary.failed
);
}
}
fn report_tests_jenkins(&self, summary: &TestSummary) {
println!("Test Results Summary");
println!("====================");
println!("Total: {}", summary.total);
println!("Passed: {}", summary.passed);
println!("Failed: {}", summary.failed);
println!("Skipped: {}", summary.skipped);
println!("Pass Rate: {:.1}%", summary.pass_rate * 100.0);
if summary.failed > 0 {
println!();
println!("FAILED TESTS:");
for test in summary.failed_tests() {
println!(" - {}", test.full_name());
}
}
}
fn report_tests_teamcity(&self, summary: &TestSummary) {
println!(
"##teamcity[buildStatisticValue key='tests.total' value='{}']",
summary.total
);
println!(
"##teamcity[buildStatisticValue key='tests.passed' value='{}']",
summary.passed
);
println!(
"##teamcity[buildStatisticValue key='tests.failed' value='{}']",
summary.failed
);
println!(
"##teamcity[buildStatisticValue key='tests.skipped' value='{}']",
summary.skipped
);
for suite in &summary.suites {
println!(
"##teamcity[testSuiteStarted name='{}']",
escape_teamcity(&suite.name)
);
for test in &suite.tests {
println!(
"##teamcity[testStarted name='{}']",
escape_teamcity(&test.name)
);
match test.status {
super::results::TestStatus::Failed => {
println!(
"##teamcity[testFailed name='{}' message='{}']",
escape_teamcity(&test.name),
escape_teamcity(test.message.as_deref().unwrap_or(""))
);
}
super::results::TestStatus::Skipped => {
println!(
"##teamcity[testIgnored name='{}']",
escape_teamcity(&test.name)
);
}
_ => {}
}
println!(
"##teamcity[testFinished name='{}' duration='{}']",
escape_teamcity(&test.name),
(test.duration * 1000.0) as u64
);
}
println!(
"##teamcity[testSuiteFinished name='{}']",
escape_teamcity(&suite.name)
);
}
}
fn report_tests_generic(&self, summary: &TestSummary) {
summary.print_summary();
}
pub fn report_coverage(&self, report: &CoverageReport) {
match self.format {
CiFormat::GitHubActions => self.report_coverage_github(report),
CiFormat::GitLabCI => self.report_coverage_gitlab(report),
CiFormat::AzureDevOps => self.report_coverage_azure(report),
_ => self.report_coverage_generic(report),
}
}
fn report_coverage_github(&self, report: &CoverageReport) {
println!(
"::set-output name=coverage_line::{:.1}",
report.line_coverage_percent()
);
println!(
"::set-output name=coverage_branch::{:.1}",
report.branch_coverage_percent()
);
println!(
"::set-output name=coverage_function::{:.1}",
report.function_coverage_percent()
);
println!("::group::Coverage Report");
println!("## Code Coverage");
println!();
println!("| Metric | Coverage |");
println!("|--------|----------|");
println!("| Lines | {:.1}% |", report.line_coverage_percent());
println!("| Branches | {:.1}% |", report.branch_coverage_percent());
println!("| Functions | {:.1}% |", report.function_coverage_percent());
println!("::endgroup::");
}
fn report_coverage_gitlab(&self, report: &CoverageReport) {
println!("Coverage: {:.1}%", report.line_coverage_percent());
}
fn report_coverage_azure(&self, report: &CoverageReport) {
println!(
"##vso[task.setvariable variable=coverage_line]{:.1}",
report.line_coverage_percent()
);
println!(
"##vso[task.setvariable variable=coverage_branch]{:.1}",
report.branch_coverage_percent()
);
println!("##[section]Code Coverage");
println!("Line Coverage: {:.1}%", report.line_coverage_percent());
println!("Branch Coverage: {:.1}%", report.branch_coverage_percent());
println!(
"Function Coverage: {:.1}%",
report.function_coverage_percent()
);
}
fn report_coverage_generic(&self, report: &CoverageReport) {
report.print_summary();
}
pub fn report_benchmarks(&self, report: &ComparisonReport) {
match self.format {
CiFormat::GitHubActions => self.report_benchmarks_github(report),
CiFormat::GitLabCI => self.report_benchmarks_gitlab(report),
CiFormat::AzureDevOps => self.report_benchmarks_azure(report),
_ => self.report_benchmarks_generic(report),
}
}
fn report_benchmarks_github(&self, report: &ComparisonReport) {
println!(
"::set-output name=benchmark_regressions::{}",
report.regressions
);
println!(
"::set-output name=benchmark_improvements::{}",
report.improvements
);
println!("::group::Benchmark Comparison");
println!("{}", report.to_markdown());
println!("::endgroup::");
for comp in report.comparisons.iter().filter(|c| c.is_regression) {
println!(
"::warning::Benchmark regression: {} ({:+.1}%)",
comp.name, comp.time_diff_percent
);
}
}
fn report_benchmarks_gitlab(&self, report: &ComparisonReport) {
println!("{}", report.to_markdown());
}
fn report_benchmarks_azure(&self, report: &ComparisonReport) {
println!(
"##vso[task.setvariable variable=benchmark_regressions]{}",
report.regressions
);
println!("##[section]Benchmark Comparison");
report.print_report();
if report.has_regressions() {
println!(
"##vso[task.logissue type=warning]{} benchmark regression(s) detected",
report.regressions
);
}
}
fn report_benchmarks_generic(&self, report: &ComparisonReport) {
report.print_report();
}
pub fn write_junit_xml(&self, summary: &TestSummary, path: &Path) -> Result<()> {
let xml = summary.to_junit_xml();
std::fs::write(path, xml)?;
Ok(())
}
}
fn parse_file_line(trace: &str) -> Option<(String, u32)> {
for line in trace.lines() {
let line = line.trim();
let line = line.strip_prefix("at ").unwrap_or(line);
if let Some(colon_pos) = line.rfind(':') {
let file = &line[..colon_pos];
if let Ok(line_num) = line[colon_pos + 1..].trim().parse::<u32>() {
if file.ends_with(".cpp")
|| file.ends_with(".cc")
|| file.ends_with(".c")
|| file.ends_with(".h")
|| file.ends_with(".hpp")
{
return Some((file.to_string(), line_num));
}
}
}
if let Some(paren_pos) = line.rfind('(') {
let file = &line[..paren_pos];
if let Some(close_pos) = line.rfind(')') {
if let Ok(line_num) = line[paren_pos + 1..close_pos].parse::<u32>() {
if file.ends_with(".cpp")
|| file.ends_with(".cc")
|| file.ends_with(".c")
|| file.ends_with(".h")
|| file.ends_with(".hpp")
{
return Some((file.to_string(), line_num));
}
}
}
}
}
None
}
fn escape_teamcity(s: &str) -> String {
s.replace('|', "||")
.replace('\'', "|'")
.replace('\n', "|n")
.replace('\r', "|r")
.replace('[', "|[")
.replace(']', "|]")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ci_format_parse() {
assert_eq!(
"github".parse::<CiFormat>().unwrap(),
CiFormat::GitHubActions
);
assert_eq!("gitlab".parse::<CiFormat>().unwrap(), CiFormat::GitLabCI);
assert_eq!("azure".parse::<CiFormat>().unwrap(), CiFormat::AzureDevOps);
}
#[test]
fn test_parse_file_line() {
assert_eq!(
parse_file_line("test.cpp:42"),
Some(("test.cpp".to_string(), 42))
);
assert_eq!(
parse_file_line(" at /path/to/test.cpp:123"),
Some(("/path/to/test.cpp".to_string(), 123))
);
assert_eq!(parse_file_line("no file here"), None);
}
#[test]
fn test_escape_teamcity() {
assert_eq!(escape_teamcity("test|name"), "test||name");
assert_eq!(escape_teamcity("test\nname"), "test|nname");
assert_eq!(escape_teamcity("test[0]"), "test|[0|]");
}
}