#![cfg_attr(
not(test),
expect(
dead_code,
reason = "coverage view layer (SL-045 PHASE-03) is a leaf built ahead of \
its main.rs `coverage` verb consumer — every item is dead in the \
bins/lib build until that verb is wired"
)
)]
use std::collections::BTreeSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::coverage::{self, Composite, Verdict};
use crate::coverage_scan::scan_coverage_batch;
use crate::listing;
use crate::requirement::{self, ReqKind, ReqStatus};
use crate::spec::member_reqs;
enum Target {
Req(String),
Spec(String),
}
fn dispatch(reference: &str) -> anyhow::Result<Target> {
let prefix = reference.split('-').next().unwrap_or("");
match prefix {
"REQ" => Ok(Target::Req(reference.to_owned())),
"PRD" | "SPEC" => Ok(Target::Spec(reference.to_owned())),
_ => anyhow::bail!("`{reference}` is not a coverage ref (expected REQ-/PRD-/SPEC-NNN)"),
}
}
pub(crate) enum ObservedState {
None,
Contradicted,
Verified,
Forward,
Stale,
}
impl ObservedState {
fn label(&self) -> &'static str {
match self {
ObservedState::None => "none",
ObservedState::Contradicted => "contradicted",
ObservedState::Verified => "verified",
ObservedState::Forward => "forward",
ObservedState::Stale => "stale",
}
}
}
pub(crate) fn observed_state(c: &Composite) -> ObservedState {
if c.is_empty() {
ObservedState::None
} else if c.any_failed_or_blocked() {
ObservedState::Contradicted
} else if c.any_fresh_verified() {
ObservedState::Verified
} else if c.only_forward() {
ObservedState::Forward
} else {
ObservedState::Stale
}
}
pub(crate) enum CoverageRow {
Healthy {
id: String,
label: Option<String>,
kind: ReqKind,
status: ReqStatus,
observed: ObservedState,
verdict: Verdict,
},
Dangling {
id: String,
label: Option<String>,
load_error: String,
},
}
pub(crate) fn rows(root: &Path, reference: &str) -> anyhow::Result<Vec<CoverageRow>> {
let members: Vec<(Option<String>, String)> = match dispatch(reference)? {
Target::Req(r) => vec![(None, requirement::canonicalize_fk(&r))],
Target::Spec(s) => member_reqs(root, &s)?
.into_iter()
.map(|m| (Some(m.label), m.requirement))
.collect(),
};
let wanted: BTreeSet<String> = members.iter().map(|(_, req)| req.clone()).collect();
let scanned = scan_coverage_batch(root, &wanted);
let mut out = Vec::with_capacity(members.len());
for (label, req) in members {
let cells = scanned.get(&req).map(Vec::as_slice).unwrap_or_default();
let comp = coverage::composite(cells);
match requirement::load(root, &req) {
Ok(r) => out.push(CoverageRow::Healthy {
id: req,
label,
kind: r.kind,
status: r.status,
observed: observed_state(&comp),
verdict: coverage::drift(r.status, &comp),
}),
Err(e) if label.is_some() => out.push(CoverageRow::Dangling {
id: req,
label,
load_error: e.to_string(),
}),
Err(e) => return Err(e),
}
}
Ok(out)
}
impl CoverageRow {
fn id(&self) -> &str {
match self {
CoverageRow::Healthy { id, .. } | CoverageRow::Dangling { id, .. } => id,
}
}
fn label(&self) -> Option<&str> {
match self {
CoverageRow::Healthy { label, .. } | CoverageRow::Dangling { label, .. } => {
label.as_deref()
}
}
}
fn kind_cell(&self) -> String {
match self {
CoverageRow::Healthy { kind, .. } => kind.as_str().to_owned(),
CoverageRow::Dangling { load_error, .. } => load_error.clone(),
}
}
fn status_cell(&self) -> String {
match self {
CoverageRow::Healthy { status, .. } => status.as_str().to_owned(),
CoverageRow::Dangling { load_error, .. } => load_error.clone(),
}
}
fn observed_cell(&self) -> String {
match self {
CoverageRow::Healthy { observed, .. } => observed.label().to_owned(),
CoverageRow::Dangling { load_error, .. } => load_error.clone(),
}
}
fn verdict_cell(&self) -> String {
match self {
CoverageRow::Healthy { verdict, .. } => verdict.label(),
CoverageRow::Dangling { load_error, .. } => load_error.clone(),
}
}
fn label_cell(&self) -> String {
self.label().unwrap_or("-").to_owned()
}
fn status_hue(&self) -> Option<owo_colors::AnsiColors> {
match self {
CoverageRow::Healthy { status, .. } => listing::status_hue(status.as_str()),
CoverageRow::Dangling { .. } => None,
}
}
}
const COVERAGE_COLUMNS: [listing::Column<CoverageRow>; 6] = [
listing::Column {
name: "id",
header: "id",
cell: |r| r.id().to_owned(),
paint: listing::ColumnPaint::Fixed(owo_colors::AnsiColors::Cyan),
},
listing::Column {
name: "label",
header: "label",
cell: CoverageRow::label_cell,
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "kind",
header: "kind",
cell: CoverageRow::kind_cell,
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "status",
header: "status",
cell: CoverageRow::status_cell,
paint: listing::ColumnPaint::ByValue(CoverageRow::status_hue),
},
listing::Column {
name: "observed",
header: "observed",
cell: CoverageRow::observed_cell,
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "verdict",
header: "verdict",
cell: CoverageRow::verdict_cell,
paint: listing::ColumnPaint::None,
},
];
const COVERAGE_DEFAULT: &[&str] = &["id", "status", "observed", "verdict"];
pub(crate) fn render_table(
rows: &[CoverageRow],
columns: Option<&[String]>,
opts: listing::RenderOpts,
) -> anyhow::Result<String> {
let has_label = rows.iter().any(|r| r.label().is_some());
let default: Vec<&str> = if has_label && columns.is_none() {
std::iter::once("label")
.chain(COVERAGE_DEFAULT.iter().copied())
.collect()
} else {
COVERAGE_DEFAULT.to_vec()
};
let sel = listing::select_columns(&COVERAGE_COLUMNS, &default, columns)?;
Ok(listing::render_columns(rows, &sel, opts))
}
#[derive(Serialize)]
#[serde(untagged)]
enum CoverageJsonRow {
Healthy {
requirement: String,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
kind: &'static str,
status: &'static str,
observed: &'static str,
verdict: String,
#[serde(skip_serializing_if = "Option::is_none")]
divergent_reason: Option<&'static str>,
},
Dangling {
requirement: String,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
dangling: bool,
load_error: String,
},
}
fn json_row(row: &CoverageRow) -> CoverageJsonRow {
match row {
CoverageRow::Healthy {
id,
label,
kind,
status,
observed,
verdict,
} => {
let divergent_reason = match verdict {
Verdict::Divergent(r) => Some(r.label()),
Verdict::Coherent | Verdict::Indeterminate => None,
};
CoverageJsonRow::Healthy {
requirement: id.clone(),
label: label.clone(),
kind: kind.as_str(),
status: status.as_str(),
observed: observed.label(),
verdict: verdict.label(),
divergent_reason,
}
}
CoverageRow::Dangling {
id,
label,
load_error,
} => CoverageJsonRow::Dangling {
requirement: id.clone(),
label: label.clone(),
dangling: true,
load_error: load_error.clone(),
},
}
}
pub(crate) fn render_json(rows: &[CoverageRow]) -> anyhow::Result<String> {
let json_rows: Vec<CoverageJsonRow> = rows.iter().map(json_row).collect();
listing::json_envelope("coverage", &json_rows)
}
pub(crate) fn run(
path: Option<PathBuf>,
reference: &str,
columns: Option<&[String]>,
format: listing::Format,
json: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let rows = rows(&root, reference)?;
let resolved = if json { listing::Format::Json } else { format };
let out = match resolved {
listing::Format::Json => render_json(&rows)?,
listing::Format::Table => render_table(
&rows,
columns,
listing::RenderOpts {
color: crate::tty::stdout_color_enabled(),
term_width: crate::tty::stdout_terminal_width(),
},
)?,
};
write!(std::io::stdout(), "{out}")?;
Ok(())
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
reason = "tests: fail-fast unwrap on round-trip/parse is idiomatic"
)]
mod tests {
use super::*;
use crate::coverage::{CoverageEntry, CoverageKey, IsStale};
use crate::requirement::CoverageStatus;
use std::fs;
fn cell(
slice: &str,
change: &str,
status: CoverageStatus,
stale: IsStale,
) -> (CoverageEntry, IsStale) {
(
CoverageEntry {
key: CoverageKey {
slice: slice.to_owned(),
requirement: "REQ-111".to_owned(),
contributing_change: change.to_owned(),
mode: "VT".to_owned(),
},
status,
git_anchor: "anchor-abc123".to_owned(),
attested_date: None,
touched_paths: Vec::new(),
check: None,
},
stale,
)
}
#[test]
fn observed_state_partitions_the_five_canonical_composites() {
let empty = coverage::composite(&[]);
assert!(matches!(observed_state(&empty), ObservedState::None));
let fresh_verified = coverage::composite(&[cell(
"SL-042",
"SL-042",
CoverageStatus::Verified,
IsStale::Fresh,
)]);
assert!(matches!(
observed_state(&fresh_verified),
ObservedState::Verified
));
let stale_verified = coverage::composite(&[cell(
"SL-042",
"SL-042",
CoverageStatus::Verified,
IsStale::Stale,
)]);
assert!(matches!(
observed_state(&stale_verified),
ObservedState::Stale
));
let failed = coverage::composite(&[cell(
"SL-042",
"SL-042",
CoverageStatus::Failed,
IsStale::Fresh,
)]);
assert!(matches!(
observed_state(&failed),
ObservedState::Contradicted
));
let forward = coverage::composite(&[
cell(
"SL-042",
"SL-042",
CoverageStatus::Planned,
IsStale::Unknown,
),
cell(
"SL-043",
"SL-043",
CoverageStatus::InProgress,
IsStale::Stale,
),
]);
assert!(matches!(observed_state(&forward), ObservedState::Forward));
}
#[test]
fn observed_state_does_not_track_drift_one_to_one() {
let fresh_verified = coverage::composite(&[cell(
"SL-042",
"SL-042",
CoverageStatus::Verified,
IsStale::Fresh,
)]);
assert!(matches!(
observed_state(&fresh_verified),
ObservedState::Verified
));
let verdict = coverage::drift(ReqStatus::Pending, &fresh_verified);
assert!(matches!(verdict, Verdict::Divergent(_)));
}
#[test]
fn dispatch_classifies_req_prd_spec_and_rejects_garbage() {
assert!(matches!(dispatch("REQ-007").unwrap(), Target::Req(r) if r == "REQ-007"));
assert!(matches!(dispatch("PRD-001").unwrap(), Target::Spec(s) if s == "PRD-001"));
assert!(matches!(dispatch("SPEC-012").unwrap(), Target::Spec(s) if s == "SPEC-012"));
assert!(dispatch("SL-045").is_err());
assert!(dispatch("garbage").is_err());
assert!(dispatch("").is_err());
}
fn make_req(root: &Path, slug: &str, kind: ReqKind, status: ReqStatus) -> String {
let reserved = requirement::reserve(root, slug, slug, "2026-06-12").unwrap();
let id = reserved.eid.numeric_id().unwrap();
requirement::set_kind(root, id, kind).unwrap();
requirement::set_status(root, id, status).unwrap();
requirement::canonical_id(id)
}
#[test]
fn bare_single_req_row_has_no_label() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let fk = make_req(root, "auth", ReqKind::Functional, ReqStatus::Active);
let rows = rows(root, &fk).unwrap();
assert_eq!(rows.len(), 1);
match rows.first().unwrap() {
CoverageRow::Healthy { id, label, .. } => {
assert_eq!(*id, fk);
assert!(
label.is_none(),
"a bare REQ read carries no membership label"
);
}
CoverageRow::Dangling { .. } => panic!("expected a healthy row"),
}
}
#[test]
fn bare_single_req_load_failure_is_fatal() {
let dir = tempfile::tempdir().unwrap();
let err = rows(dir.path(), "REQ-404");
assert!(err.is_err());
}
#[test]
fn spec_fan_preserves_member_order_and_continues_past_a_dangling_member() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
crate::spec::run_new(
Some(root.to_path_buf()),
crate::spec::SpecSubtype::Product,
Some("Login".to_owned()),
Some("login".to_owned()),
)
.unwrap();
let first = make_req(root, "first", ReqKind::Functional, ReqStatus::Active);
let second = make_req(root, "second", ReqKind::Quality, ReqStatus::Pending);
let members_path = root.join(".doctrine/spec/product/001/members.toml");
for (idx, fk) in [first.as_str(), second.as_str(), "REQ-999"]
.iter()
.enumerate()
{
let mut text = fs::read_to_string(&members_path).unwrap();
let order = idx + 1;
text.push_str(&format!(
"\n[[member]]\nrequirement = \"{fk}\"\nlabel = \"FR-{order:03}\"\norder = {order}\n"
));
fs::write(&members_path, text).unwrap();
}
let rows = rows(root, "PRD-001").unwrap();
assert_eq!(rows.len(), 3, "the fan continues past the dangling member");
assert_eq!(rows.first().unwrap().id(), first);
assert_eq!(rows.get(1).unwrap().id(), second);
match rows.get(2).unwrap() {
CoverageRow::Dangling {
id,
label,
load_error,
} => {
assert_eq!(id, "REQ-999");
assert_eq!(label.as_deref(), Some("FR-003"));
assert!(!load_error.is_empty());
}
CoverageRow::Healthy { .. } => panic!("REQ-999 should dangle"),
}
}
#[test]
fn dangling_json_row_has_dangling_flag_and_no_status_keys() {
let row = CoverageRow::Dangling {
id: "REQ-999".to_owned(),
label: Some("FR-003".to_owned()),
load_error: "requirement REQ-999 not found".to_owned(),
};
let json = render_json(std::slice::from_ref(&row)).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = value
.get("rows")
.and_then(|r| r.get(0))
.and_then(serde_json::Value::as_object)
.unwrap();
assert_eq!(
obj.get("dangling").and_then(serde_json::Value::as_bool),
Some(true)
);
assert!(obj.contains_key("load_error"));
assert_eq!(
obj.get("requirement").and_then(serde_json::Value::as_str),
Some("REQ-999")
);
assert_eq!(
obj.get("label").and_then(serde_json::Value::as_str),
Some("FR-003")
);
assert!(!obj.contains_key("status"));
assert!(!obj.contains_key("observed"));
assert!(!obj.contains_key("verdict"));
assert!(!obj.contains_key("kind"));
}
#[test]
fn healthy_json_row_omits_label_and_divergent_reason_when_none() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let fk = make_req(root, "auth", ReqKind::Functional, ReqStatus::Active);
let rows = rows(root, &fk).unwrap();
let json = render_json(&rows).unwrap();
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
let obj = value
.get("rows")
.and_then(|r| r.get(0))
.and_then(serde_json::Value::as_object)
.unwrap();
assert_eq!(
obj.get("status").and_then(serde_json::Value::as_str),
Some("active")
);
assert_eq!(
obj.get("observed").and_then(serde_json::Value::as_str),
Some("none")
);
assert!(!obj.contains_key("label"), "a bare REQ omits the label key");
assert!(
!obj.contains_key("divergent_reason"),
"a non-divergent verdict omits the reason key"
);
}
#[test]
fn table_prepends_label_only_for_a_labelled_row_set() {
let labelled = CoverageRow::Healthy {
id: "REQ-001".to_owned(),
label: Some("FR-001".to_owned()),
kind: ReqKind::Functional,
status: ReqStatus::Active,
observed: ObservedState::None,
verdict: Verdict::Indeterminate,
};
let out = render_table(
std::slice::from_ref(&labelled),
None,
listing::RenderOpts::default(),
)
.unwrap();
let header = out.lines().next().unwrap();
assert!(
header.starts_with("label"),
"labelled set leads with label: {header}"
);
let bare = CoverageRow::Healthy {
id: "REQ-001".to_owned(),
label: None,
kind: ReqKind::Functional,
status: ReqStatus::Active,
observed: ObservedState::None,
verdict: Verdict::Indeterminate,
};
let out = render_table(
std::slice::from_ref(&bare),
None,
listing::RenderOpts::default(),
)
.unwrap();
let header = out.lines().next().unwrap();
assert!(
header.starts_with("id"),
"bare set omits label, leads with id: {header}"
);
}
}