use std::collections::BTreeMap;
use serde::Serialize;
use crate::catalog::hydrate::{Catalog, CatalogEdgeLabel, CatalogKey, EdgeTarget};
use crate::integrity;
use crate::listing;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TargetState {
Resolved,
Unresolved,
#[serde(rename = "free_text")]
FreeText,
}
pub(crate) fn target_state(target: &EdgeTarget) -> TargetState {
match target {
EdgeTarget::Resolved(_) => TargetState::Resolved,
EdgeTarget::UnresolvedRef { .. } => TargetState::Unresolved,
EdgeTarget::UnvalidatedText { .. } => TargetState::FreeText,
}
}
pub(crate) fn target_display(target: &EdgeTarget) -> String {
match target {
EdgeTarget::Resolved(key) => key.canonical(),
EdgeTarget::UnresolvedRef { raw } | EdgeTarget::UnvalidatedText { raw } => raw.clone(),
}
}
pub(crate) fn source_kind(key: &CatalogKey) -> &str {
match key {
CatalogKey::Numbered(k) => k.prefix,
CatalogKey::Memory(_) => "MEM",
}
}
pub(crate) fn edge_label_display(edge: &crate::catalog::hydrate::CatalogEdge) -> String {
match edge.role {
Some(role) => format!("{}({})", edge.label.name(), role.name()),
None => edge.label.name().to_string(),
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ListFilter {
pub(crate) include_memory: bool,
pub(crate) label: Option<String>,
pub(crate) target: Option<String>,
pub(crate) source_kind: Option<String>,
pub(crate) unresolved: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RelationRow {
pub(crate) source: String,
pub(crate) label: String,
pub(crate) target: String,
pub(crate) state: TargetState,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CensusRow {
pub(crate) label: String,
pub(crate) count: usize,
pub(crate) resolved: usize,
pub(crate) unresolved: usize,
pub(crate) free_text: usize,
}
pub(crate) fn project_list(catalog: &Catalog, filter: &ListFilter) -> Vec<RelationRow> {
let normalised_target: Option<String> = filter.target.as_ref().map(|t| {
if let Ok((kref, id)) = integrity::parse_canonical_ref(t) {
listing::canonical_id(kref.kind.prefix, id)
} else {
t.clone()
}
});
let mut rows: Vec<RelationRow> = catalog
.edges
.iter()
.filter(|edge| {
if !filter.include_memory && matches!(edge.label, CatalogEdgeLabel::Raw(_)) {
return false;
}
if let Some(ref want) = filter.label
&& edge.label.name() != want.as_str()
{
return false;
}
if let Some(ref want) = filter.source_kind
&& source_kind(&edge.source).to_uppercase() != want.to_uppercase()
{
return false;
}
if let Some(ref want) = normalised_target
&& target_display(&edge.target) != *want
{
return false;
}
if filter.unresolved && target_state(&edge.target) == TargetState::Resolved {
return false;
}
true
})
.map(|edge| {
let source = edge.source.canonical();
let label = edge_label_display(edge);
let target = target_display(&edge.target);
let state = target_state(&edge.target);
RelationRow {
source,
label,
target,
state,
}
})
.collect();
rows.sort_by(|a, b| {
a.label
.cmp(&b.label)
.then_with(|| a.source.cmp(&b.source))
.then_with(|| a.target.cmp(&b.target))
});
rows
}
pub(crate) fn project_census(catalog: &Catalog, include_memory: bool) -> Vec<CensusRow> {
let mut groups: BTreeMap<String, CensusRow> = BTreeMap::new();
for edge in &catalog.edges {
if !include_memory && matches!(edge.label, CatalogEdgeLabel::Raw(_)) {
continue;
}
let label = edge_label_display(edge);
let entry = groups.entry(label).or_insert(CensusRow {
label: String::new(),
count: 0,
resolved: 0,
unresolved: 0,
free_text: 0,
});
entry.count = entry.count.wrapping_add(1);
match target_state(&edge.target) {
TargetState::Resolved => entry.resolved = entry.resolved.wrapping_add(1),
TargetState::Unresolved => entry.unresolved = entry.unresolved.wrapping_add(1),
TargetState::FreeText => entry.free_text = entry.free_text.wrapping_add(1),
}
}
let mut rows: Vec<CensusRow> = groups
.into_iter()
.map(|(lbl, mut row)| {
row.label = lbl;
row
})
.collect();
rows.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.label.cmp(&b.label)));
rows
}
fn state_name(state: TargetState) -> String {
match state {
TargetState::Resolved => "resolved",
TargetState::Unresolved => "unresolved",
TargetState::FreeText => "free_text",
}
.to_owned()
}
const RELATION_COLUMNS: [listing::Column<RelationRow>; 4] = [
listing::Column {
name: "source",
header: "source",
cell: |r| r.source.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "label",
header: "label",
cell: |r| r.label.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "target",
header: "target",
cell: |r| r.target.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "state",
header: "state",
cell: |r| state_name(r.state),
paint: listing::ColumnPaint::None,
},
];
const CENSUS_COLUMNS: [listing::Column<CensusRow>; 5] = [
listing::Column {
name: "label",
header: "label",
cell: |r| r.label.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "count",
header: "count",
cell: |r| r.count.to_string(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "resolved",
header: "resolved",
cell: |r| r.resolved.to_string(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "unresolved",
header: "unresolved",
cell: |r| r.unresolved.to_string(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "free_text",
header: "free_text",
cell: |r| r.free_text.to_string(),
paint: listing::ColumnPaint::None,
},
];
pub(crate) fn render_list(
rows: &[RelationRow],
format: listing::Format,
opts: listing::RenderOpts,
columns: Option<&[String]>,
) -> anyhow::Result<String> {
match format {
listing::Format::Json => render_list_json(rows, columns),
listing::Format::Table => render_list_table(rows, opts, columns),
}
}
pub(crate) fn render_census(
rows: &[CensusRow],
format: listing::Format,
opts: listing::RenderOpts,
columns: Option<&[String]>,
) -> anyhow::Result<String> {
match format {
listing::Format::Json => render_census_json(rows),
listing::Format::Table => render_census_table(rows, opts, columns),
}
}
fn render_list_table(
rows: &[RelationRow],
opts: listing::RenderOpts,
columns: Option<&[String]>,
) -> anyhow::Result<String> {
if rows.is_empty() {
return Ok(String::new());
}
let default: &[&str] = &["source", "label", "target", "state"];
let sel = listing::select_columns(&RELATION_COLUMNS, default, columns)?;
Ok(listing::render_columns(rows, &sel, opts))
}
fn render_census_table(
rows: &[CensusRow],
opts: listing::RenderOpts,
columns: Option<&[String]>,
) -> anyhow::Result<String> {
if rows.is_empty() {
return Ok(String::new());
}
let default: &[&str] = &["label", "count", "resolved", "unresolved", "free_text"];
let sel = listing::select_columns(&CENSUS_COLUMNS, default, columns)?;
Ok(listing::render_columns(rows, &sel, opts))
}
#[derive(Serialize)]
struct RelationJsonRow {
source: String,
label: String,
target: String,
state: TargetState,
}
#[derive(Serialize)]
struct CensusJsonRow {
label: String,
count: usize,
resolved: usize,
unresolved: usize,
free_text: usize,
}
fn render_list_json(rows: &[RelationRow], _columns: Option<&[String]>) -> anyhow::Result<String> {
let json_rows: Vec<RelationJsonRow> = rows
.iter()
.map(|r| RelationJsonRow {
source: r.source.clone(),
label: r.label.clone(),
target: r.target.clone(),
state: r.state,
})
.collect();
listing::json_envelope("relation", &json_rows)
}
fn render_census_json(rows: &[CensusRow]) -> anyhow::Result<String> {
let json_rows: Vec<CensusJsonRow> = rows
.iter()
.map(|r| CensusJsonRow {
label: r.label.clone(),
count: r.count,
resolved: r.resolved,
unresolved: r.unresolved,
free_text: r.free_text,
})
.collect();
listing::json_envelope("census", &json_rows)
}
#[cfg(test)]
#[expect(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
mod tests {
use super::*;
use crate::catalog::hydrate::Units;
use crate::catalog::scan::{EntityKey, ScannedEntity};
use crate::catalog::test_helpers::tmp;
fn test_units() -> Units {
Units {
estimation: "espresso_shots".to_string(),
value: "magic_beans".to_string(),
}
}
fn catalog_from(scanned: &[ScannedEntity]) -> Catalog {
let dir = tmp();
Catalog::from_scanned(dir.path(), scanned, &[], &BTreeMap::new(), test_units())
}
fn numbered_with_edge(
prefix: &'static str,
id: u32,
label: &str,
target: &str,
) -> ScannedEntity {
use crate::relation::{RelationLabel, Role};
let kind = crate::integrity::kind_by_prefix(prefix).unwrap().kind;
let (name, role) = match label
.strip_prefix("references(")
.and_then(|s| s.strip_suffix(')'))
{
Some(r) => ("references", Role::from_name(r)),
None => (label, None),
};
let rel_label = RelationLabel::from_name(name).unwrap_or_else(|| {
panic!("unknown relation label {label}");
});
ScannedEntity {
key: EntityKey { prefix, id },
kind,
status: Some("proposed".to_string()),
title: format!("{prefix}-{id}"),
outbound: vec![crate::relation::RelationEdge::with_role(
rel_label,
role,
target.to_string(),
)],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
}
}
fn two_entities(a: ScannedEntity, b: ScannedEntity) -> Catalog {
catalog_from(&[a, b])
}
#[test]
fn target_state_covers_all_variants() {
let ekey = EntityKey {
prefix: "SL",
id: 1,
};
let resolved = EdgeTarget::Resolved(CatalogKey::Numbered(ekey));
assert_eq!(target_state(&resolved), TargetState::Resolved);
assert_eq!(target_display(&resolved), "SL-001");
let unresolved = EdgeTarget::UnresolvedRef {
raw: "SL-999".to_string(),
};
assert_eq!(target_state(&unresolved), TargetState::Unresolved);
assert_eq!(target_display(&unresolved), "SL-999");
let free = EdgeTarget::UnvalidatedText {
raw: "loose talk".to_string(),
};
assert_eq!(target_state(&free), TargetState::FreeText);
assert_eq!(target_display(&free), "loose talk");
}
#[test]
fn source_kind_returns_prefix_or_mem() {
let num = CatalogKey::Numbered(EntityKey {
prefix: "SL",
id: 1,
});
assert_eq!(source_kind(&num), "SL");
let mem = CatalogKey::Memory("mem_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string());
assert_eq!(source_kind(&mem), "MEM");
}
#[test]
fn project_list_and_composes_four_axes_to_one_row() {
use crate::relation::{RelationEdge, RelationLabel, Role};
let kind = crate::integrity::kind_by_prefix("SL").unwrap().kind;
let req_kind = crate::integrity::kind_by_prefix("REQ").unwrap().kind;
let sl001 = ScannedEntity {
key: EntityKey {
prefix: "SL",
id: 1,
},
kind,
status: Some("proposed".to_string()),
title: "SL-001".to_string(),
outbound: vec![
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-005".to_string(),
),
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-999".to_string(),
),
],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let req005 = ScannedEntity {
key: EntityKey {
prefix: "REQ",
id: 5,
},
kind: req_kind,
status: Some("active".to_string()),
title: "REQ-005".to_string(),
outbound: vec![],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let catalog = two_entities(sl001, req005);
let rows = project_list(
&catalog,
&ListFilter {
include_memory: true,
label: Some("references".into()),
source_kind: Some("SL".into()),
target: Some("REQ-999".into()),
unresolved: true,
},
);
assert_eq!(
rows.len(),
1,
"four axes + include_memory narrows to one row"
);
assert_eq!(rows[0].source, "SL-001");
assert_eq!(rows[0].label, "references(implements)");
assert_eq!(rows[0].target, "REQ-999");
assert_eq!(rows[0].state, TargetState::Unresolved);
let all = project_list(
&catalog,
&ListFilter {
include_memory: true,
..Default::default()
},
);
assert_eq!(all.len(), 2, "both edges visible without --unresolved");
}
#[test]
fn include_memory_false_drops_raw_edges() {
use crate::catalog::hydrate::{CatalogEdge, CatalogEdgeLabel};
use std::path::PathBuf;
let sl001 = numbered_with_edge("SL", 1, "references(implements)", "REQ-001");
let mut catalog = catalog_from(&[sl001]);
catalog.edges.push(CatalogEdge {
source: CatalogKey::Memory("mem_user123".to_string()),
label: CatalogEdgeLabel::Raw("references".to_string()),
role: None,
descriptor: None,
target: EdgeTarget::UnvalidatedText {
raw: "some note".to_string(),
},
origin: crate::catalog::hydrate::EdgeOrigin {
file: PathBuf::new(),
field: None,
},
});
let rows = project_list(
&catalog,
&ListFilter {
source_kind: Some("MEM".into()),
..Default::default()
},
);
assert!(
rows.is_empty(),
"MEM source-kind without include_memory should be empty"
);
let rows = project_list(
&catalog,
&ListFilter {
include_memory: true,
source_kind: Some("MEM".into()),
..Default::default()
},
);
assert_eq!(
rows.len(),
1,
"MEM source-kind WITH include_memory should include the memory edge"
);
assert_eq!(rows[0].source, "mem_user123");
assert_eq!(rows[0].label, "references");
assert_eq!(rows[0].state, TargetState::FreeText);
let all = project_list(
&catalog,
&ListFilter {
include_memory: true,
..Default::default()
},
);
assert_eq!(
all.len(),
2,
"include_memory=true with no source-kind should show all edges"
);
let default = project_list(&catalog, &ListFilter::default());
assert_eq!(default.len(), 1, "default should show only numbered edges");
}
#[test]
fn unresolved_filter_keeps_only_non_resolved() {
use crate::relation::{RelationEdge, RelationLabel, Role};
let kind = crate::integrity::kind_by_prefix("SL").unwrap().kind;
let req_kind = crate::integrity::kind_by_prefix("REQ").unwrap().kind;
let sl001 = ScannedEntity {
key: EntityKey {
prefix: "SL",
id: 1,
},
kind,
status: Some("proposed".to_string()),
title: "SL-001".to_string(),
outbound: vec![
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-005".to_string(),
),
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-999".to_string(),
),
],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let req005 = ScannedEntity {
key: EntityKey {
prefix: "REQ",
id: 5,
},
kind: req_kind,
status: Some("active".to_string()),
title: "REQ-005".to_string(),
outbound: vec![],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let catalog = two_entities(sl001, req005);
let rows = project_list(
&catalog,
&ListFilter {
unresolved: true,
..Default::default()
},
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target, "REQ-999");
assert_eq!(rows[0].state, TargetState::Unresolved);
}
#[test]
fn project_list_sorts_by_label_source_target() {
use crate::relation::{RelationEdge, RelationLabel, Role};
let kind = crate::integrity::kind_by_prefix("SL").unwrap().kind;
let sl001 = ScannedEntity {
key: EntityKey {
prefix: "SL",
id: 1,
},
kind,
status: Some("proposed".to_string()),
title: "SL-001".to_string(),
outbound: vec![RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"PRD-002".to_string(),
)],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let sl002 = ScannedEntity {
key: EntityKey {
prefix: "SL",
id: 2,
},
kind,
status: Some("proposed".to_string()),
title: "SL-002".to_string(),
outbound: vec![RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"PRD-001".to_string(),
)],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let catalog = two_entities(sl001, sl002);
let rows = project_list(&catalog, &ListFilter::default());
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].source, "SL-001");
assert_eq!(rows[1].source, "SL-002");
}
#[test]
fn render_list_empty_is_empty_string() {
let out = render_list(
&[],
listing::Format::Table,
listing::RenderOpts::default(),
None,
)
.unwrap();
assert_eq!(out, "");
}
#[test]
fn census_tallies_honour_breakdown_and_sort() {
use crate::relation::{RelationEdge, RelationLabel, Role};
let kind = crate::integrity::kind_by_prefix("SL").unwrap().kind;
let req_kind = crate::integrity::kind_by_prefix("REQ").unwrap().kind;
let sl001 = ScannedEntity {
key: EntityKey {
prefix: "SL",
id: 1,
},
kind,
status: Some("proposed".to_string()),
title: "SL-001".to_string(),
outbound: vec![
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-005".to_string(),
),
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Implements),
"REQ-999".to_string(),
),
RelationEdge::new(RelationLabel::Drift, "some free text".to_string()),
],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let req005 = ScannedEntity {
key: EntityKey {
prefix: "REQ",
id: 5,
},
kind: req_kind,
status: Some("active".to_string()),
title: "REQ-005".to_string(),
outbound: vec![],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let catalog = two_entities(sl001, req005);
let rows = project_census(&catalog, false);
assert_eq!(rows.len(), 2);
let req_row = &rows[0];
assert_eq!(req_row.label, "references(implements)");
assert_eq!(req_row.count, 2);
assert_eq!(req_row.resolved, 1);
assert_eq!(req_row.unresolved, 1);
assert_eq!(req_row.free_text, 0);
assert_eq!(
req_row.count,
req_row.resolved + req_row.unresolved + req_row.free_text
);
let drift_row = &rows[1];
assert_eq!(drift_row.label, "drift");
assert_eq!(drift_row.count, 1);
assert_eq!(drift_row.resolved, 0);
assert_eq!(drift_row.unresolved, 0);
assert_eq!(drift_row.free_text, 1);
}
#[test]
fn census_include_memory_drops_raw_labels() {
let sl001 = numbered_with_edge("SL", 1, "references(implements)", "REQ-001");
let catalog = catalog_from(&[sl001]);
let rows = project_census(&catalog, false);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "references(implements)");
let rows2 = project_census(&catalog, true);
assert_eq!(rows2.len(), 1);
}
#[test]
fn list_json_shape_matches_envelope_contract() {
let row = RelationRow {
source: "SL-001".to_string(),
label: "references(implements)".to_string(),
target: "REQ-005".to_string(),
state: TargetState::Resolved,
};
let json = render_list(
&[row],
listing::Format::Json,
listing::RenderOpts::default(),
None,
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"].as_str(), Some("relation"));
let rows = &v["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r["source"].as_str(), Some("SL-001"));
assert_eq!(r["label"].as_str(), Some("references(implements)"));
assert_eq!(r["target"].as_str(), Some("REQ-005"));
assert_eq!(r["state"].as_str(), Some("resolved"));
}
#[test]
fn census_json_shape_matches_envelope_contract() {
let row = CensusRow {
label: "references(implements)".to_string(),
count: 2,
resolved: 1,
unresolved: 1,
free_text: 0,
};
let json = render_census(
&[row],
listing::Format::Json,
listing::RenderOpts::default(),
None,
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"].as_str(), Some("census"));
let rows = &v["rows"].as_array().unwrap();
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r["label"].as_str(), Some("references(implements)"));
assert_eq!(r["count"].as_u64(), Some(2));
assert_eq!(r["resolved"].as_u64(), Some(1));
assert_eq!(r["unresolved"].as_u64(), Some(1));
assert_eq!(r["free_text"].as_u64(), Some(0));
}
#[test]
fn target_filter_normalizes_canonical_ref() {
use crate::relation::{RelationEdge, RelationLabel};
let adr_kind = crate::integrity::kind_by_prefix("ADR").unwrap().kind;
let adr001 = ScannedEntity {
key: EntityKey {
prefix: "ADR",
id: 1,
},
kind: adr_kind,
status: Some("accepted".to_string()),
title: "ADR-001".to_string(),
outbound: vec![RelationEdge::new(
RelationLabel::Supersedes,
"ADR-002".to_string(),
)],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let adr002 = ScannedEntity {
key: EntityKey {
prefix: "ADR",
id: 2,
},
kind: adr_kind,
status: Some("accepted".to_string()),
title: "ADR-002".to_string(),
outbound: vec![],
estimate: None,
value: None,
risk: None,
tags: vec![],
body: None,
};
let catalog = two_entities(adr001, adr002);
let rows = project_list(
&catalog,
&ListFilter {
target: Some("ADR-2".into()),
..Default::default()
},
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target, "ADR-002");
assert_eq!(rows[0].state, TargetState::Resolved);
let rows = project_list(
&catalog,
&ListFilter {
target: Some("ADR-002".into()),
..Default::default()
},
);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].target, "ADR-002");
}
}