src/warehouse/coverage.rs
//! Iceberg writer + reader for **source-based coverage** (feature N — maven
//! style): the `coverage` (overall + per-crate + per-file rollup) and
//! `coverage_fn` (per-function, boundary-tagged) tables.
//!
//! Mirrors [`super::surface_coverage`] / [`super::test_results`]: one Iceberg
//! snapshot per `nornir coverage` run, all rows sharing one `(run_id,
//! ts_micros)`, historized so coverage is time-travellable and feeds the docs
//! `nornir:gen:coverage` table + the viz 🧪 Test-pane coverage panel.
//!
//! The PURE model — [`crate::coverage::CoverageReport`] + the percentage / rollup
//! / boundary logic — lives in [`crate::coverage`]; this module only adds the
//! `CoverageReport → rows → warehouse → rows → panel data` write/read seam.
use std::sync::Arc;
use anyhow::{Result, anyhow};
use arrow::array::{Array, Int32Array, Int64Array, RecordBatch, StringArray, TimestampMicrosecondArray};
use iceberg::Catalog;
use iceberg::arrow::schema_to_arrow_schema;
use super::iceberg::{
IcebergWarehouse, TABLE_COVERAGE, TABLE_COVERAGE_FN, append_batch, ensure_table_schema,
};
use crate::coverage::{Boundary, CoverageReport, CrateCoverage, FileCoverage, FnCoverage};
// ─── persisted row shapes ───────────────────────────────────────────────────
/// One `coverage` row: an overall / per-crate / per-file rollup.
///
/// The struct itself now lives in the `nornir-coverage` leaf (a plain serde POD
/// whose only logic is `pct_i64`, which already lived there) so the
/// warehouse-free doc assembly (`nornir-docgen`'s coverage section) can accept
/// pre-fetched rows without linking this warehouse. Re-exported here unchanged —
/// every `crate::warehouse::coverage::CoverageRow` call site resolves as before.
///
/// `serde` derives let the server ship the exact rows to a thin viz client over
/// `Viz.Coverage` and the client decode them into the identical struct (the same
/// fat/thin parity pattern as [`super::release_events::ReleaseEventRow`]).
pub use nornir_coverage::CoverageRow;
/// One `coverage_fn` row: a boundary-tagged function's coverage.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CoverageFnRow {
pub run_id: String,
pub ts_micros: i64,
pub repo: String,
pub name: String,
pub file: String,
/// `ui` | `grpc` | `emitter` | `core`.
pub boundary: String,
pub lines: i64,
pub lines_covered: i64,
pub regions: i64,
pub regions_covered: i64,
/// The function's first source line (`0` = the export named none, or a row
/// written before the column) — with `file`, the key the Mímir code room's
/// functions join on.
#[serde(default)]
pub line: u32,
/// The function's last source line.
#[serde(default)]
pub end_line: u32,
/// The checkout's HEAD the run measured (`""` = not known).
#[serde(default)]
pub commit: String,
/// An `only: Affected` run, merged ([`merge_affected`]): the commit whose
/// run measured these counts — the function did not change since, so the
/// selected tests did not run it again (`""` = measured by THIS run).
#[serde(default)]
pub carried_from: String,
/// An `only: Affected` run: did the function change since the baseline?
/// (`None` = a run of every test, which does not say.)
#[serde(default)]
pub changed: Option<bool>,
}
impl CoverageFnRow {
pub fn exercised(&self) -> bool {
self.regions_covered > 0 || self.lines_covered > 0
}
pub fn is_boundary(&self) -> bool {
self.boundary != Boundary::Core.as_str()
}
/// A function that changed since the baseline and that no selected test
/// executed — what an `only: Affected` coverage run flags.
pub fn changed_uncovered(&self) -> bool {
self.changed == Some(true) && !self.exercised()
}
}
/// One changed item an `only: Affected` run was told about (`Test.Affected`'s
/// `changed`): where it is at the measured commit.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ChangedSpan {
pub file: String,
pub line: u32,
pub end_line: u32,
#[serde(default)]
pub fqn: String,
}
/// What [`merge_affected`] did.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Merged {
/// Rows whose counts came from the previous run (unchanged functions).
pub carried: usize,
/// Rows measured by this run (changed functions, and ones the previous
/// run did not have).
pub measured: usize,
/// Changed functions no selected test executed.
pub changed_uncovered: Vec<String>,
}
/// **Merge an `only: Affected` coverage run with the previous one.** `new` is
/// this run's per-function rows: every instrumented function, at this
/// commit's lines, but counted over the SELECTED tests only. A function that
/// did not change since the baseline (its span meets no `changed` span of its
/// file) keeps the previous run's counts — the tests that would execute it
/// were not run because nothing they reach changed; `carried_from` names the
/// commit that measured them. A changed function (or one the previous run did
/// not have) keeps this run's counts; a changed one no selected test executed
/// is flagged. `previous` is the repo's newest earlier run.
pub fn merge_affected(new: &mut [CoverageFnRow], previous: &[CoverageFnRow], changed: &[ChangedSpan]) -> Merged {
use std::collections::HashMap;
let mut prev: HashMap<(&str, &str), &CoverageFnRow> = HashMap::new();
for p in previous {
prev.insert((p.file.as_str(), p.name.as_str()), p);
}
let touches = |r: &CoverageFnRow| {
r.line == 0
|| changed.iter().any(|c| c.file == r.file && c.line <= r.end_line.max(r.line) && r.line <= c.end_line.max(c.line))
};
let mut m = Merged::default();
for r in new.iter_mut() {
let changed_here = touches(r);
r.changed = Some(changed_here);
match prev.get(&(r.file.as_str(), r.name.as_str())) {
// unchanged: what the tests NOT run executed is what they executed
// before; what the selected tests executed now adds to it
Some(p) if !changed_here && (p.lines_covered > r.lines_covered || p.regions_covered > r.regions_covered) => {
r.lines_covered = r.lines_covered.max(p.lines_covered).min(r.lines.max(p.lines));
r.regions_covered = r.regions_covered.max(p.regions_covered).min(r.regions.max(p.regions));
r.carried_from = if p.carried_from.is_empty() { p.commit.clone() } else { p.carried_from.clone() };
m.carried += 1;
}
_ => {
m.measured += 1;
if r.changed_uncovered() {
m.changed_uncovered.push(r.name.clone());
}
}
}
}
m
}
/// Flatten a parsed [`CoverageReport`] into the `(coverage, coverage_fn)` row
/// sets for one run. PURE — fully unit-testable. The `coverage` set is: one
/// `overall` row + one `crate:*` row per crate + one `file:*` row per file. The
/// `coverage_fn` set is one row per discovered function (all boundaries, so the
/// boundary view can also show which non-boundary fns exist if wanted; the
/// reader filters to ui/grpc/emitter).
pub fn rows_for(
report: &CoverageReport,
run_id: &str,
repo: &str,
ts_micros: i64,
) -> (Vec<CoverageRow>, Vec<CoverageFnRow>) {
let mut rows = Vec::new();
let mk = |scope: String, krate: String, file: String, l: u64, lc: u64, r: u64, rc: u64| {
CoverageRow {
run_id: run_id.to_string(),
ts_micros,
repo: repo.to_string(),
scope,
krate,
file,
lines: l as i64,
lines_covered: lc as i64,
regions: r as i64,
regions_covered: rc as i64,
}
};
rows.push(mk(
"overall".into(),
String::new(),
String::new(),
report.lines,
report.lines_covered,
report.regions,
report.regions_covered,
));
for c in &report.crates {
let CrateCoverage {
krate,
lines,
lines_covered,
regions,
regions_covered,
..
} = c;
rows.push(mk(
format!("crate:{krate}"),
krate.clone(),
String::new(),
*lines,
*lines_covered,
*regions,
*regions_covered,
));
}
for f in &report.files {
let FileCoverage {
file,
krate,
lines,
lines_covered,
regions,
regions_covered,
..
} = f;
rows.push(mk(
format!("file:{file}"),
krate.clone(),
file.clone(),
*lines,
*lines_covered,
*regions,
*regions_covered,
));
}
let mut fn_rows = Vec::new();
for f in &report.files {
for fnc in &f.functions {
let FnCoverage {
name,
file,
lines,
lines_covered,
regions,
regions_covered,
boundary,
line,
end_line,
} = fnc;
fn_rows.push(CoverageFnRow {
run_id: run_id.to_string(),
ts_micros,
repo: repo.to_string(),
name: name.clone(),
file: file.clone(),
boundary: boundary.as_str().to_string(),
lines: *lines as i64,
lines_covered: *lines_covered as i64,
regions: *regions as i64,
regions_covered: *regions_covered as i64,
line: *line,
end_line: *end_line,
commit: String::new(),
carried_from: String::new(),
changed: None,
});
}
}
(rows, fn_rows)
}
// ─── write ───────────────────────────────────────────────────────────────────
/// Append a run's `coverage` rows as ONE Iceberg snapshot.
pub async fn append_coverage(wh: &IcebergWarehouse, rows: &[CoverageRow]) -> Result<()> {
if rows.is_empty() {
return Ok(());
}
let ident = wh.table_ident(TABLE_COVERAGE);
let table = wh.catalog().load_table(&ident).await?;
let table = ensure_table_schema(
wh.catalog(),
&ident,
table,
&super::iceberg_schema::coverage()?,
)
.await?;
let arrow_schema = Arc::new(schema_to_arrow_schema(table.metadata().current_schema())?);
let cols: Vec<Arc<dyn Array>> = vec![
Arc::new(StringArray::from(
rows.iter().map(|r| r.run_id.clone()).collect::<Vec<_>>(),
)),
Arc::new(
TimestampMicrosecondArray::from(rows.iter().map(|r| r.ts_micros).collect::<Vec<_>>())
.with_timezone("+00:00"),
),
Arc::new(StringArray::from(
rows.iter().map(|r| r.repo.clone()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.scope.clone()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.krate.clone()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.file.clone()).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.lines).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.lines_covered).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.regions).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.regions_covered).collect::<Vec<_>>(),
)),
];
let batch = RecordBatch::try_new(arrow_schema, cols)?;
append_batch(wh.catalog(), table, batch).await?;
Ok(())
}
/// Append a run's `coverage_fn` rows as ONE Iceberg snapshot.
pub async fn append_coverage_fn(wh: &IcebergWarehouse, rows: &[CoverageFnRow]) -> Result<()> {
if rows.is_empty() {
return Ok(());
}
let ident = wh.table_ident(TABLE_COVERAGE_FN);
let table = wh.catalog().load_table(&ident).await?;
let table = ensure_table_schema(
wh.catalog(),
&ident,
table,
&super::iceberg_schema::coverage_fn()?,
)
.await?;
let arrow_schema = Arc::new(schema_to_arrow_schema(table.metadata().current_schema())?);
let cols: Vec<Arc<dyn Array>> = vec![
Arc::new(StringArray::from(
rows.iter().map(|r| r.run_id.clone()).collect::<Vec<_>>(),
)),
Arc::new(
TimestampMicrosecondArray::from(rows.iter().map(|r| r.ts_micros).collect::<Vec<_>>())
.with_timezone("+00:00"),
),
Arc::new(StringArray::from(
rows.iter().map(|r| r.repo.clone()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.name.clone()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.file.clone()).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| r.boundary.clone()).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.lines).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.lines_covered).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.regions).collect::<Vec<_>>(),
)),
Arc::new(Int64Array::from(
rows.iter().map(|r| r.regions_covered).collect::<Vec<_>>(),
)),
// 11-13: where the function is, and the commit (NULL = not known)
Arc::new(Int32Array::from(
rows.iter().map(|r| (r.line > 0).then_some(r.line as i32)).collect::<Vec<_>>(),
)),
Arc::new(Int32Array::from(
rows.iter().map(|r| (r.end_line > 0).then_some(r.end_line as i32)).collect::<Vec<_>>(),
)),
Arc::new(StringArray::from(
rows.iter().map(|r| Some(r.commit.clone()).filter(|c| !c.is_empty())).collect::<Vec<_>>(),
)),
// 14-15: an `only: Affected` run, merged (NULL = a run of every test)
Arc::new(StringArray::from(
rows.iter().map(|r| Some(r.carried_from.clone()).filter(|c| !c.is_empty())).collect::<Vec<_>>(),
)),
Arc::new(arrow::array::BooleanArray::from(rows.iter().map(|r| r.changed).collect::<Vec<_>>())),
];
let batch = RecordBatch::try_new(arrow_schema, cols)?;
append_batch(wh.catalog(), table, batch).await?;
Ok(())
}
/// Append both row sets for a run (the usual write — `coverage` then `coverage_fn`).
pub async fn append_report(
wh: &IcebergWarehouse,
rows: &[CoverageRow],
fn_rows: &[CoverageFnRow],
) -> Result<()> {
append_coverage(wh, rows).await?;
append_coverage_fn(wh, fn_rows).await
}
// ─── read ──────────────────────────────────────────────────────────────────
/// An optional int column read by name: absent (a table older than it) or NULL → 0.
fn opt_i32(b: &RecordBatch, name: &str, i: usize) -> u32 {
b.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.filter(|c| !c.is_null(i))
.map_or(0, |c| c.value(i).max(0) as u32)
}
/// Read every `coverage` row for `repo`, sorted by `(ts_micros, scope)`.
pub async fn query_coverage(wh: &IcebergWarehouse, repo: &str) -> Result<Vec<CoverageRow>> {
// Blank/torn Iceberg metadata JSON → 0 rows, never a JSON-EOF crash (load+read).
let batches: Vec<RecordBatch> = super::iceberg::load_and_read_all(wh, TABLE_COVERAGE).await?;
let mut out = Vec::new();
for b in &batches {
let run_id = col_str(b, 0, TABLE_COVERAGE)?;
let ts = col_ts(b, 1, TABLE_COVERAGE)?;
let r = col_str(b, 2, TABLE_COVERAGE)?;
let scope = col_str(b, 3, TABLE_COVERAGE)?;
let krate = col_str(b, 4, TABLE_COVERAGE)?;
let file = col_str(b, 5, TABLE_COVERAGE)?;
let lines = col_i64(b, 6, TABLE_COVERAGE)?;
let lc = col_i64(b, 7, TABLE_COVERAGE)?;
let regions = col_i64(b, 8, TABLE_COVERAGE)?;
let rc = col_i64(b, 9, TABLE_COVERAGE)?;
for i in 0..b.num_rows() {
if r.value(i) != repo {
continue;
}
out.push(CoverageRow {
run_id: run_id.value(i).to_string(),
ts_micros: ts.value(i),
repo: r.value(i).to_string(),
scope: scope.value(i).to_string(),
krate: krate.value(i).to_string(),
file: file.value(i).to_string(),
lines: lines.value(i),
lines_covered: lc.value(i),
regions: regions.value(i),
regions_covered: rc.value(i),
});
}
}
out.sort_by(|a, b| (a.ts_micros, &a.scope).cmp(&(b.ts_micros, &b.scope)));
Ok(out)
}
/// Read every `coverage_fn` row for `repo`, sorted by `(ts_micros, boundary, name)`.
pub async fn query_coverage_fn(wh: &IcebergWarehouse, repo: &str) -> Result<Vec<CoverageFnRow>> {
// Blank/torn Iceberg metadata JSON → 0 rows, never a JSON-EOF crash (load+read).
let batches: Vec<RecordBatch> =
super::iceberg::load_and_read_all(wh, TABLE_COVERAGE_FN).await?;
let mut out = Vec::new();
for b in &batches {
let run_id = col_str(b, 0, TABLE_COVERAGE_FN)?;
let ts = col_ts(b, 1, TABLE_COVERAGE_FN)?;
let r = col_str(b, 2, TABLE_COVERAGE_FN)?;
let name = col_str(b, 3, TABLE_COVERAGE_FN)?;
let file = col_str(b, 4, TABLE_COVERAGE_FN)?;
let boundary = col_str(b, 5, TABLE_COVERAGE_FN)?;
let lines = col_i64(b, 6, TABLE_COVERAGE_FN)?;
let lc = col_i64(b, 7, TABLE_COVERAGE_FN)?;
let regions = col_i64(b, 8, TABLE_COVERAGE_FN)?;
let rc = col_i64(b, 9, TABLE_COVERAGE_FN)?;
for i in 0..b.num_rows() {
if r.value(i) != repo {
continue;
}
out.push(CoverageFnRow {
run_id: run_id.value(i).to_string(),
ts_micros: ts.value(i),
repo: r.value(i).to_string(),
name: name.value(i).to_string(),
file: file.value(i).to_string(),
boundary: boundary.value(i).to_string(),
lines: lines.value(i),
lines_covered: lc.value(i),
regions: regions.value(i),
regions_covered: rc.value(i),
line: opt_i32(b, "line", i),
end_line: opt_i32(b, "end_line", i),
commit: b
.column_by_name("commit")
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
.filter(|c| !c.is_null(i))
.map(|c| c.value(i).to_string())
.unwrap_or_default(),
carried_from: b
.column_by_name("carried_from")
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
.filter(|c| !c.is_null(i))
.map(|c| c.value(i).to_string())
.unwrap_or_default(),
changed: b
.column_by_name("changed")
.and_then(|c| c.as_any().downcast_ref::<arrow::array::BooleanArray>())
.filter(|c| !c.is_null(i))
.map(|c| c.value(i)),
});
}
}
out.sort_by(|a, b| {
(a.ts_micros, &a.boundary, &a.name).cmp(&(b.ts_micros, &b.boundary, &b.name))
});
Ok(out)
}
/// The newest run's `(coverage, coverage_fn)` rows for `repo` — what the viz
/// Test pane + docs read. Empty when no run is persisted.
pub async fn latest(
wh: &IcebergWarehouse,
repo: &str,
) -> Result<(Vec<CoverageRow>, Vec<CoverageFnRow>)> {
let all = query_coverage(wh, repo).await?;
let Some(latest_ts) = all.iter().map(|r| r.ts_micros).max() else {
return Ok((Vec::new(), Vec::new()));
};
let run_id = all
.iter()
.filter(|r| r.ts_micros == latest_ts)
.map(|r| r.run_id.clone())
.next()
.unwrap_or_default();
let rows: Vec<CoverageRow> = all.into_iter().filter(|r| r.run_id == run_id).collect();
let fns: Vec<CoverageFnRow> = query_coverage_fn(wh, repo)
.await?
.into_iter()
.filter(|r| r.run_id == run_id)
.collect();
Ok((rows, fns))
}
// ─── column helpers ──────────────────────────────────────────────────────────
fn col_str<'a>(b: &'a RecordBatch, idx: usize, t: &str) -> Result<&'a StringArray> {
b.column(idx)
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| anyhow!("{t} col {idx} is not StringArray"))
}
fn col_i64<'a>(b: &'a RecordBatch, idx: usize, t: &str) -> Result<&'a Int64Array> {
b.column(idx)
.as_any()
.downcast_ref::<Int64Array>()
.ok_or_else(|| anyhow!("{t} col {idx} is not Int64Array"))
}
fn col_ts<'a>(b: &'a RecordBatch, idx: usize, t: &str) -> Result<&'a TimestampMicrosecondArray> {
b.column(idx)
.as_any()
.downcast_ref::<TimestampMicrosecondArray>()
.ok_or_else(|| anyhow!("{t} col {idx} is not TimestampMicrosecondArray"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::coverage::{Boundary, FileCoverage, FnCoverage, summarise};
fn sample_report() -> CoverageReport {
// One viz file (a `ui` fn + a `state_json` emitter) + one core file.
let viz = FileCoverage {
file: "src/viz/test_tab.rs".into(),
krate: "nornir".into(),
lines: 10,
lines_covered: 6,
regions: 8,
regions_covered: 5,
functions: vec![
FnCoverage {
name: "nornir::viz::test_tab::TestTabState::ui".into(),
file: "src/viz/test_tab.rs".into(),
lines: 4,
lines_covered: 4,
regions: 4,
regions_covered: 4,
boundary: Boundary::Ui,
line: 0,
end_line: 0,
},
FnCoverage {
name: "nornir::viz::test_tab::TestTabState::state_json".into(),
file: "src/viz/test_tab.rs".into(),
lines: 3,
lines_covered: 2,
regions: 3,
regions_covered: 1,
boundary: Boundary::Emitter,
line: 0,
end_line: 0,
},
],
};
let core = FileCoverage {
file: "src/deps.rs".into(),
krate: "nornir".into(),
lines: 5,
lines_covered: 5,
regions: 5,
regions_covered: 5,
functions: vec![FnCoverage {
name: "nornir::deps::topo_sort".into(),
file: "src/deps.rs".into(),
lines: 5,
lines_covered: 5,
regions: 5,
regions_covered: 5,
boundary: Boundary::Core,
line: 0,
end_line: 0,
}],
};
summarise("nornir", vec![viz, core]).unwrap()
}
/// LAW 1 round-trip: build a known report → rows → warehouse → rows back,
/// asserting the overall/crate/file rollup AND the boundary fn rows.
#[test]
fn append_query_round_trip_rollup_and_boundary() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
let report = sample_report();
let (rows, fn_rows) = rows_for(&report, "run-1", "nornir", 1000);
// overall + 1 crate + 2 files = 4 coverage rows; 3 fn rows.
assert_eq!(rows.len(), 4);
assert_eq!(fn_rows.len(), 3);
wh.block_on(append_report(&wh, &rows, &fn_rows)).unwrap();
// Read coverage rows back; the overall row carries the right totals.
let back = wh.block_on(query_coverage(&wh, "nornir")).unwrap();
assert_eq!(back.len(), 4);
let overall = back.iter().find(|r| r.scope == "overall").unwrap();
assert_eq!(overall.lines, 15, "10 + 5 lines");
assert_eq!(overall.lines_covered, 11, "6 + 5 covered");
assert!((overall.line_pct() - 11.0 / 15.0 * 100.0).abs() < 1e-9);
// The per-file row for the viz pane reads back at 60% lines.
let vizf = back
.iter()
.find(|r| r.scope == "file:src/viz/test_tab.rs")
.unwrap();
assert_eq!(vizf.krate, "nornir");
assert!((vizf.line_pct() - 60.0).abs() < 1e-9);
// Read fn rows; the ui + emitter boundary fns round-trip with their tags.
let fns = wh.block_on(query_coverage_fn(&wh, "nornir")).unwrap();
assert_eq!(fns.len(), 3);
let ui = fns.iter().find(|r| r.name.ends_with("::ui")).unwrap();
assert_eq!(ui.boundary, "ui");
assert!(ui.is_boundary());
assert!(ui.exercised(), "ui fn covered → exercised");
let emit = fns
.iter()
.find(|r| r.name.ends_with("::state_json"))
.unwrap();
assert_eq!(emit.boundary, "emitter");
// The core fn round-trips as core (boundary view will filter it out).
let core = fns
.iter()
.find(|r| r.name.ends_with("::topo_sort"))
.unwrap();
assert_eq!(core.boundary, "core");
assert!(!core.is_boundary());
// latest() returns the same run's rows.
let (lrows, lfns) = wh.block_on(latest(&wh, "nornir")).unwrap();
assert_eq!(lrows.len(), 4);
assert_eq!(lfns.len(), 3);
// A repo with no rows yields empty.
let (er, ef) = wh.block_on(latest(&wh, "ghost")).unwrap();
assert!(er.is_empty() && ef.is_empty());
}
#[test]
fn latest_picks_newest_run() {
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
let report = sample_report();
let (a, af) = rows_for(&report, "old", "nornir", 1000);
wh.block_on(append_report(&wh, &a, &af)).unwrap();
let (b, bf) = rows_for(&report, "new", "nornir", 2000);
wh.block_on(append_report(&wh, &b, &bf)).unwrap();
let (lrows, _) = wh.block_on(latest(&wh, "nornir")).unwrap();
assert!(lrows.iter().all(|r| r.run_id == "new"), "newest run only");
}
fn fnrow(run: &str, commit: &str, name: &str, line: u32, end_line: u32, covered: i64) -> CoverageFnRow {
CoverageFnRow {
run_id: run.into(),
ts_micros: 0,
repo: "shop".into(),
name: name.into(),
file: "src/lib.rs".into(),
boundary: "core".into(),
lines: 4,
lines_covered: covered,
regions: 2,
regions_covered: covered.min(2),
line,
end_line,
commit: commit.into(),
carried_from: String::new(),
changed: None,
}
}
/// The owner's case: an `only: Affected` coverage run counted the SELECTED
/// tests only. Merged with the previous run, an unchanged function keeps its
/// row's counts (the tests that executed it were not run, nothing they reach
/// changed) and says where they came from; a changed function's row is this
/// run's — and a changed one no selected test executed is flagged.
#[test]
fn an_affected_run_keeps_unchanged_rows_and_replaces_changed_ones() {
let previous = vec![
fnrow("r1", "c1", "shop::total", 10, 14, 4),
fnrow("r1", "c1", "shop::checkout", 20, 24, 3),
fnrow("r1", "c1", "shop::refund", 30, 34, 2),
];
// c2 moved `checkout` (20-24 changed) and `refund` (30-34); only the test
// of checkout ran: total and refund read 0 in the new run
let mut new = vec![
fnrow("r2", "c2", "shop::total", 10, 14, 0),
fnrow("r2", "c2", "shop::checkout", 20, 24, 1),
fnrow("r2", "c2", "shop::refund", 30, 34, 0),
fnrow("r2", "c2", "shop::born", 40, 44, 0),
];
let changed = [
ChangedSpan { file: "src/lib.rs".into(), line: 21, end_line: 21, fqn: "shop::checkout".into() },
ChangedSpan { file: "src/lib.rs".into(), line: 30, end_line: 34, fqn: "shop::refund".into() },
ChangedSpan { file: "src/other.rs".into(), line: 10, end_line: 14, fqn: "other::x".into() },
];
let m = merge_affected(&mut new, &previous, &changed);
let by = |n: &str| new.iter().find(|r| r.name == n).unwrap().clone();
let total = by("shop::total");
assert_eq!((total.lines_covered, total.carried_from.as_str(), total.changed), (4, "c1", Some(false)), "unchanged: the previous row's counts");
let checkout = by("shop::checkout");
assert_eq!((checkout.lines_covered, checkout.carried_from.as_str(), checkout.changed), (1, "", Some(true)), "changed: this run's counts, not the old 3");
let refund = by("shop::refund");
assert_eq!((refund.lines_covered, refund.changed), (0, Some(true)), "changed and not executed: 0, never the old 2");
assert!(refund.changed_uncovered() && !checkout.changed_uncovered());
assert_eq!((m.carried, m.measured), (1, 3), "{m:?}");
assert_eq!(m.changed_uncovered, vec!["shop::refund".to_string()]);
// the merged rows round-trip through the warehouse with their marks
let dir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(dir.path()).unwrap();
wh.block_on(append_coverage_fn(&wh, &new)).unwrap();
let back = wh.block_on(query_coverage_fn(&wh, "shop")).unwrap();
let t = back.iter().find(|r| r.name == "shop::total").unwrap();
assert_eq!((t.carried_from.as_str(), t.changed), ("c1", Some(false)));
let r = back.iter().find(|r| r.name == "shop::refund").unwrap();
assert!(r.changed_uncovered(), "{r:?}");
}
}