use std::path::Path;
use crate::relation_graph::{self, EntityKey};
use super::channels;
use super::graph::{self, NodeAttr, PriorityGraph};
use super::partition::{StatusClass, status_class};
use super::view::{
Actionability, ActionabilityBlock, ActionabilityEdge, ActionabilityNode, ActionabilityView,
BlockersView, Explanation, NextRow, ReasonKind, SurveyRow,
};
fn attr(g: &PriorityGraph, key: EntityKey) -> Option<&NodeAttr> {
g.attrs.get(&key)
}
fn kind_of(g: &PriorityGraph, key: EntityKey) -> String {
attr(g, key).map_or_else(|| key.prefix.to_string(), |a| a.kind.prefix.to_string())
}
fn title_of(g: &PriorityGraph, key: EntityKey) -> String {
attr(g, key).map_or_else(|| key.canonical(), |a| a.title.clone())
}
fn status_of(g: &PriorityGraph, key: EntityKey) -> String {
attr(g, key)
.and_then(|a| a.status.clone())
.unwrap_or_else(|| "—".to_string())
}
fn class_of(g: &PriorityGraph, key: EntityKey) -> StatusClass {
match attr(g, key) {
Some(a) => status_class(a.kind, a.status.as_deref()),
None => StatusClass::Unrecognised,
}
}
fn eligibility_reason(g: &PriorityGraph, key: EntityKey) -> ReasonKind {
ReasonKind::Eligibility {
status: attr(g, key).and_then(|a| a.status.clone()),
class: class_of(g, key),
}
}
fn refs(keys: &[EntityKey]) -> Vec<String> {
keys.iter().map(|k| k.canonical()).collect()
}
fn actionability(g: &PriorityGraph, key: EntityKey) -> Actionability {
if channels::blocked(g, key) {
Actionability::Blocked
} else {
Actionability::Actionable
}
}
struct SurveyDecorated {
key: EntityKey,
act: Actionability,
consequence: u32,
blockers: Vec<String>,
}
fn act_rank(a: Actionability) -> u8 {
match a {
Actionability::Actionable => 0,
Actionability::Blocked => 1,
}
}
pub(crate) fn survey_for_map(g: &PriorityGraph, all: bool) -> Vec<SurveyRow> {
let mut rows: Vec<SurveyDecorated> = g
.attrs
.keys()
.copied()
.filter(|&k| {
if all {
return true;
}
channels::eligible(g, k) && !channels::promoted(g, k)
})
.map(|k| SurveyDecorated {
key: k,
act: actionability(g, k),
consequence: channels::consequence(g, k),
blockers: refs(&channels::blocked_by(g, k)),
})
.collect();
rows.sort_by(|a, b| {
let act = act_rank(a.act).cmp(&act_rank(b.act));
let cons = b.consequence.cmp(&a.consequence); act.then(cons).then_with(|| a.key.cmp(&b.key))
});
rows.into_iter()
.map(|d| {
let mut reasons = vec![eligibility_reason(g, d.key)];
if !d.blockers.is_empty() {
reasons.push(ReasonKind::BlockedBy {
items: d.blockers.clone(),
});
}
reasons.push(ReasonKind::Consequence {
inbound: d.consequence,
});
SurveyRow {
id: d.key.canonical(),
title: title_of(g, d.key),
kind: kind_of(g, d.key),
status: status_of(g, d.key),
act: d.act,
consequence: d.consequence,
blockers: d.blockers,
reasons,
}
})
.collect()
}
pub(crate) fn survey(root: &Path, all: bool) -> anyhow::Result<Vec<SurveyRow>> {
let g = graph::build(root)?;
Ok(survey_for_map(&g, all))
}
pub(crate) fn survey_view_for_map(g: &PriorityGraph, all: bool) -> ActionabilityView {
use std::collections::{BTreeMap, BTreeSet, VecDeque};
const WORK_PREFIXES: &[&str] = &["SL", "ISS", "IMP", "CHR", "RSK", "IDE"];
let rows: Vec<_> = survey_for_map(g, all)
.into_iter()
.filter(|r| {
WORK_PREFIXES.contains(&r.kind.as_str())
})
.collect();
let key_by_id: BTreeMap<String, EntityKey> = rows
.iter()
.filter_map(|r| parse_key(&r.id).ok().map(|k| (r.id.clone(), k)))
.collect();
let node_keys: BTreeSet<EntityKey> = key_by_id.values().copied().collect();
let mut blockers_of: BTreeMap<EntityKey, Vec<EntityKey>> = BTreeMap::new();
let mut dependents_of: BTreeMap<EntityKey, Vec<EntityKey>> = BTreeMap::new();
let mut indeg: BTreeMap<EntityKey, usize> = BTreeMap::new();
for &k in &node_keys {
let blockers: Vec<EntityKey> = channels::blocked_by(g, k)
.into_iter()
.filter(|b| node_keys.contains(b))
.collect();
indeg.insert(k, blockers.len());
for &b in &blockers {
dependents_of.entry(b).or_default().push(k);
}
blockers_of.insert(k, blockers);
}
let mut ranks: BTreeMap<EntityKey, u32> = BTreeMap::new();
let mut queue: VecDeque<EntityKey> = indeg
.iter()
.filter(|(_, d)| **d == 0)
.map(|(&k, _)| k)
.collect();
while let Some(k) = queue.pop_front() {
let rank = blockers_of.get(&k).map_or(0, |bs| {
bs.iter()
.filter_map(|b| ranks.get(b))
.max()
.map_or(0, |r| r + 1)
});
ranks.insert(k, rank);
if let Some(deps) = dependents_of.get(&k) {
for &dep in deps {
if let Some(d) = indeg.get_mut(&dep) {
*d -= 1;
if *d == 0 {
queue.push_back(dep);
}
}
}
}
}
for &k in &node_keys {
if !ranks.contains_key(&k) {
let rank = blockers_of.get(&k).map_or(0, |bs| {
bs.iter()
.filter_map(|b| ranks.get(b))
.max()
.map_or(0, |r| r + 1)
});
ranks.insert(k, rank);
}
}
let mut edges: Vec<ActionabilityEdge> = Vec::new();
for &k in &node_keys {
for blocker in &channels::blocked_by(g, k) {
if node_keys.contains(blocker) {
edges.push(ActionabilityEdge {
source: blocker.canonical(),
target: k.canonical(),
kind: "needs".into(),
});
}
}
}
for &k in &node_keys {
if let Some(n) = g.projection.resolve(k) {
for (pred, _) in g.graph.in_edges(g.seq_overlay, n) {
if let Some(pred_key) = g.projection.key_of(pred)
&& node_keys.contains(&pred_key)
{
edges.push(ActionabilityEdge {
source: pred_key.canonical(),
target: k.canonical(),
kind: "after".into(),
});
}
}
}
}
let nodes: Vec<ActionabilityNode> = rows
.into_iter()
.filter_map(|r| {
let k = parse_key(&r.id).ok()?;
let rank = ranks.get(&k).copied().unwrap_or(0);
let actionability = match r.act {
Actionability::Actionable => "actionable",
Actionability::Blocked => "blocked",
};
Some(ActionabilityNode {
id: r.id,
title: r.title,
kind: r.kind,
status: r.status,
actionability: actionability.into(),
consequence: r.consequence,
rank,
blockers: r.blockers,
})
})
.collect();
ActionabilityView {
kind: "actionability_graph".into(),
policy_version: "priority.v2".into(),
nodes,
edges,
}
}
pub(crate) fn next(root: &Path) -> anyhow::Result<Vec<NextRow>> {
let g = graph::build(root)?;
let order = channels::order_key(&g);
let rows = order
.into_iter()
.filter(|&k| channels::actionable(&g, k) && !channels::promoted(&g, k))
.map(|k| {
let blocking = refs(&channels::blocking(&g, k));
let mut reasons = vec![eligibility_reason(&g, k)];
if !blocking.is_empty() {
reasons.push(ReasonKind::Blocking {
items: blocking.clone(),
});
}
NextRow {
id: k.canonical(),
title: title_of(&g, k),
kind: kind_of(&g, k),
status: status_of(&g, k),
act: Actionability::Actionable,
reasons,
blockers: Vec::new(),
blocking,
}
})
.collect();
Ok(rows)
}
fn parse_key(id: &str) -> anyhow::Result<EntityKey> {
let (kref, qid) = crate::integrity::parse_canonical_ref(id)?;
Ok(EntityKey {
prefix: kref.kind.prefix,
id: qid,
})
}
pub(crate) fn blockers(root: &Path, id: &str, transitive: bool) -> anyhow::Result<BlockersView> {
let key = parse_key(id)?;
let g = graph::build(root)?;
relation_graph::require_minted(&g.projection, key)?;
let (blocked_by, blocking) = if transitive {
(
channels::blocked_by_transitive(&g, key),
channels::blocking_transitive(&g, key),
)
} else {
(channels::blocked_by(&g, key), channels::blocking(&g, key))
};
Ok(BlockersView {
id: key.canonical(),
transitive,
blocked_by: refs(&blocked_by),
blocking: refs(&blocking),
})
}
pub(crate) fn explain(root: &Path, id: &str) -> anyhow::Result<Explanation> {
let key = parse_key(id)?;
let g = graph::build(root)?;
relation_graph::require_minted(&g.projection, key)?;
let eligibility = eligibility_reason(&g, key);
let chain = channels::blocked_by_transitive(&g, key);
let blocker_chain = if chain.is_empty() {
Vec::new()
} else {
vec![ReasonKind::BlockedBy {
items: refs(&chain),
}]
};
let evictions = channels::evicted_seq_edges(&g, key)
.into_iter()
.map(|(from, to, reason)| ReasonKind::EvictedEdge {
from: from.canonical(),
to: to.canonical(),
reason,
})
.collect();
let cycle = channels::dep_cycles(&g)
.into_iter()
.find(|c| c.contains(&key));
let consequence = ReasonKind::Consequence {
inbound: channels::consequence(&g, key),
};
let mut blocker_chain = blocker_chain;
if let Some(component) = cycle {
let nodes = component.into_iter().map(EntityKey::canonical).collect();
blocker_chain.push(ReasonKind::CycleDegraded { nodes });
}
Ok(Explanation {
id: key.canonical(),
eligibility,
blocker_chain,
evictions,
consequence,
})
}
pub(crate) fn actionability_block_from(
scanned: &[relation_graph::ScannedEntity],
root: &Path,
id: &str,
) -> anyhow::Result<ActionabilityBlock> {
let key = parse_key(id)?;
let g = graph::build_from(scanned, root)?;
relation_graph::require_minted(&g.projection, key)?;
Ok(ActionabilityBlock {
eligible: channels::eligible(&g, key),
actionable: channels::actionable(&g, key),
blockers: refs(&channels::blocked_by(&g, key)),
blocking: refs(&channels::blocking(&g, key)),
consequence: channels::consequence(&g, key),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use crate::priority::graph::build;
fn write(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
fn tmp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn seed_issue(root: &Path, id: u32, status: &str, resolution: &str, axes: &[(&str, &[&str])]) {
let rels = crate::relation::rels_block(&crate::backlog::ISSUE_KIND, axes);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.toml"),
&format!(
"id = {id}\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"{status}\"\n\
resolution = \"{resolution}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
{rels}"
),
);
write(
root,
&format!(".doctrine/backlog/issue/{id:03}/backlog-{id:03}.md"),
"b\n",
);
}
#[test]
fn survey_rank_topological_chain_a_to_b_to_c() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-002"])]);
seed_issue(root, 2, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 3, "open", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
let n1 = view.nodes.iter().find(|n| n.id == "ISS-001").unwrap();
let n2 = view.nodes.iter().find(|n| n.id == "ISS-002").unwrap();
let n3 = view.nodes.iter().find(|n| n.id == "ISS-003").unwrap();
assert_eq!(n3.rank, 0, "ISS-003 has no blockers → rank 0");
assert_eq!(n2.rank, 1, "ISS-002 blocked by ISS-003 (rank 0) → rank 1");
assert_eq!(n1.rank, 2, "ISS-001 blocked by ISS-002 (rank 1) → rank 2");
}
#[test]
fn survey_needs_edges_present() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-002"])]);
seed_issue(root, 2, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 3, "open", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert!(
view.edges
.iter()
.any(|e| e.source == "ISS-003" && e.target == "ISS-002" && e.kind == "needs")
);
assert!(
view.edges
.iter()
.any(|e| e.source == "ISS-002" && e.target == "ISS-001" && e.kind == "needs")
);
}
#[test]
fn survey_after_edges_present() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 2, "open", "", &[]);
write(
root,
".doctrine/backlog/issue/001/backlog-001.toml",
"id = 1\nslug = \"i\"\ntitle = \"I\"\nkind = \"issue\"\nstatus = \"open\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nafter = [{ to = \"ISS-002\", rank = 0 }]\n",
);
write(root, ".doctrine/backlog/issue/001/backlog-001.md", "b\n");
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert!(
view.edges
.iter()
.any(|e| e.source == "ISS-002" && e.target == "ISS-001" && e.kind == "after")
);
}
#[test]
fn survey_empty_graph() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "closed", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert!(view.nodes.is_empty());
assert!(view.edges.is_empty());
}
#[test]
fn survey_excludes_terminal() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[]);
seed_issue(root, 2, "closed", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert_eq!(view.nodes.len(), 1, "only the eligible (open) node");
assert_eq!(view.nodes[0].id, "ISS-001");
assert!(view.nodes.iter().all(|n| n.id != "ISS-002"));
}
#[test]
fn survey_terminal_blocker_no_edge() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-002"])]);
seed_issue(root, 2, "closed", "", &[]);
let g = build(root).unwrap();
let view = survey_view_for_map(&g, false);
assert_eq!(view.nodes.len(), 1);
let n1 = &view.nodes[0];
assert_eq!(n1.id, "ISS-001");
assert!(view.edges.is_empty(), "terminal → eligible edge suppressed");
assert_eq!(n1.actionability, "actionable");
assert_eq!(n1.rank, 0);
}
#[test]
fn survey_for_map_matches_survey_byte_for_byte() {
let dir = tmp();
let root = dir.path();
seed_issue(root, 1, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 2, "open", "", &[("needs", &["ISS-003"])]);
seed_issue(root, 3, "open", "", &[]);
let g = build(root).unwrap();
let from_survey = survey(root, false).unwrap();
let from_for_map = survey_for_map(&g, false);
assert_eq!(
from_survey, from_for_map,
"survey_for_map must match survey output exactly"
);
}
}