use shiplog::schema::coverage::{Completeness, CoverageManifest, CoverageSlice};
use shiplog::schema::event::EventEnvelope;
use super::source::{
SkippedSource, display_source_label, display_source_list, event_source_present,
included_source_summary, skipped_source_warning, skipped_source_warnings, source_event_count,
source_present,
};
pub(crate) fn render_coverage(
out: &mut String,
coverage: &CoverageManifest,
events: &[EventEnvelope],
) {
out.push_str("## Coverage and Limits\n\n");
let skipped_sources = skipped_source_warnings(&coverage.warnings);
render_included_sources(out, coverage, events, &skipped_sources);
render_skipped_sources(out, &skipped_sources);
render_known_gaps(out, coverage, events);
render_coverage_details(out, coverage);
}
fn render_included_sources(
out: &mut String,
coverage: &CoverageManifest,
events: &[EventEnvelope],
skipped_sources: &[SkippedSource<'_>],
) {
out.push_str("Included:\n");
let included_sources = included_source_summary(&coverage.sources, events, skipped_sources);
if included_sources.is_empty() {
out.push_str("- No completed sources recorded\n");
} else {
for source in &included_sources {
let count = source_event_count(events, source);
let noun = if count == 1 { "event" } else { "events" };
out.push_str(&format!(
"- {}: {} {}\n",
display_source_label(source),
count,
noun
));
}
}
render_query_slice_summary(out, &coverage.slices);
out.push('\n');
}
fn render_query_slice_summary(out: &mut String, slices: &[CoverageSlice]) {
if slices.is_empty() {
out.push_str("- Fetched events: not reported by query slices\n");
return;
}
let fetched: u64 = slices.iter().map(|slice| slice.fetched).sum();
let total: u64 = slices.iter().map(|slice| slice.total_count).sum();
let slice_label = if slices.len() == 1 { "slice" } else { "slices" };
out.push_str(&format!(
"- Query slices: {} {}, fetched {} of {} reported results\n",
slices.len(),
slice_label,
fetched,
total
));
}
fn render_skipped_sources(out: &mut String, skipped_sources: &[SkippedSource<'_>]) {
out.push_str("Skipped:\n");
if skipped_sources.is_empty() {
out.push_str("- None recorded\n");
} else {
for skipped in skipped_sources {
out.push_str(&format!(
"- {}: {}\n",
display_source_label(skipped.source),
skipped.reason
));
}
}
out.push('\n');
}
fn render_known_gaps(out: &mut String, coverage: &CoverageManifest, events: &[EventEnvelope]) {
out.push_str("Known gaps:\n");
let mut has_gap = render_completeness_gap(out, &coverage.completeness);
has_gap |= render_warning_gaps(out, &coverage.warnings);
has_gap |= render_manual_source_gap(out, coverage, events);
has_gap |= render_slice_quality_gaps(out, &coverage.slices);
if !has_gap {
out.push_str("- None recorded\n");
}
out.push('\n');
}
fn render_completeness_gap(out: &mut String, completeness: &Completeness) -> bool {
if matches!(completeness, Completeness::Complete) {
return false;
}
out.push_str(&format!("- Overall completeness is {}\n", completeness));
true
}
fn render_warning_gaps(out: &mut String, warnings: &[String]) -> bool {
let mut has_gap = false;
for warning in warnings {
if skipped_source_warning(warning).is_none() {
has_gap = true;
out.push_str(&format!("- {}\n", warning));
}
}
has_gap
}
fn render_manual_source_gap(
out: &mut String,
coverage: &CoverageManifest,
events: &[EventEnvelope],
) -> bool {
if !source_present(&coverage.sources, "manual") && !event_source_present(events, "manual") {
return false;
}
out.push_str("- Manual events are user-provided\n");
true
}
fn render_slice_quality_gaps(out: &mut String, slices: &[CoverageSlice]) -> bool {
let mut has_gap = false;
let incomplete_count = slices
.iter()
.filter(|slice| slice.incomplete_results.unwrap_or(false))
.count();
if incomplete_count > 0 {
has_gap = true;
let slice_label = if incomplete_count == 1 {
"slice"
} else {
"slices"
};
out.push_str(&format!(
"- {} query {} reported incomplete results\n",
incomplete_count, slice_label
));
}
let capped_count = slices
.iter()
.filter(|slice| slice.total_count > slice.fetched)
.count();
if capped_count > 0 {
has_gap = true;
let slice_label = if capped_count == 1 { "slice" } else { "slices" };
out.push_str(&format!(
"- {} query {} fetched fewer results than reported\n",
capped_count, slice_label
));
}
has_gap
}
fn render_coverage_details(out: &mut String, coverage: &CoverageManifest) {
out.push_str("Details:\n");
out.push_str(&format!(
"- **Date window:** {} to {}\n",
coverage.window.since, coverage.window.until
));
out.push_str(&format!("- **Mode:** {}\n", coverage.mode));
out.push_str(&format!(
"- **Sources:** {}\n",
display_source_list(&coverage.sources)
));
render_owner_filter_detail(out, &coverage.slices);
out.push_str(&format!(
"- **Completeness:** {:?}\n",
coverage.completeness
));
render_query_slice_details(out, &coverage.slices);
out.push('\n');
}
fn render_owner_filter_detail(out: &mut String, slices: &[CoverageSlice]) {
let Some(summary) = owner_filter_summary(slices) else {
return;
};
out.push_str(&format!("- **GitHub owner filter:** {summary}\n"));
}
fn owner_filter_summary(slices: &[CoverageSlice]) -> Option<String> {
let mut requested = None;
let mut kept = None;
let mut dropped = None;
for note in slices.iter().flat_map(|slice| &slice.notes) {
if note == "owner_filter:actor_wide" {
requested.get_or_insert_with(|| "actor-wide".to_string());
} else if let Some(value) = note.strip_prefix("owner_filter:requested=") {
requested = Some(format!("requested {}", value.replace(',', ", ")));
} else if let Some(value) = note.strip_prefix("owner_filter:kept=") {
kept = Some(format!("kept {}", value));
} else if let Some(value) = note.strip_prefix("owner_filter:dropped=") {
dropped = Some(format!("dropped {}", value));
}
}
let mut parts = Vec::new();
if let Some(requested) = requested {
parts.push(requested);
}
if let Some(kept) = kept {
parts.push(kept);
}
if let Some(dropped) = dropped {
parts.push(dropped);
}
if parts.is_empty() {
None
} else {
Some(parts.join("; "))
}
}
fn render_query_slice_details(out: &mut String, slices: &[CoverageSlice]) {
if slices.is_empty() {
return;
}
out.push_str(&format!("- **Query slices:** {}\n", slices.len()));
render_incomplete_slice_detail(out, slices);
render_capped_slice_details(out, slices);
}
fn render_incomplete_slice_detail(out: &mut String, slices: &[CoverageSlice]) {
let partial_count = slices
.iter()
.filter(|s| s.incomplete_results.unwrap_or(false))
.count();
if partial_count > 0 {
out.push_str(&format!(
" - ⚠️ {} slices had incomplete results\n",
partial_count
));
}
}
fn render_capped_slice_details(out: &mut String, slices: &[CoverageSlice]) {
let capped_slices: Vec<_> = slices
.iter()
.filter(|s| s.total_count > s.fetched)
.collect();
if capped_slices.is_empty() {
return;
}
out.push_str(" - **Slicing applied (API caps):**\n");
for slice in capped_slices.iter().take(3) {
out.push_str(&format!(
" - {}: fetched {}/{} ({}%)\n",
slice.query,
slice.fetched,
slice.total_count,
fetched_percent(slice)
));
}
if capped_slices.len() > 3 {
out.push_str(&format!(" - ... and {} more\n", capped_slices.len() - 3));
}
}
fn fetched_percent(slice: &CoverageSlice) -> u64 {
if slice.total_count > 0 {
(slice.fetched as f64 / slice.total_count as f64 * 100.0) as u64
} else {
100
}
}