use crate::model::GateResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct SpanKey {
start_line: u32,
end_line: u32,
}
pub fn render(result: &GateResult, _diff_description: &str) -> String {
let mut out = String::new();
out.push_str("## Covgate\n\n");
out.push_str("### Diff Coverage\n\n");
out.push_str("| Result | Rule | Observed | Configured |\n");
out.push_str("| --- | --- | ---: | ---: |\n");
for outcome in &result.rules {
let status = if outcome.passed { "✅PASS" } else { "❌FAIL" };
match &outcome.rule {
crate::model::GateRule::Percent {
minimum_percent, ..
} => {
out.push_str(&format!(
"| {} | `{}` | {:.2}% | ≥ {:.2}% |\n",
status,
outcome.rule.label(),
outcome.observed_percent,
minimum_percent
));
}
crate::model::GateRule::UncoveredCount { maximum_count, .. } => {
out.push_str(&format!(
"| {} | `{}` | {} | ≤ {} |\n",
status,
outcome.rule.label(),
outcome.observed_uncovered_count,
maximum_count
));
}
}
}
out.push('\n');
for metric in &result.metrics {
let metric_label = title_case(metric.metric.label());
out.push_str(&format!("#### {}\n\n", title_case(metric.metric.as_str())));
out.push_str(&format!(
"| File | Covered Changed {metric_label} | Changed {metric_label} | Coverage | Missed Changed Spans |\n"
));
out.push_str("| --- | ---: | ---: | ---: | --- |\n");
let mut missed_by_file =
std::collections::BTreeMap::<String, std::collections::BTreeMap<SpanKey, usize>>::new();
for opportunity in &metric.uncovered_changed_opportunities {
missed_by_file
.entry(opportunity.span.path.display().to_string())
.or_default()
.entry(SpanKey {
start_line: opportunity.span.start_line,
end_line: opportunity.span.end_line,
})
.and_modify(|count| *count += 1)
.or_insert(1);
}
for (path, totals) in &metric.changed_totals_by_file {
let percent = if totals.total == 0 {
100.0
} else {
(totals.covered as f64 / totals.total as f64) * 100.0
};
let missed = missed_by_file
.get(&path.display().to_string())
.map(|values| {
values
.iter()
.map(|(key, count)| {
let label = format!("{}-{}", key.start_line, key.end_line);
if *count > 1 {
format!("`{label}({count})`")
} else {
format!("`{label}`")
}
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
out.push_str(&format!(
"| `{}` | {} | {} | {:.2}% {} | {} |\n",
path.display(),
totals.covered,
totals.total,
percent,
coverage_circle(percent),
missed
));
}
out.push_str(&format!(
"| **Total** | **{}** | **{}** | **{:.2}% {}** | |\n",
metric.covered,
metric.total,
metric.percent,
coverage_circle(metric.percent)
));
out.push('\n');
}
out.push_str("### Overall Coverage\n\n");
for metric in &result.metrics {
let metric_label = title_case(metric.metric.label());
out.push_str(&format!("#### {}\n\n", title_case(metric.metric.as_str())));
out.push_str(&format!(
"| File | Covered {metric_label} | {metric_label} | Missed {metric_label} | Coverage |\n"
));
out.push_str("| --- | ---: | ---: | ---: | ---: |\n");
for (path, totals) in &metric.totals_by_file {
let percent = if totals.total == 0 {
100.0
} else {
(totals.covered as f64 / totals.total as f64) * 100.0
};
let missed = totals.total.saturating_sub(totals.covered);
out.push_str(&format!(
"| `{}` | {} | {} | {} | {:.2}% {} |\n",
path.display(),
totals.covered,
totals.total,
missed,
percent,
coverage_circle(percent)
));
}
let overall_covered: usize = metric
.totals_by_file
.values()
.map(|totals| totals.covered)
.sum();
let overall_total: usize = metric
.totals_by_file
.values()
.map(|totals| totals.total)
.sum();
let overall_percent = if overall_total == 0 {
100.0
} else {
(overall_covered as f64 / overall_total as f64) * 100.0
};
let overall_missed = overall_total.saturating_sub(overall_covered);
out.push_str(&format!(
"| **Total** | **{}** | **{}** | **{}** | **{:.2}% {}** |\n",
overall_covered,
overall_total,
overall_missed,
overall_percent,
coverage_circle(overall_percent)
));
out.push('\n');
}
out
}
fn title_case(value: &str) -> String {
let mut chars = value.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn coverage_circle(percent: f64) -> &'static str {
if percent < 50.0 {
"🔴"
} else if percent < 80.0 {
"🟡"
} else {
"🟢"
}
}
#[cfg(test)]
mod tests {
use std::{collections::BTreeMap, path::PathBuf};
use crate::model::{FileTotals, GateResult, GateRule, MetricKind, RuleOutcome};
use super::render;
#[test]
fn renders_markdown_tables() {
let result = GateResult {
metrics: vec![crate::model::ComputedMetric {
metric: MetricKind::Region,
covered: 1,
total: 2,
percent: 50.0,
uncovered_changed_opportunities: vec![crate::model::CoverageOpportunity {
kind: crate::model::OpportunityKind::Region,
span: crate::model::SourceSpan {
path: PathBuf::from("src/lib.rs"),
start_line: 5,
end_line: 6,
},
covered: false,
}],
changed_totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 1,
total: 2,
},
)]),
totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 3,
total: 4,
},
)]),
}],
rules: vec![RuleOutcome {
rule: GateRule::Percent {
metric: MetricKind::Region,
minimum_percent: 90.0,
},
passed: false,
observed_percent: 50.0,
observed_uncovered_count: 1,
}],
passed: false,
};
let rendered = render(&result, "origin/main...HEAD");
assert!(rendered.contains("| Result | Rule | Observed | Configured |"));
assert!(rendered.contains("| ❌FAIL | `fail-under-regions` | 50.00% | ≥ 90.00% |"));
assert!(rendered.contains(
"| File | Covered Changed Regions | Changed Regions | Coverage | Missed Changed Spans |"
));
assert!(rendered.contains("| `src/lib.rs` | 1 | 2 | 50.00% 🟡 |"));
assert!(rendered.contains("| **Total** | **1** | **2** | **50.00% 🟡** | |"));
assert!(
rendered.contains("| File | Covered Regions | Regions | Missed Regions | Coverage |")
);
assert!(rendered.contains("| `src/lib.rs` | 3 | 4 | 1 | 75.00% 🟡 |"));
assert!(rendered.contains("| **Total** | **3** | **4** | **1** | **75.00% 🟡** |"));
assert!(rendered.contains("### Overall Coverage"));
assert!(!rendered.contains("Informational only. Does not affect the gate result in v1."));
}
#[test]
fn renders_all_nonzero_metrics_in_markdown_summary() {
let result = GateResult {
metrics: vec![
crate::model::ComputedMetric {
metric: MetricKind::Region,
covered: 1,
total: 2,
percent: 50.0,
uncovered_changed_opportunities: Vec::new(),
changed_totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 1,
total: 2,
},
)]),
totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 3,
total: 4,
},
)]),
},
crate::model::ComputedMetric {
metric: MetricKind::Line,
covered: 2,
total: 2,
percent: 100.0,
uncovered_changed_opportunities: Vec::new(),
changed_totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 2,
total: 2,
},
)]),
totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 5,
total: 5,
},
)]),
},
],
rules: vec![RuleOutcome {
rule: GateRule::Percent {
metric: MetricKind::Region,
minimum_percent: 90.0,
},
passed: false,
observed_percent: 50.0,
observed_uncovered_count: 0,
}],
passed: false,
};
let rendered = render(&result, "origin/main...HEAD");
assert!(rendered.contains("#### Region"));
assert!(rendered.contains("#### Line"));
assert!(rendered.contains(
"| File | Covered Changed Lines | Changed Lines | Coverage | Missed Changed Spans |"
));
assert!(rendered.contains("| File | Covered Lines | Lines | Missed Lines | Coverage |"));
}
#[test]
fn renders_rule_status_with_unicode_icons() {
let result = GateResult {
metrics: vec![crate::model::ComputedMetric {
metric: MetricKind::Region,
covered: 2,
total: 2,
percent: 100.0,
uncovered_changed_opportunities: Vec::new(),
changed_totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 2,
total: 2,
},
)]),
totals_by_file: BTreeMap::new(),
}],
rules: vec![
RuleOutcome {
rule: GateRule::Percent {
metric: MetricKind::Region,
minimum_percent: 90.0,
},
passed: true,
observed_percent: 100.0,
observed_uncovered_count: 0,
},
RuleOutcome {
rule: GateRule::UncoveredCount {
metric: MetricKind::Region,
maximum_count: 0,
},
passed: false,
observed_percent: 100.0,
observed_uncovered_count: 1,
},
],
passed: false,
};
let rendered = render(&result, "origin/main...HEAD");
assert!(rendered.contains("| ✅PASS | `fail-under-regions` | 100.00% | ≥ 90.00% |"));
assert!(rendered.contains("| ❌FAIL | `fail-uncovered-regions` | 1 | ≤ 0 |"));
}
#[test]
fn renders_coverage_with_threshold_circles() {
let result = GateResult {
metrics: vec![crate::model::ComputedMetric {
metric: MetricKind::Region,
covered: 1,
total: 3,
percent: 33.33,
uncovered_changed_opportunities: Vec::new(),
changed_totals_by_file: BTreeMap::from([
(
PathBuf::from("src/red.rs"),
FileTotals {
covered: 0,
total: 3,
},
),
(
PathBuf::from("src/yellow.rs"),
FileTotals {
covered: 1,
total: 2,
},
),
(
PathBuf::from("src/green.rs"),
FileTotals {
covered: 4,
total: 4,
},
),
]),
totals_by_file: BTreeMap::from([
(
PathBuf::from("src/red.rs"),
FileTotals {
covered: 2,
total: 5,
},
),
(
PathBuf::from("src/yellow.rs"),
FileTotals {
covered: 3,
total: 4,
},
),
(
PathBuf::from("src/green.rs"),
FileTotals {
covered: 5,
total: 5,
},
),
]),
}],
rules: vec![],
passed: true,
};
let rendered = render(&result, "origin/main...HEAD");
assert!(rendered.contains("| `src/red.rs` | 0 | 3 | 0.00% 🔴 |"));
assert!(rendered.contains("| `src/yellow.rs` | 1 | 2 | 50.00% 🟡 |"));
assert!(rendered.contains("| `src/green.rs` | 4 | 4 | 100.00% 🟢 |"));
assert!(rendered.contains("| `src/red.rs` | 2 | 5 | 3 | 40.00% 🔴 |"));
assert!(rendered.contains("| `src/yellow.rs` | 3 | 4 | 1 | 75.00% 🟡 |"));
assert!(rendered.contains("| `src/green.rs` | 5 | 5 | 0 | 100.00% 🟢 |"));
}
#[test]
fn groups_duplicate_spans_with_counts() {
let result = GateResult {
metrics: vec![crate::model::ComputedMetric {
metric: MetricKind::Region,
covered: 1,
total: 3,
percent: 33.33,
uncovered_changed_opportunities: vec![
crate::model::CoverageOpportunity {
kind: crate::model::OpportunityKind::Region,
span: crate::model::SourceSpan {
path: PathBuf::from("src/lib.rs"),
start_line: 5,
end_line: 6,
},
covered: false,
},
crate::model::CoverageOpportunity {
kind: crate::model::OpportunityKind::Region,
span: crate::model::SourceSpan {
path: PathBuf::from("src/lib.rs"),
start_line: 5,
end_line: 6,
},
covered: false,
},
],
changed_totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 1,
total: 3,
},
)]),
totals_by_file: BTreeMap::new(),
}],
rules: vec![RuleOutcome {
rule: GateRule::Percent {
metric: MetricKind::Region,
minimum_percent: 90.0,
},
passed: false,
observed_percent: 33.33,
observed_uncovered_count: 2,
}],
passed: false,
};
let rendered = render(&result, "origin/main...HEAD");
assert!(rendered.contains("`5-6(2)`"));
}
#[test]
fn sorts_spans_numerically() {
let result = GateResult {
metrics: vec![crate::model::ComputedMetric {
metric: MetricKind::Region,
covered: 1,
total: 3,
percent: 33.33,
uncovered_changed_opportunities: vec![
crate::model::CoverageOpportunity {
kind: crate::model::OpportunityKind::Region,
span: crate::model::SourceSpan {
path: PathBuf::from("src/lib.rs"),
start_line: 102,
end_line: 102,
},
covered: false,
},
crate::model::CoverageOpportunity {
kind: crate::model::OpportunityKind::Region,
span: crate::model::SourceSpan {
path: PathBuf::from("src/lib.rs"),
start_line: 48,
end_line: 48,
},
covered: false,
},
],
changed_totals_by_file: BTreeMap::from([(
PathBuf::from("src/lib.rs"),
FileTotals {
covered: 1,
total: 3,
},
)]),
totals_by_file: BTreeMap::new(),
}],
rules: vec![RuleOutcome {
rule: GateRule::Percent {
metric: MetricKind::Region,
minimum_percent: 90.0,
},
passed: false,
observed_percent: 33.33,
observed_uncovered_count: 2,
}],
passed: false,
};
let rendered = render(&result, "origin/main...HEAD");
let row = rendered
.lines()
.find(|line| line.starts_with("| `src/lib.rs` |"))
.expect("file row should exist");
assert!(row.find("`48-48`").expect("48-48") < row.find("`102-102`").expect("102-102"));
}
}