use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, Utc};
use crate::knowledge::symbols::SymbolRow;
use crate::release::depdag::{state_str, ReleaseDepDag};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
Constellation,
Repo,
Crate,
Module,
Code,
}
impl Level {
pub fn as_str(self) -> &'static str {
match self {
Level::Constellation => "constellation",
Level::Repo => "repo",
Level::Crate => "crate",
Level::Module => "module",
Level::Code => "code",
}
}
pub fn depth(self) -> u8 {
match self {
Level::Constellation => 0,
Level::Repo => 1,
Level::Crate => 2,
Level::Module => 3,
Level::Code => 4,
}
}
}
pub const ROOT_ID: &str = "constellation";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Recency {
pub commits_30d: i64,
pub commits_90d: i64,
pub commits_total: i64,
pub last_commit_ts: Option<DateTime<Utc>>,
}
impl Recency {
fn absorb(&mut self, o: &Recency) {
self.commits_30d += o.commits_30d;
self.commits_90d += o.commits_90d;
self.commits_total += o.commits_total;
if let Some(t) = o.last_commit_ts {
self.last_commit_ts = Some(self.last_commit_ts.map_or(t, |cur| cur.max(t)));
}
}
fn is_cold_30d(&self) -> bool {
self.commits_30d == 0
}
}
#[derive(Debug, Clone)]
pub struct FileHeat {
pub repo: String,
pub file: String,
pub commits_30d: i64,
pub commits_90d: i64,
pub commits_total: i64,
pub last_commit_ts: Option<DateTime<Utc>>,
}
impl FileHeat {
fn recency(&self) -> Recency {
Recency {
commits_30d: self.commits_30d,
commits_90d: self.commits_90d,
commits_total: self.commits_total,
last_commit_ts: self.last_commit_ts,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BugAnchor {
pub repo: String,
pub file: Option<String>,
pub line: Option<u32>,
pub entry: String,
}
#[must_use]
pub fn parse_bug_anchors(repo: &str, markdown: &str) -> Vec<BugAnchor> {
let mut out: Vec<BugAnchor> = Vec::new();
let mut header = format!("{repo} problems");
let mut seen_in_section: BTreeSet<(Option<String>, Option<u32>)> = BTreeSet::new();
let mut section_had_file = false;
let flush_bare = |out: &mut Vec<BugAnchor>, header: &str, had_file: bool| {
if !had_file {
out.push(BugAnchor { repo: repo.to_string(), file: None, line: None, entry: header.to_string() });
}
};
for line in markdown.lines() {
let trimmed = line.trim_start();
if let Some(h) = trimmed.strip_prefix("## ") {
flush_bare(&mut out, &header, section_had_file);
header = h.trim().to_string();
seen_in_section.clear();
section_had_file = false;
continue;
}
for (file, ln) in scan_src_paths(line) {
let key = (Some(file.clone()), ln);
if seen_in_section.insert(key) {
section_had_file = true;
out.push(BugAnchor { repo: repo.to_string(), file: Some(file), line: ln, entry: header.clone() });
}
}
}
flush_bare(&mut out, &header, section_had_file);
out
}
fn scan_src_paths(line: &str) -> Vec<(String, Option<u32>)> {
let mut out = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
while i + 4 <= bytes.len() {
if &line[i..i + 4] == "src/" {
let mut j = i + 4;
while j < bytes.len() {
let c = bytes[j] as char;
if c.is_whitespace() || matches!(c, '`' | '(' | ')' | '[' | ']' | '"' | '\'' | ',' | ';' | '<' | '>' | '{' | '}') {
break;
}
j += 1;
}
let tok = &line[i..j];
if let Some((file, ln)) = split_path_line(tok) {
out.push((file, ln));
}
i = j;
} else {
i += 1;
}
}
out
}
fn split_path_line(tok: &str) -> Option<(String, Option<u32>)> {
let tok = tok.trim_end_matches(['.', ':', ')', '`']);
if let Some((path, line)) = tok.rsplit_once(':') {
if path.ends_with(".rs") {
return Some((path.to_string(), line.parse().ok()));
}
}
if tok.ends_with(".rs") {
return Some((tok.to_string(), None));
}
None
}
pub fn load_bug_anchors_dir(dir: &std::path::Path) -> Vec<BugAnchor> {
const SUFFIX: &str = "_agents_problems.md";
let mut out: Vec<BugAnchor> = Vec::new();
let Ok(rd) = std::fs::read_dir(dir) else { return out };
let mut entries: Vec<std::path::PathBuf> = rd.flatten().map(|e| e.path()).collect();
entries.sort();
for path in entries {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue };
let Some(repo) = name.strip_suffix(SUFFIX) else { continue };
if repo.is_empty() {
continue;
}
if let Ok(text) = std::fs::read_to_string(&path) {
out.extend(parse_bug_anchors(repo, &text));
}
}
out.sort_by(|a, b| (&a.repo, &a.entry, &a.file).cmp(&(&b.repo, &b.entry, &b.file)));
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeKind {
Contains,
CrateDep,
RepoDep,
}
impl EdgeKind {
pub fn as_str(self) -> &'static str {
match self {
EdgeKind::Contains => "contains",
EdgeKind::CrateDep => "crate_dep",
EdgeKind::RepoDep => "repo_dep",
}
}
}
#[derive(Debug, Clone)]
pub struct NavNode {
pub id: String,
pub label: String,
pub level: Level,
pub parent: Option<String>,
pub repo: String,
pub crate_name: String,
pub file: Option<String>,
pub line: Option<u32>,
pub recency: Recency,
pub bug_anchors: Vec<BugAnchor>,
pub wave: Option<usize>,
pub registry_state: Option<String>,
pub blast: usize,
pub bottleneck_rank: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct NavEdge {
pub from: String,
pub to: String,
pub kind: EdgeKind,
}
#[derive(Debug, Clone)]
pub enum Travel {
LatestChanges,
BugsReported,
ReleaseWave,
BlastRadius { crate_name: String },
Bottleneck,
DeepestChain,
}
impl Travel {
pub fn as_str(&self) -> &'static str {
match self {
Travel::LatestChanges => "latest_changes",
Travel::BugsReported => "bugs_reported",
Travel::ReleaseWave => "release_wave",
Travel::BlastRadius { .. } => "blast_radius",
Travel::Bottleneck => "bottleneck",
Travel::DeepestChain => "deepest_chain",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NavigatorModel {
pub nodes: Vec<NavNode>,
pub edges: Vec<NavEdge>,
index: BTreeMap<String, usize>,
release_order: Vec<String>,
crate_node: BTreeMap<String, String>,
}
fn crate_id(name: &str) -> String {
format!("crate:{name}")
}
fn repo_id(name: &str) -> String {
format!("repo:{name}")
}
impl NavigatorModel {
#[must_use]
pub fn build(
dag: &ReleaseDepDag,
symbols: &[SymbolRow],
heat: &[FileHeat],
anchors: &[BugAnchor],
) -> Self {
let mut m = NavigatorModel::default();
m.push(NavNode {
id: ROOT_ID.to_string(),
label: "constellation".to_string(),
level: Level::Constellation,
parent: None,
repo: String::new(),
crate_name: String::new(),
file: None,
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
let repos: BTreeSet<String> = dag.nodes.iter().map(|n| n.repo.clone()).collect();
for repo in &repos {
if repo.is_empty() {
continue;
}
m.push(NavNode {
id: repo_id(repo),
label: repo.clone(),
level: Level::Repo,
parent: Some(ROOT_ID.to_string()),
repo: repo.clone(),
crate_name: String::new(),
file: None,
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
m.edges.push(NavEdge { from: ROOT_ID.to_string(), to: repo_id(repo), kind: EdgeKind::Contains });
}
let blast = downstream_blast(dag);
for cn in &dag.nodes {
let id = crate_id(&cn.name);
let parent = if cn.repo.is_empty() { ROOT_ID.to_string() } else { repo_id(&cn.repo) };
m.crate_node.insert(cn.name.clone(), id.clone());
m.push(NavNode {
id: id.clone(),
label: cn.name.clone(),
level: Level::Crate,
parent: Some(parent.clone()),
repo: cn.repo.clone(),
crate_name: cn.name.clone(),
file: None,
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: cn.wave,
registry_state: Some(state_str(cn.state).to_string()),
blast: blast.get(&cn.name).copied().unwrap_or(0),
bottleneck_rank: None,
});
m.edges.push(NavEdge { from: parent, to: id, kind: EdgeKind::Contains });
}
m.release_order = dag.topo_order();
let mut ranked: Vec<(&String, usize)> =
dag.nodes.iter().map(|n| (&n.name, blast.get(&n.name).copied().unwrap_or(0))).collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (rank, (name, _)) in ranked.iter().enumerate() {
if let Some(&i) = m.crate_node.get(*name).and_then(|id| m.index.get(id)) {
m.nodes[i].bottleneck_rank = Some(rank + 1);
}
}
let owner: BTreeMap<&str, &str> =
dag.nodes.iter().map(|n| (n.name.as_str(), n.repo.as_str())).collect();
let mut repo_edges: BTreeSet<(String, String)> = BTreeSet::new();
for e in &dag.edges {
if m.crate_node.contains_key(&e.from) && m.crate_node.contains_key(&e.to) {
m.edges.push(NavEdge { from: crate_id(&e.from), to: crate_id(&e.to), kind: EdgeKind::CrateDep });
}
if let (Some(&fr), Some(&tr)) = (owner.get(e.from.as_str()), owner.get(e.to.as_str())) {
if fr != tr && !fr.is_empty() && !tr.is_empty() {
repo_edges.insert((fr.to_string(), tr.to_string()));
}
}
}
for (fr, tr) in repo_edges {
m.edges.push(NavEdge { from: repo_id(&fr), to: repo_id(&tr), kind: EdgeKind::RepoDep });
}
m.attach_symbols(symbols);
m.attach_recency(heat, symbols);
m.attach_anchors(anchors);
m
}
fn push(&mut self, node: NavNode) {
let idx = self.nodes.len();
self.index.insert(node.id.clone(), idx);
self.nodes.push(node);
}
fn attach_symbols(&mut self, symbols: &[SymbolRow]) {
let mut modules: BTreeMap<(String, String), (String, Option<String>)> = BTreeMap::new();
for s in symbols {
if !self.crate_node.contains_key(&s.crate_name) {
continue;
}
let mp = if s.module_path.is_empty() { s.crate_name.clone() } else { s.module_path.clone() };
modules
.entry((s.crate_name.clone(), mp))
.or_insert_with(|| (s.file.clone(), Some(s.file.clone())));
}
for ((cn, mp), (file, _)) in &modules {
let id = module_id(cn, mp);
if self.index.contains_key(&id) {
continue;
}
let parent = crate_id(cn);
let repo = self.repo_of_crate(cn);
self.push(NavNode {
id: id.clone(),
label: last_seg(mp).to_string(),
level: Level::Module,
parent: Some(parent.clone()),
repo,
crate_name: cn.clone(),
file: Some(file.clone()),
line: None,
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
self.edges.push(NavEdge { from: parent, to: id, kind: EdgeKind::Contains });
}
for s in symbols {
if !self.crate_node.contains_key(&s.crate_name) {
continue;
}
let mp = if s.module_path.is_empty() { s.crate_name.clone() } else { s.module_path.clone() };
let mod_nid = module_id(&s.crate_name, &mp);
let id = code_id(&s.crate_name, &s.file, s.line, &s.item_name);
if self.index.contains_key(&id) {
continue;
}
let repo = self.repo_of_crate(&s.crate_name);
self.push(NavNode {
id: id.clone(),
label: s.item_name.clone(),
level: Level::Code,
parent: Some(mod_nid.clone()),
repo,
crate_name: s.crate_name.clone(),
file: Some(s.file.clone()),
line: Some(s.line),
recency: Recency::default(),
bug_anchors: Vec::new(),
wave: None,
registry_state: None,
blast: 0,
bottleneck_rank: None,
});
self.edges.push(NavEdge { from: mod_nid, to: id, kind: EdgeKind::Contains });
}
}
fn attach_recency(&mut self, heat: &[FileHeat], symbols: &[SymbolRow]) {
let mut by_file: BTreeMap<(String, String), Recency> = BTreeMap::new();
for h in heat {
by_file
.entry((h.repo.clone(), h.file.clone()))
.or_default()
.absorb(&h.recency());
}
let lookup = |repo: &str, file: &str| -> Option<Recency> {
by_file.get(&(repo.to_string(), file.to_string())).cloned().or_else(|| {
by_file
.iter()
.find(|((r, f), _)| r == repo && (f.ends_with(file) || file.ends_with(f.as_str())))
.map(|(_, v)| v.clone())
})
};
for i in 0..self.nodes.len() {
if matches!(self.nodes[i].level, Level::Module | Level::Code) {
let (repo, file) = (self.nodes[i].repo.clone(), self.nodes[i].file.clone());
if let Some(file) = file {
if let Some(r) = lookup(&repo, &file) {
self.nodes[i].recency = r;
}
}
}
}
let mut crate_files: BTreeMap<String, BTreeSet<(String, String)>> = BTreeMap::new();
for s in symbols {
if self.crate_node.contains_key(&s.crate_name) {
let repo = self.repo_of_crate(&s.crate_name);
crate_files.entry(s.crate_name.clone()).or_default().insert((repo, s.file.clone()));
}
}
let mut repo_roll: BTreeMap<String, Recency> = BTreeMap::new();
for (cn, files) in &crate_files {
let mut roll = Recency::default();
for (repo, file) in files {
if let Some(r) = lookup(repo, file) {
roll.absorb(&r);
}
}
if let Some(&i) = self.crate_node.get(cn).and_then(|id| self.index.get(id)) {
self.nodes[i].recency = roll.clone();
let repo = self.nodes[i].repo.clone();
if !repo.is_empty() {
repo_roll.entry(repo).or_default().absorb(&roll);
}
}
}
for (repo, roll) in repo_roll {
if let Some(&i) = self.index.get(&repo_id(&repo)) {
self.nodes[i].recency = roll;
}
}
}
fn attach_anchors(&mut self, anchors: &[BugAnchor]) {
for a in anchors {
let target = self.resolve_anchor(a);
if let Some(&i) = self.index.get(&target) {
self.nodes[i].bug_anchors.push(a.clone());
}
let rid = repo_id(&a.repo);
if target != rid {
if let Some(&ri) = self.index.get(&rid) {
self.nodes[ri].bug_anchors.push(a.clone());
}
}
}
}
fn resolve_anchor(&self, a: &BugAnchor) -> String {
if let Some(file) = &a.file {
let mut best_code: Option<(&str, u32)> = None; let mut file_module: Option<&str> = None;
let mut file_crate: Option<&str> = None;
for n in &self.nodes {
if n.repo != a.repo {
continue;
}
let matches_file = n.file.as_deref().map(|f| f.ends_with(file.as_str()) || file.ends_with(f)).unwrap_or(false);
if !matches_file {
continue;
}
match n.level {
Level::Code => {
if let (Some(l), Some(al)) = (n.line, a.line) {
if l == al {
return n.id.clone(); }
}
if best_code.is_none() {
best_code = Some((&n.id, n.line.unwrap_or(0)));
}
}
Level::Module => {
if file_module.is_none() {
file_module = Some(&n.id);
}
file_crate = Some(self.crate_node.get(&n.crate_name).map(String::as_str).unwrap_or(""));
}
_ => {}
}
}
if let Some(m) = file_module {
return m.to_string();
}
if let Some((c, _)) = best_code {
return c.to_string();
}
if let Some(c) = file_crate {
if !c.is_empty() {
return c.to_string();
}
}
}
let rid = repo_id(&a.repo);
if self.index.contains_key(&rid) {
return rid;
}
ROOT_ID.to_string()
}
fn repo_of_crate(&self, cn: &str) -> String {
self.crate_node
.get(cn)
.and_then(|id| self.index.get(id))
.map(|&i| self.nodes[i].repo.clone())
.unwrap_or_default()
}
pub fn node(&self, id: &str) -> Option<&NavNode> {
self.index.get(id).map(|&i| &self.nodes[i])
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn edge_count(&self) -> usize {
self.edges.len()
}
pub fn has_edge(&self, from: &str, to: &str, kind: EdgeKind) -> bool {
self.edges.iter().any(|e| e.from == from && e.to == to && e.kind == kind)
}
pub fn release_order(&self) -> &[String] {
&self.release_order
}
#[must_use]
pub fn travel(&self, t: &Travel) -> Vec<String> {
match t {
Travel::LatestChanges => {
let mut crates: Vec<&NavNode> = self
.nodes
.iter()
.filter(|n| n.level == Level::Crate && !n.recency.is_cold_30d())
.collect();
crates.sort_by(|a, b| {
b.recency
.commits_30d
.cmp(&a.recency.commits_30d)
.then(b.recency.last_commit_ts.cmp(&a.recency.last_commit_ts))
.then(a.label.cmp(&b.label))
});
crates.into_iter().map(|n| n.id.clone()).collect()
}
Travel::BugsReported => {
let mut out = vec![ROOT_ID.to_string()];
let mut targets: Vec<&NavNode> = self
.nodes
.iter()
.filter(|n| !n.bug_anchors.is_empty() && n.level != Level::Repo)
.collect();
targets.sort_by(|a, b| a.level.depth().cmp(&b.level.depth()).then(a.id.cmp(&b.id)));
for n in targets {
let rid = repo_id(&n.repo);
if !out.contains(&rid) && self.index.contains_key(&rid) {
out.push(rid);
}
out.push(n.id.clone());
}
out
}
Travel::ReleaseWave => self.release_order.iter().map(|c| crate_id(c)).collect(),
Travel::Bottleneck => {
let mut crates: Vec<&NavNode> =
self.nodes.iter().filter(|n| n.bottleneck_rank.is_some()).collect();
crates.sort_by_key(|n| n.bottleneck_rank.unwrap_or(usize::MAX));
crates.into_iter().map(|n| n.id.clone()).collect()
}
Travel::BlastRadius { crate_name } => {
let mut out = Vec::new();
if let Some(id) = self.crate_node.get(crate_name) {
out.push(id.clone());
}
for dep in self.dependents_of(crate_name) {
if let Some(id) = self.crate_node.get(&dep) {
out.push(id.clone());
}
}
out
}
Travel::DeepestChain => {
self.deepest_chain().into_iter().map(|c| crate_id(&c)).collect()
}
}
}
fn dependents_of(&self, crate_name: &str) -> Vec<String> {
let mut rev: BTreeMap<String, Vec<String>> = BTreeMap::new();
for e in &self.edges {
if e.kind == EdgeKind::CrateDep {
let (from, to) = (strip_crate(&e.from), strip_crate(&e.to));
rev.entry(to).or_default().push(from);
}
}
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut stack = vec![crate_name.to_string()];
while let Some(c) = stack.pop() {
if let Some(deps) = rev.get(&c) {
for d in deps {
if seen.insert(d.clone()) {
stack.push(d.clone());
}
}
}
}
let mut out: Vec<String> = seen.into_iter().collect();
out.sort_by(|a, b| {
let wa = self.node(&crate_id(a)).and_then(|n| n.wave).unwrap_or(usize::MAX);
let wb = self.node(&crate_id(b)).and_then(|n| n.wave).unwrap_or(usize::MAX);
wa.cmp(&wb).then(a.cmp(b))
});
out
}
fn deepest_chain(&self) -> Vec<String> {
let mut adj: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut nodes: BTreeSet<String> = BTreeSet::new();
for e in &self.edges {
if e.kind == EdgeKind::CrateDep {
let (from, to) = (strip_crate(&e.from), strip_crate(&e.to));
adj.entry(from.clone()).or_default().push(to.clone());
nodes.insert(from);
nodes.insert(to);
}
}
let mut best: BTreeMap<String, Vec<String>> = BTreeMap::new();
fn longest(
n: &str,
adj: &BTreeMap<String, Vec<String>>,
best: &mut BTreeMap<String, Vec<String>>,
visiting: &mut BTreeSet<String>,
) -> Vec<String> {
if let Some(v) = best.get(n) {
return v.clone();
}
if !visiting.insert(n.to_string()) {
return vec![n.to_string()]; }
let mut longest_tail: Vec<String> = Vec::new();
if let Some(succ) = adj.get(n) {
for s in succ {
let tail = longest(s, adj, best, visiting);
if tail.len() > longest_tail.len() {
longest_tail = tail;
}
}
}
visiting.remove(n);
let mut path = vec![n.to_string()];
path.extend(longest_tail);
best.insert(n.to_string(), path.clone());
path
}
let mut visiting = BTreeSet::new();
let mut winner: Vec<String> = Vec::new();
for n in &nodes {
let p = longest(n, &adj, &mut best, &mut visiting);
if p.len() > winner.len() {
winner = p;
}
}
winner
}
#[must_use]
pub fn state_json(&self) -> serde_json::Value {
let mut level_counts: BTreeMap<&'static str, usize> = BTreeMap::new();
for n in &self.nodes {
*level_counts.entry(n.level.as_str()).or_default() += 1;
}
let mut edge_kinds: BTreeMap<&'static str, usize> = BTreeMap::new();
for e in &self.edges {
*edge_kinds.entry(e.kind.as_str()).or_default() += 1;
}
serde_json::json!({
"node_count": self.node_count(),
"edge_count": self.edge_count(),
"level_counts": level_counts,
"edge_kinds": edge_kinds,
"release_order": self.release_order,
"nodes": self.nodes.iter().map(|n| serde_json::json!({
"id": n.id,
"label": n.label,
"level": n.level.as_str(),
"parent": n.parent,
"repo": n.repo,
"crate": n.crate_name,
"file": n.file,
"line": n.line,
"wave": n.wave,
"registry_state": n.registry_state,
"blast": n.blast,
"bottleneck_rank": n.bottleneck_rank,
"recency": {
"commits_30d": n.recency.commits_30d,
"commits_90d": n.recency.commits_90d,
"commits_total": n.recency.commits_total,
"last_commit_ts": n.recency.last_commit_ts.map(|t| t.to_rfc3339()),
},
"bug_anchors": n.bug_anchors.iter().map(|b| serde_json::json!({
"file": b.file, "line": b.line, "entry": b.entry,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"edges": self.edges.iter().map(|e| serde_json::json!({
"from": e.from, "to": e.to, "kind": e.kind.as_str(),
})).collect::<Vec<_>>(),
"travels": {
"latest_changes": self.travel(&Travel::LatestChanges),
"bugs_reported": self.travel(&Travel::BugsReported),
"release_wave": self.travel(&Travel::ReleaseWave),
"bottleneck": self.travel(&Travel::Bottleneck),
"deepest_chain": self.travel(&Travel::DeepestChain),
},
})
}
}
fn downstream_blast(dag: &ReleaseDepDag) -> BTreeMap<String, usize> {
let mut rev: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for e in &dag.edges {
rev.entry(e.to.as_str()).or_default().push(e.from.as_str());
}
let mut out = BTreeMap::new();
for n in &dag.nodes {
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack = vec![n.name.as_str()];
while let Some(c) = stack.pop() {
if let Some(deps) = rev.get(c) {
for d in deps {
if seen.insert(d) {
stack.push(d);
}
}
}
}
out.insert(n.name.clone(), seen.len());
}
out
}
fn last_seg(s: &str) -> &str {
s.rsplit(['/', ':']).next().unwrap_or(s)
}
fn module_id(crate_name: &str, module_path: &str) -> String {
format!("mod:{crate_name}:{module_path}")
}
fn code_id(crate_name: &str, file: &str, line: u32, item: &str) -> String {
format!("code:{crate_name}:{file}:{line}:{item}")
}
fn strip_crate(id: &str) -> String {
id.strip_prefix("crate:").unwrap_or(id).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::doctor::RepoGraph;
fn chain_dag() -> ReleaseDepDag {
let mut a = RepoGraph { repo: "A".into(), ..Default::default() };
a.crate_deps.entry("app".into()).or_default().insert("lib".into());
let mut b = RepoGraph { repo: "B".into(), ..Default::default() };
b.crate_deps.entry("lib".into()).or_default().insert("base".into());
let mut c = RepoGraph { repo: "C".into(), ..Default::default() };
c.crate_deps.entry("base".into()).or_default();
ReleaseDepDag::assemble(&[a, b, c], &BTreeMap::new(), &BTreeMap::new())
}
fn sym(crate_name: &str, module_path: &str, item: &str, file: &str, line: u32) -> SymbolRow {
SymbolRow {
crate_name: crate_name.into(),
module_path: module_path.into(),
item_kind: "fn".into(),
item_name: item.into(),
visibility: "pub".into(),
file: file.into(),
line,
doc_lines: 0,
signature: None,
}
}
#[test]
fn builds_the_five_level_hierarchy_with_hull_edges() {
let dag = chain_dag();
let symbols = vec![sym("app", "app::run", "run", "src/run.rs", 10)];
let m = NavigatorModel::build(&dag, &symbols, &[], &[]);
assert!(m.node(ROOT_ID).is_some());
assert_eq!(m.node(ROOT_ID).unwrap().level, Level::Constellation);
assert_eq!(m.node("repo:A").unwrap().level, Level::Repo);
assert_eq!(m.node("crate:app").unwrap().level, Level::Crate);
assert_eq!(m.node("crate:app").unwrap().parent.as_deref(), Some("repo:A"));
assert_eq!(m.node("repo:A").unwrap().parent.as_deref(), Some(ROOT_ID));
assert!(m.has_edge(ROOT_ID, "repo:A", EdgeKind::Contains));
assert!(m.has_edge("repo:A", "crate:app", EdgeKind::Contains));
let code = m.nodes.iter().find(|n| n.level == Level::Code).unwrap();
assert_eq!(code.label, "run");
assert_eq!(code.crate_name, "app");
assert_eq!(code.line, Some(10));
}
#[test]
fn known_dep_edges_exist_at_crate_and_repo_level() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
assert!(m.has_edge("crate:app", "crate:lib", EdgeKind::CrateDep), "app depends on lib");
assert!(m.has_edge("crate:lib", "crate:base", EdgeKind::CrateDep), "lib depends on base");
assert!(m.has_edge("repo:A", "repo:B", EdgeKind::RepoDep), "repo A → B");
assert!(m.has_edge("repo:B", "repo:C", EdgeKind::RepoDep), "repo B → C");
assert!(!m.has_edge("crate:base", "crate:app", EdgeKind::CrateDep));
}
#[test]
fn seeded_bug_anchor_resolves_to_its_code_node() {
let dag = chain_dag();
let symbols = vec![
sym("app", "app::run", "run", "src/run.rs", 10),
sym("app", "app::run", "helper", "src/run.rs", 42),
];
let anchor = BugAnchor {
repo: "A".into(),
file: Some("src/run.rs".into()),
line: Some(10),
entry: "2026-07-14 — boom".into(),
};
let m = NavigatorModel::build(&dag, &symbols, &[], &[anchor.clone()]);
let run = m.nodes.iter().find(|n| n.level == Level::Code && n.line == Some(10)).unwrap();
assert_eq!(run.bug_anchors.len(), 1, "anchor resolved to the run() code node");
assert_eq!(run.bug_anchors[0].entry, "2026-07-14 — boom");
let helper = m.nodes.iter().find(|n| n.level == Level::Code && n.line == Some(42)).unwrap();
assert!(helper.bug_anchors.is_empty());
assert_eq!(m.node("repo:A").unwrap().bug_anchors.len(), 1);
}
#[test]
fn parse_bug_anchors_extracts_file_and_line_tokens() {
let md = "\
# nornir problems
## 2026-07-14 — populate arc
`WarehouseSink::flush` (`src/deepscan.rs:317`) calls the async twin at `src/warehouse/iceberg.rs:2333`.
## 2026-07-13 — no file here
Just prose, nothing pinned.
";
let anchors = parse_bug_anchors("nornir", md);
let files: Vec<(Option<&str>, Option<u32>)> =
anchors.iter().map(|a| (a.file.as_deref(), a.line)).collect();
assert!(files.contains(&(Some("src/deepscan.rs"), Some(317))), "{files:?}");
assert!(files.contains(&(Some("src/warehouse/iceberg.rs"), Some(2333))), "{files:?}");
assert!(anchors.iter().any(|a| a.file.is_none() && a.entry.starts_with("2026-07-13")));
assert!(anchors.iter().any(|a| a.entry.starts_with("2026-07-14")));
}
#[test]
fn load_bug_anchors_dir_reads_sister_files_and_derives_repo() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("nornir_agents_problems.md"),
"## 2026-07-14 — boom\nbug in `src/deepscan.rs:317`.\n",
)
.unwrap();
std::fs::write(
dir.path().join("facett_agents_problems.md"),
"## 2026-07-13 — paint\ncrash in `src/graph.rs:9`.\n",
)
.unwrap();
std::fs::write(dir.path().join("README.md"), "not a problems file").unwrap();
let anchors = load_bug_anchors_dir(dir.path());
assert!(anchors.iter().any(|a|
a.repo == "nornir" && a.file.as_deref() == Some("src/deepscan.rs") && a.line == Some(317)));
assert!(anchors.iter().any(|a|
a.repo == "facett" && a.file.as_deref() == Some("src/graph.rs") && a.line == Some(9)));
assert!(anchors.iter().all(|a| a.repo == "nornir" || a.repo == "facett"), "README skipped");
}
#[test]
fn release_wave_travel_is_deps_first() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
assert_eq!(m.release_order(), &["base".to_string(), "lib".to_string(), "app".to_string()]);
let tour = m.travel(&Travel::ReleaseWave);
assert_eq!(tour, vec!["crate:base", "crate:lib", "crate:app"]);
}
#[test]
fn blast_and_bottleneck_rank_the_base_crate_highest() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
assert_eq!(m.node("crate:base").unwrap().blast, 2);
assert_eq!(m.node("crate:lib").unwrap().blast, 1);
assert_eq!(m.node("crate:app").unwrap().blast, 0);
assert_eq!(m.node("crate:base").unwrap().bottleneck_rank, Some(1));
assert_eq!(m.travel(&Travel::Bottleneck).first().map(String::as_str), Some("crate:base"));
let tour = m.travel(&Travel::BlastRadius { crate_name: "base".into() });
assert_eq!(tour[0], "crate:base");
assert!(tour.contains(&"crate:lib".to_string()));
assert!(tour.contains(&"crate:app".to_string()));
}
#[test]
fn deepest_chain_is_the_full_transitive_path() {
let dag = chain_dag();
let m = NavigatorModel::build(&dag, &[], &[], &[]);
assert_eq!(m.travel(&Travel::DeepestChain), vec!["crate:app", "crate:lib", "crate:base"]);
}
#[test]
fn latest_changes_travel_ranks_by_recency_and_drops_cold_crates() {
let dag = chain_dag();
let symbols = vec![
sym("app", "app::run", "run", "src/run.rs", 10),
sym("lib", "lib::l", "l", "src/l.rs", 1),
sym("base", "base::b", "b", "src/b.rs", 1),
];
let heat = vec![
FileHeat { repo: "A".into(), file: "src/run.rs".into(), commits_30d: 5, commits_90d: 5, commits_total: 9, last_commit_ts: None },
FileHeat { repo: "B".into(), file: "src/l.rs".into(), commits_30d: 1, commits_90d: 2, commits_total: 3, last_commit_ts: None },
FileHeat { repo: "C".into(), file: "src/b.rs".into(), commits_30d: 0, commits_90d: 0, commits_total: 4, last_commit_ts: None },
];
let m = NavigatorModel::build(&dag, &symbols, &heat, &[]);
assert_eq!(m.node("crate:app").unwrap().recency.commits_30d, 5);
assert_eq!(m.node("crate:base").unwrap().recency.commits_30d, 0);
let tour = m.travel(&Travel::LatestChanges);
assert_eq!(tour, vec!["crate:app", "crate:lib"]);
assert_eq!(m.node("repo:A").unwrap().recency.commits_30d, 5);
}
#[test]
fn state_json_exposes_levels_edges_and_travels_for_facett() {
let dag = chain_dag();
let symbols = vec![sym("app", "app::run", "run", "src/run.rs", 10)];
let m = NavigatorModel::build(&dag, &symbols, &[], &[]);
let j = m.state_json();
assert_eq!(j["level_counts"]["constellation"], 1);
assert_eq!(j["level_counts"]["crate"], 3);
assert_eq!(j["edge_kinds"]["crate_dep"], 2);
assert_eq!(j["release_order"][0], "base");
assert_eq!(j["travels"]["release_wave"][0], "crate:base");
let base = j["nodes"].as_array().unwrap().iter().find(|n| n["id"] == "crate:base").unwrap();
assert_eq!(base["blast"], 2);
assert_eq!(base["bottleneck_rank"], 1);
assert_eq!(base["registry_state"], "unknown");
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(
"viz/navigator (warehouse constellation model)",
"navigator_model_built",
true,
&format!(
"nodes={} edges={} crates={} release_order={:?}",
m.node_count(),
m.edge_count(),
j["level_counts"]["crate"],
m.release_order(),
),
);
}
}