///|
/// Output level for CI annotations.
pub(all) enum TapAnnotationLevel {
AnnotationNotice
AnnotationWarning
AnnotationFailure
} derive(Debug, Eq)
///|
/// One issue annotation that can be rendered for CI systems.
pub(all) struct TapAnnotation {
suite : String
path : String
line : Int
end_line : Int
column : Int
level : TapAnnotationLevel
code : String
title : String
message : String
remediation : String
} derive(Debug, Eq)
///|
/// A simple named text artifact such as Markdown, JSON, XML, or properties.
pub(all) struct TapArtifact {
name : String
media_type : String
content : String
} derive(Debug, Eq)
///|
/// Convert a report issue to an annotation.
pub fn annotation_for_issue(
suite : String,
path : String,
item : TapIssue,
) -> TapAnnotation {
let meta = issue_meta(item.code)
TapAnnotation::{
suite,
path,
line: if item.line > 0 {
item.line
} else {
1
},
end_line: if item.line > 0 {
item.line
} else {
1
},
column: 1,
level: annotation_level_for_severity(meta.severity),
code: item.code,
title: meta.title,
message: item.message,
remediation: meta.remediation,
}
}
///|
/// Build annotations for a single TAP report.
pub fn report_annotations(
suite : String,
path : String,
report : TapReport,
) -> Array[TapAnnotation] {
let items : Array[TapAnnotation] = []
for issue_item in report.issues {
items.push(annotation_for_issue(suite, path, issue_item))
}
items
}
///|
/// Build annotations for every member of a suite.
pub fn suite_annotations(
suite : TapSuiteReport,
path_prefix : String,
) -> Array[TapAnnotation] {
let items : Array[TapAnnotation] = []
for entry in suite.reports {
let path = if path_prefix.length() == 0 {
entry.name
} else {
"\{path_prefix}/\{entry.name}"
}
for issue_item in entry.report.issues {
items.push(annotation_for_issue(entry.name, path, issue_item))
}
}
items
}
///|
/// Render annotations as GitHub workflow command lines.
pub fn annotations_to_github_commands(
annotations : Array[TapAnnotation],
) -> String {
let sb = StringBuilder()
for item in annotations {
sb.write_string("::")
sb.write_string(github_level_label(item.level))
sb.write_string(" file=")
sb.write_string(github_escape_property(item.path))
sb.write_string(
",line=\{item.line},endLine=\{item.end_line},col=\{item.column},title=",
)
sb.write_string(github_escape_property("\{item.code}: \{item.title}"))
sb.write_string("::")
sb.write_string(github_escape_message(item.message))
sb.write_string("\n")
}
sb.to_string()
}
///|
/// Render annotations as Markdown.
pub fn annotations_to_markdown(annotations : Array[TapAnnotation]) -> String {
let sb = StringBuilder()
sb.write_string(
"| Suite | Level | Code | Location | Message | Remediation |\n",
)
sb.write_string("| --- | --- | --- | --- | --- | --- |\n")
for item in annotations {
sb.write_string("| ")
sb.write_string(annotation_cell(item.suite))
sb.write_string(" | ")
sb.write_string(annotation_level_label(item.level))
sb.write_string(" | `")
sb.write_string(item.code)
sb.write_string("` | ")
sb.write_string(annotation_cell("\{item.path}:\{item.line}"))
sb.write_string(" | ")
sb.write_string(annotation_cell(item.message))
sb.write_string(" | ")
sb.write_string(annotation_cell(item.remediation))
sb.write_string(" |\n")
}
sb.to_string()
}
///|
/// Render annotations as a compact JSON array.
pub fn annotations_to_json(annotations : Array[TapAnnotation]) -> String {
let sb = StringBuilder()
sb.write_string("[")
for item in annotations; first = true {
if first {
()
} else {
sb.write_string(",")
}
sb.write_string("{\"suite\":\"")
sb.write_string(json_escape(item.suite))
sb.write_string("\",\"level\":\"")
sb.write_string(annotation_level_label(item.level))
sb.write_string("\",\"code\":\"")
sb.write_string(json_escape(item.code))
sb.write_string("\",\"path\":\"")
sb.write_string(json_escape(item.path))
sb.write_string("\",\"line\":\{item.line}")
sb.write_string(",\"title\":\"")
sb.write_string(json_escape(item.title))
sb.write_string("\",\"message\":\"")
sb.write_string(json_escape(item.message))
sb.write_string("\",\"remediation\":\"")
sb.write_string(json_escape(item.remediation))
sb.write_string("\"}")
continue false
}
sb.write_string("]")
sb.to_string()
}
///|
/// Render report key-value pairs for shell-independent CI artifact files.
pub fn report_to_properties(report : TapReport, prefix : String) -> String {
let key = normalize_key_prefix(prefix)
let sb = StringBuilder()
sb.write_string("\{key}.ok=\{report.ok}\n")
sb.write_string("\{key}.planned=\{report.summary.planned}\n")
sb.write_string("\{key}.total=\{report.summary.total}\n")
sb.write_string("\{key}.passed=\{report.summary.passed}\n")
sb.write_string("\{key}.failed=\{report.summary.failed}\n")
sb.write_string("\{key}.skipped=\{report.summary.skipped}\n")
sb.write_string("\{key}.todo=\{report.summary.todo}\n")
sb.write_string("\{key}.diagnostics=\{report.summary.diagnostics}\n")
sb.write_string("\{key}.yaml_blocks=\{report.summary.yaml_blocks}\n")
sb.write_string("\{key}.issues=\{report.issues.length()}\n")
sb.write_string("\{key}.score=\{health_score(report)}\n")
sb.to_string()
}
///|
/// Render suite key-value pairs for artifact files.
pub fn suite_to_properties(suite : TapSuiteReport, prefix : String) -> String {
let key = normalize_key_prefix(prefix)
let sb = StringBuilder()
sb.write_string("\{key}.ok=\{suite.ok}\n")
sb.write_string("\{key}.suites=\{suite.summary.suites}\n")
sb.write_string("\{key}.passed_suites=\{suite.summary.passed_suites}\n")
sb.write_string("\{key}.failed_suites=\{suite.summary.failed_suites}\n")
sb.write_string("\{key}.planned=\{suite.summary.planned}\n")
sb.write_string("\{key}.total=\{suite.summary.total}\n")
sb.write_string("\{key}.passed=\{suite.summary.passed}\n")
sb.write_string("\{key}.failed=\{suite.summary.failed}\n")
sb.write_string("\{key}.skipped=\{suite.summary.skipped}\n")
sb.write_string("\{key}.todo=\{suite.summary.todo}\n")
sb.write_string("\{key}.issues=\{suite.summary.issues}\n")
sb.write_string("\{key}.score=\{suite_health_score(suite)}\n")
sb.to_string()
}
///|
/// Render one report as GitHub environment lines.
pub fn report_to_env_lines(report : TapReport, prefix : String) -> String {
let name = normalize_env_prefix(prefix)
let sb = StringBuilder()
sb.write_string("\{name}_OK=\{report.ok}\n")
sb.write_string("\{name}_STATUS=\{if report.ok { "ok" } else { "failed" }}\n")
sb.write_string("\{name}_PLANNED=\{report.summary.planned}\n")
sb.write_string("\{name}_TOTAL=\{report.summary.total}\n")
sb.write_string("\{name}_PASSED=\{report.summary.passed}\n")
sb.write_string("\{name}_FAILED=\{report.summary.failed}\n")
sb.write_string("\{name}_SKIPPED=\{report.summary.skipped}\n")
sb.write_string("\{name}_TODO=\{report.summary.todo}\n")
sb.write_string("\{name}_ISSUES=\{report.issues.length()}\n")
sb.write_string("\{name}_SCORE=\{health_score(report)}\n")
sb.to_string()
}
///|
/// Render one report as TeamCity service messages.
pub fn report_to_teamcity_messages(
report : TapReport,
flow_id : String,
) -> String {
let flow = if flow_id.length() == 0 { "taptrail" } else { flow_id }
let sb = StringBuilder()
for point in report.document.points {
let name = teamcity_escape(teamcity_test_name(point))
sb.write_string("##teamcity[testStarted name='")
sb.write_string(name)
sb.write_string("' flowId='")
sb.write_string(teamcity_escape(flow))
sb.write_string("']\n")
if !point.ok {
match point.directive {
Todo(reason) => {
sb.write_string("##teamcity[testIgnored name='")
sb.write_string(name)
sb.write_string("' message='TODO: ")
sb.write_string(teamcity_escape(reason))
sb.write_string("' flowId='")
sb.write_string(teamcity_escape(flow))
sb.write_string("']\n")
}
_ => {
sb.write_string("##teamcity[testFailed name='")
sb.write_string(name)
sb.write_string("' message='not ok' details='")
sb.write_string(teamcity_escape(point.raw))
sb.write_string("' flowId='")
sb.write_string(teamcity_escape(flow))
sb.write_string("']\n")
}
}
} else {
match point.directive {
Skip(reason) => {
sb.write_string("##teamcity[testIgnored name='")
sb.write_string(name)
sb.write_string("' message='")
sb.write_string(teamcity_escape(reason))
sb.write_string("' flowId='")
sb.write_string(teamcity_escape(flow))
sb.write_string("']\n")
}
Todo(reason) => {
sb.write_string("##teamcity[testIgnored name='")
sb.write_string(name)
sb.write_string("' message='TODO: ")
sb.write_string(teamcity_escape(reason))
sb.write_string("' flowId='")
sb.write_string(teamcity_escape(flow))
sb.write_string("']\n")
}
NoDirective => ()
}
}
sb.write_string("##teamcity[testFinished name='")
sb.write_string(name)
sb.write_string("' flowId='")
sb.write_string(teamcity_escape(flow))
sb.write_string("']\n")
}
sb.to_string()
}
///|
/// Render Checkstyle-compatible XML for issue dashboards.
pub fn report_to_checkstyle_xml(
report : TapReport,
file_name : String,
) -> String {
let file = if file_name.length() == 0 { "tap.tap" } else { file_name }
let sb = StringBuilder()
sb.write_string("\n")
sb.write_string("\n")
sb.write_string(" \n")
for item in report.issues {
let meta = issue_meta(item.code)
sb.write_string(" 0 { item.line } else { 1 }}")
sb.write_string("\" column=\"1\" severity=\"")
sb.write_string(checkstyle_severity(meta.severity))
sb.write_string("\" message=\"")
sb.write_string(xml_escape(item.message))
sb.write_string("\" source=\"taptrail.")
sb.write_string(xml_escape(item.code))
sb.write_string("\" />\n")
}
sb.write_string(" \n")
sb.write_string("\n")
sb.to_string()
}
///|
/// Render Checkstyle-compatible XML for suite issues.
pub fn suite_to_checkstyle_xml(suite : TapSuiteReport) -> String {
let sb = StringBuilder()
sb.write_string("\n")
sb.write_string("\n")
for entry in suite.reports {
sb.write_string(" \n")
for item in entry.report.issues {
let meta = issue_meta(item.code)
sb.write_string(" 0 { item.line } else { 1 }}")
sb.write_string("\" column=\"1\" severity=\"")
sb.write_string(checkstyle_severity(meta.severity))
sb.write_string("\" message=\"")
sb.write_string(xml_escape(item.message))
sb.write_string("\" source=\"taptrail.")
sb.write_string(xml_escape(item.code))
sb.write_string("\" />\n")
}
sb.write_string(" \n")
}
sb.write_string("\n")
sb.to_string()
}
///|
/// Render a Shields endpoint JSON badge for a report.
pub fn report_to_shields_json(report : TapReport, label : String) -> String {
let badge_label = if label.length() == 0 { "taptrail" } else { label }
let message = if report.ok {
"passing"
} else {
"\{report.issues.length()} issues"
}
let color = if report.ok { "brightgreen" } else { "red" }
"{\"schemaVersion\":1,\"label\":\"\{json_escape(badge_label)}\",\"message\":\"\{json_escape(message)}\",\"color\":\"\{color}\"}"
}
///|
/// Render a Shields endpoint JSON badge for a suite.
pub fn suite_to_shields_json(suite : TapSuiteReport, label : String) -> String {
let badge_label = if label.length() == 0 { "taptrail suite" } else { label }
let message = if suite.ok {
"\{suite.summary.passed_suites}/\{suite.summary.suites} suites"
} else {
"\{suite.summary.failed_suites} failed"
}
let color = if suite.ok { "brightgreen" } else { "red" }
"{\"schemaVersion\":1,\"label\":\"\{json_escape(badge_label)}\",\"message\":\"\{json_escape(message)}\",\"color\":\"\{color}\"}"
}
///|
/// Render a Markdown badge link for GitHub README files.
pub fn report_badge_markdown(
report : TapReport,
label : String,
target_url : String,
) -> String {
let title = if label.length() == 0 { "taptrail" } else { label }
let status = if report.ok { "passing" } else { "failing" }
let color = if report.ok { "brightgreen" } else { "red" }
let url = "https://img.shields.io/badge/\{url_component(title)}-\{url_component(status)}-\{color}"
if target_url.length() == 0 {
""
} else {
"[](\{target_url})"
}
}
///|
/// Render a Markdown badge link for suite status.
pub fn suite_badge_markdown(
suite : TapSuiteReport,
label : String,
target_url : String,
) -> String {
let title = if label.length() == 0 { "taptrail suite" } else { label }
let status = if suite.ok {
"\{suite.summary.passed_suites} of \{suite.summary.suites} ok"
} else {
"\{suite.summary.failed_suites} failed"
}
let color = if suite.ok { "brightgreen" } else { "red" }
let url = "https://img.shields.io/badge/\{url_component(title)}-\{url_component(status)}-\{color}"
if target_url.length() == 0 {
""
} else {
"[](\{target_url})"
}
}
///|
/// Package common report outputs in one call.
pub fn report_artifacts(
name : String,
report : TapReport,
) -> Array[TapArtifact] {
[
TapArtifact::{
name: "\{name}.md",
media_type: "text/markdown",
content: to_markdown(report),
},
TapArtifact::{
name: "\{name}.json",
media_type: "application/json",
content: to_compact_json(report),
},
TapArtifact::{
name: "\{name}.xml",
media_type: "application/xml",
content: to_junit_xml(report, suite_name=name),
},
TapArtifact::{
name: "\{name}.properties",
media_type: "text/plain",
content: report_to_properties(report, "taptrail"),
},
]
}
///|
/// Package common suite outputs in one call.
pub fn suite_artifacts(suite : TapSuiteReport) -> Array[TapArtifact] {
[
TapArtifact::{
name: "\{suite.name}.md",
media_type: "text/markdown",
content: suite_to_markdown(suite),
},
TapArtifact::{
name: "\{suite.name}.json",
media_type: "application/json",
content: suite_to_compact_json(suite),
},
TapArtifact::{
name: "\{suite.name}.xml",
media_type: "application/xml",
content: suite_to_junit_xml(suite),
},
TapArtifact::{
name: "\{suite.name}.csv",
media_type: "text/csv",
content: suite_to_csv(suite),
},
]
}
///|
/// Render a high-signal Markdown review for one report.
pub fn report_review_markdown(
name : String,
report : TapReport,
policy : TapPolicy,
) -> String {
let result = evaluate_policy(report, policy)
let sb = StringBuilder()
sb.write_string("# TAP Review: ")
sb.write_string(name)
sb.write_string("\n\n")
sb.write_string("- status: ")
sb.write_string(if result.ok { "ok" } else { "failed" })
sb.write_string("\n")
sb.write_string("- policy: ")
sb.write_string(result.policy_name)
sb.write_string("\n")
sb.write_string("- health: ")
sb.write_string("\{health_score(report)}")
sb.write_string(" (")
sb.write_string(score_grade(health_score(report)))
sb.write_string(")\n")
sb.write_string("- planned: \{report.summary.planned}\n")
sb.write_string("- total: \{report.summary.total}\n")
sb.write_string("- passed: \{report.summary.passed}\n")
sb.write_string("- failed: \{report.summary.failed}\n")
sb.write_string("- skipped: \{report.summary.skipped}\n")
sb.write_string("- todo: \{report.summary.todo}\n")
if report.issues.length() > 0 {
sb.write_string("\n## Findings\n\n")
for issue_item in report.issues {
let meta = issue_meta(issue_item.code)
sb.write_string("- ")
sb.write_string(severity_label(meta.severity))
sb.write_string(" `")
sb.write_string(issue_item.code)
sb.write_string("`: ")
sb.write_string(issue_item.message)
sb.write_string(" Remedy: ")
sb.write_string(meta.remediation)
sb.write_string("\n")
}
} else {
sb.write_string("\nNo findings.\n")
}
sb.to_string()
}
///|
/// Map issue severity to annotation level.
pub fn annotation_level_for_severity(
severity : TapSeverity,
) -> TapAnnotationLevel {
match severity {
SeverityInfo => AnnotationNotice
SeverityWarning => AnnotationWarning
SeverityError => AnnotationFailure
}
}
///|
/// Human-readable annotation level label.
pub fn annotation_level_label(level : TapAnnotationLevel) -> String {
match level {
AnnotationNotice => "notice"
AnnotationWarning => "warning"
AnnotationFailure => "failure"
}
}
///|
fn github_level_label(level : TapAnnotationLevel) -> String {
match level {
AnnotationNotice => "notice"
AnnotationWarning => "warning"
AnnotationFailure => "error"
}
}
///|
fn checkstyle_severity(severity : TapSeverity) -> String {
match severity {
SeverityInfo => "info"
SeverityWarning => "warning"
SeverityError => "error"
}
}
///|
fn teamcity_test_name(point : TapPoint) -> String {
if point.name.length() > 0 {
point.name
} else if point.number > 0 {
"test \{point.number}"
} else {
"line \{point.line}"
}
}
///|
fn normalize_key_prefix(prefix : String) -> String {
if prefix.length() == 0 {
"taptrail"
} else {
prefix
}
}
///|
fn normalize_env_prefix(prefix : String) -> String {
let raw = if prefix.length() == 0 { "TAPTRAIL" } else { prefix }
let sb = StringBuilder()
for ch in raw {
let code = ch.to_int()
if code >= 97 && code <= 122 {
sb.write_char(Int::unsafe_to_char(code - 32))
} else if code >= 65 && code <= 90 {
sb.write_char(ch)
} else if code >= 48 && code <= 57 {
sb.write_char(ch)
} else {
sb.write_string("_")
}
}
sb.to_string()
}
///|
fn github_escape_property(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'%' => sb.write_string("%25")
'\r' => sb.write_string("%0D")
'\n' => sb.write_string("%0A")
':' => sb.write_string("%3A")
',' => sb.write_string("%2C")
_ => sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn github_escape_message(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'%' => sb.write_string("%25")
'\r' => sb.write_string("%0D")
'\n' => sb.write_string("%0A")
_ => sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn teamcity_escape(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'|' => sb.write_string("||")
'\'' => sb.write_string("|'")
'\n' => sb.write_string("|n")
'\r' => sb.write_string("|r")
'[' => sb.write_string("|[")
']' => sb.write_string("|]")
_ => sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn annotation_cell(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
'|' => sb.write_string("\\|")
'\n' => sb.write_string("
")
'\r' => ()
_ => sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn url_component(text : String) -> String {
let sb = StringBuilder()
for ch in text {
match ch {
' ' => sb.write_string("%20")
'-' => sb.write_string("--")
'_' => sb.write_string("__")
'/' => sb.write_string("%2F")
':' => sb.write_string("%3A")
'?' => sb.write_string("%3F")
'&' => sb.write_string("%26")
'#' => sb.write_string("%23")
_ => sb.write_char(ch)
}
}
sb.to_string()
}