use std::collections::{HashMap, HashSet, VecDeque};
use crate::core::graph_analysis::edge_confidence;
use crate::core::graph_index;
use crate::core::graph_provider::{self, EdgeInfo};
use crate::core::protocol::shorten_path;
use crate::core::tokens::count_tokens;
struct NeighborRef {
node: String,
kind: String,
weight: f64,
}
struct Adj {
nodes: Vec<String>,
node_set: HashSet<String>,
out: HashMap<String, Vec<NeighborRef>>,
inc: HashMap<String, Vec<NeighborRef>>,
}
impl Adj {
fn build(edges: &[EdgeInfo], file_paths: &[String]) -> Self {
let mut out: HashMap<String, Vec<NeighborRef>> = HashMap::new();
let mut inc: HashMap<String, Vec<NeighborRef>> = HashMap::new();
let mut node_set: HashSet<String> = HashSet::new();
for p in file_paths {
node_set.insert(p.clone());
}
for e in edges {
node_set.insert(e.from.clone());
node_set.insert(e.to.clone());
out.entry(e.from.clone()).or_default().push(NeighborRef {
node: e.to.clone(),
kind: e.kind.clone(),
weight: e.weight,
});
inc.entry(e.to.clone()).or_default().push(NeighborRef {
node: e.from.clone(),
kind: e.kind.clone(),
weight: e.weight,
});
}
let mut nodes: Vec<String> = node_set.iter().cloned().collect();
nodes.sort();
Self {
nodes,
node_set,
out,
inc,
}
}
fn resolve(&self, input: &str, root: &str) -> Result<String, String> {
let rel = graph_index::graph_relative_key(input, root);
if self.node_set.contains(&rel) {
return Ok(rel);
}
let needle = graph_index::graph_match_key(&rel);
if self.node_set.contains(&needle) {
return Ok(needle);
}
let base = needle.rsplit('/').next().unwrap_or(&needle).to_string();
let suffix = format!("/{needle}");
let base_suffix = format!("/{base}");
let cands: Vec<&String> = self
.nodes
.iter()
.filter(|n| {
let nk = graph_index::graph_match_key(n);
nk == needle || nk.ends_with(&suffix) || nk == base || nk.ends_with(&base_suffix)
})
.collect();
match cands.len() {
0 => Err(format!(
"Node not found in graph: {input}\nRun ctx_graph action='build' to (re)index, or pass a path that exists in the project."
)),
1 => Ok(cands[0].clone()),
_ => {
let list = cands
.iter()
.take(10)
.map(|c| format!(" {c}"))
.collect::<Vec<_>>()
.join("\n");
let more = if cands.len() > 10 {
format!("\n … and {} more", cands.len() - 10)
} else {
String::new()
};
Err(format!(
"'{input}' is ambiguous ({} matches) — pass a more specific path:\n{list}{more}",
cands.len()
))
}
}
}
fn outgoing(&self, node: &str) -> Vec<&NeighborRef> {
let mut v: Vec<&NeighborRef> = self
.out
.get(node)
.map(|x| x.iter().collect())
.unwrap_or_default();
v.sort_by(|a, b| a.node.cmp(&b.node).then_with(|| a.kind.cmp(&b.kind)));
v
}
fn incoming(&self, node: &str) -> Vec<&NeighborRef> {
let mut v: Vec<&NeighborRef> = self
.inc
.get(node)
.map(|x| x.iter().collect())
.unwrap_or_default();
v.sort_by(|a, b| a.node.cmp(&b.node).then_with(|| a.kind.cmp(&b.kind)));
v
}
fn undirected_neighbors(&self, node: &str) -> Vec<String> {
let mut set: HashSet<&str> = HashSet::new();
if let Some(v) = self.out.get(node) {
for nb in v {
set.insert(nb.node.as_str());
}
}
if let Some(v) = self.inc.get(node) {
for nb in v {
set.insert(nb.node.as_str());
}
}
let mut out: Vec<String> = set.into_iter().map(str::to_string).collect();
out.sort();
out
}
fn edge_between(&self, a: &str, b: &str) -> Option<(Direction, String, f64)> {
let mut best: Option<(Direction, String, f64)> = None;
let mut consider = |dir: Direction, kind: &str, weight: f64| {
let conf = edge_confidence(kind, weight);
if best.as_ref().is_none_or(|(_, _, c)| conf > *c) {
best = Some((dir, kind.to_string(), conf));
}
};
if let Some(v) = self.out.get(a) {
for nb in v.iter().filter(|nb| nb.node == b) {
consider(Direction::Forward, &nb.kind, nb.weight);
}
}
if let Some(v) = self.inc.get(a) {
for nb in v.iter().filter(|nb| nb.node == b) {
consider(Direction::Backward, &nb.kind, nb.weight);
}
}
best
}
fn bfs_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
if from == to {
return Some(vec![from.to_string()]);
}
let mut prev: HashMap<String, String> = HashMap::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
visited.insert(from.to_string());
queue.push_back(from.to_string());
while let Some(cur) = queue.pop_front() {
for nb in self.undirected_neighbors(&cur) {
if visited.contains(&nb) {
continue;
}
visited.insert(nb.clone());
prev.insert(nb.clone(), cur.clone());
if nb == to {
return Some(reconstruct(&prev, from, to));
}
queue.push_back(nb);
}
}
None
}
fn bfs_rings(&self, start: &str, max_depth: usize) -> Vec<(usize, Vec<String>)> {
let mut dist: HashMap<String, usize> = HashMap::new();
let mut queue: VecDeque<String> = VecDeque::new();
dist.insert(start.to_string(), 0);
queue.push_back(start.to_string());
while let Some(cur) = queue.pop_front() {
let d = dist[&cur];
if d >= max_depth {
continue;
}
for nb in self.undirected_neighbors(&cur) {
if !dist.contains_key(&nb) {
dist.insert(nb.clone(), d + 1);
queue.push_back(nb);
}
}
}
let mut rings: HashMap<usize, Vec<String>> = HashMap::new();
for (node, d) in dist {
if d == 0 {
continue;
}
rings.entry(d).or_default().push(node);
}
let mut out: Vec<(usize, Vec<String>)> = rings
.into_iter()
.map(|(d, mut nodes)| {
nodes.sort();
(d, nodes)
})
.collect();
out.sort_by_key(|(d, _)| *d);
out
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Direction {
Forward,
Backward,
}
impl Direction {
fn arrow(self) -> &'static str {
match self {
Direction::Forward => "->",
Direction::Backward => "<-",
}
}
}
fn reconstruct(prev: &HashMap<String, String>, from: &str, to: &str) -> Vec<String> {
let mut chain = vec![to.to_string()];
let mut cur = to.to_string();
while cur != from {
match prev.get(&cur) {
Some(p) => {
chain.push(p.clone());
cur = p.clone();
}
None => break,
}
}
chain.reverse();
chain
}
fn open_graph(root: &str) -> Result<graph_provider::OpenGraphProvider, String> {
graph_provider::open_or_build(root)
.ok_or_else(|| "No graph index found. Run ctx_graph with action='build' first.".to_string())
}
fn is_json(format: Option<&str>) -> bool {
matches!(format, Some(f) if f.eq_ignore_ascii_case("json"))
}
pub fn neighbors(
path: Option<&str>,
root: &str,
depth: Option<usize>,
format: Option<&str>,
) -> String {
let Some(input) = path else {
return "path is required for 'neighbors' action".to_string();
};
let open = match open_graph(root) {
Ok(o) => o,
Err(e) => return e,
};
let gp = &open.provider;
let adj = Adj::build(&gp.edges(), &gp.file_paths());
let node = match adj.resolve(input, root) {
Ok(n) => n,
Err(e) => return e,
};
let depth = depth.unwrap_or(1).clamp(1, 6);
let outgoing = adj.outgoing(&node);
let incoming = adj.incoming(&node);
let rings = if depth > 1 {
adj.bfs_rings(&node, depth)
} else {
Vec::new()
};
if is_json(format) {
let out_json: Vec<_> = outgoing
.iter()
.map(|n| {
serde_json::json!({
"node": n.node,
"kind": n.kind,
"confidence": round3(edge_confidence(&n.kind, n.weight)),
})
})
.collect();
let in_json: Vec<_> = incoming
.iter()
.map(|n| {
serde_json::json!({
"node": n.node,
"kind": n.kind,
"confidence": round3(edge_confidence(&n.kind, n.weight)),
})
})
.collect();
let rings_json: Vec<_> = rings
.iter()
.map(|(d, nodes)| serde_json::json!({ "distance": d, "count": nodes.len(), "nodes": nodes }))
.collect();
let val = serde_json::json!({
"node": node,
"outgoing": out_json,
"incoming": in_json,
"rings": rings_json,
});
return serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
}
let mut out = format!("Neighbors of {}\n", shorten_path(&node));
out.push_str(&format!(
"\nOutgoing ({}) — this file depends on / references:\n",
outgoing.len()
));
if outgoing.is_empty() {
out.push_str(" (none)\n");
} else {
for n in &outgoing {
out.push_str(&format!(
" -> {:<48} {:<10} conf {:.2}\n",
shorten_path(&n.node),
n.kind,
edge_confidence(&n.kind, n.weight)
));
}
}
out.push_str(&format!(
"\nIncoming ({}) — files that depend on / reference this:\n",
incoming.len()
));
if incoming.is_empty() {
out.push_str(" (none)\n");
} else {
for n in &incoming {
out.push_str(&format!(
" <- {:<48} {:<10} conf {:.2}\n",
shorten_path(&n.node),
n.kind,
edge_confidence(&n.kind, n.weight)
));
}
}
if depth > 1 {
let total: usize = rings.iter().map(|(_, n)| n.len()).sum();
out.push_str(&format!("\nReachable within {depth} hops: {total} nodes\n"));
for (d, nodes) in &rings {
out.push_str(&format!(" {} hop(s): {} nodes\n", d, nodes.len()));
}
}
let tokens = count_tokens(&out);
format!("{out}[ctx_graph neighbors: {tokens} tok]")
}
pub fn shortest_path(
from: Option<&str>,
to: Option<&str>,
root: &str,
format: Option<&str>,
) -> String {
let (Some(a), Some(b)) = (from, to) else {
return "Both 'path' (from) and 'to' are required for 'path' action".to_string();
};
let open = match open_graph(root) {
Ok(o) => o,
Err(e) => return e,
};
let gp = &open.provider;
let adj = Adj::build(&gp.edges(), &gp.file_paths());
let na = match adj.resolve(a, root) {
Ok(n) => n,
Err(e) => return e,
};
let nb = match adj.resolve(b, root) {
Ok(n) => n,
Err(e) => return e,
};
let Some(chain) = adj.bfs_path(&na, &nb) else {
if is_json(format) {
return serde_json::json!({
"from": na, "to": nb, "connected": false, "path": [],
})
.to_string();
}
return format!(
"No path between {} and {} — they live in different components of the dependency graph.",
shorten_path(&na),
shorten_path(&nb)
);
};
let hops = chain.len().saturating_sub(1);
if is_json(format) {
let steps: Vec<_> = chain
.windows(2)
.map(|w| {
let (dir, kind, conf) = adj.edge_between(&w[0], &w[1]).unwrap_or((
Direction::Forward,
"related".to_string(),
0.5,
));
serde_json::json!({
"from": w[0],
"to": w[1],
"direction": if dir == Direction::Forward { "forward" } else { "backward" },
"kind": kind,
"confidence": round3(conf),
})
})
.collect();
let val = serde_json::json!({
"from": na, "to": nb, "connected": true, "hops": hops,
"path": chain, "steps": steps,
});
return serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
}
let mut out = format!(
"Shortest path {} -> {} ({} hops):\n\n",
shorten_path(&na),
shorten_path(&nb),
hops
);
out.push_str(&format!(" {}\n", shorten_path(&chain[0])));
for w in chain.windows(2) {
let (dir, kind, conf) = adj.edge_between(&w[0], &w[1]).unwrap_or((
Direction::Forward,
"related".to_string(),
0.5,
));
out.push_str(&format!(
" {} {} (conf {:.2})\n {}\n",
dir.arrow(),
kind,
conf,
shorten_path(&w[1])
));
}
let tokens = count_tokens(&out);
format!("{out}[ctx_graph path: {tokens} tok]")
}
pub fn explain(path: Option<&str>, root: &str, format: Option<&str>) -> String {
let Some(input) = path else {
return "path is required for 'explain' action".to_string();
};
let open = match open_graph(root) {
Ok(o) => o,
Err(e) => return e,
};
let gp = &open.provider;
let edges = gp.edges();
let adj = Adj::build(&edges, &gp.file_paths());
let node = match adj.resolve(input, root) {
Ok(n) => n,
Err(e) => return e,
};
let community = crate::core::community::detect_communities_for_provider(gp, root);
let community_map = community.assignment_min_size(2);
let god = crate::core::graph_analysis::compute_god_nodes(&edges, usize::MAX);
let bridges = crate::core::graph_analysis::compute_bridge_nodes(&edges, usize::MAX);
let surprising = crate::core::graph_analysis::find_surprising_connections(
&edges,
&community_map,
usize::MAX,
);
let god_entry = god.iter().enumerate().find(|(_, g)| g.path == node);
let (dep_in, dep_out, dep_degree) =
god_entry.map_or((0, 0, 0), |(_, g)| (g.in_degree, g.out_degree, g.degree));
let god_rank = god_entry.map(|(i, _)| i + 1);
let bridge_entry = bridges.iter().enumerate().find(|(_, b)| b.path == node);
let community_id = community_map.get(&node).copied();
let community_info =
community_id.and_then(|id| community.communities.iter().find(|c| c.id == id));
let surprising_here: Vec<_> = surprising
.iter()
.filter(|s| s.from == node || s.to == node)
.take(8)
.collect();
let out_all = adj.outgoing(&node);
let inc_all = adj.incoming(&node);
if is_json(format) {
let val = serde_json::json!({
"node": node,
"dependency_degree": { "in": dep_in, "out": dep_out, "total": dep_degree },
"god_node_rank": god_rank,
"is_god_node": god_rank.is_some_and(|r| r <= 12),
"bridge": bridge_entry.map(|(i, b)| serde_json::json!({
"rank": i + 1, "betweenness": round3(b.betweenness),
})),
"community": community_info.map(|c| serde_json::json!({
"id": c.id, "files": c.files.len(),
"cohesion": round3(c.cohesion),
"internal_edges": c.internal_edges, "external_edges": c.external_edges,
})),
"neighbors_all_kinds": { "out": out_all.len(), "in": inc_all.len() },
"surprising_connections": surprising_here.iter().map(|s| serde_json::json!({
"from": s.from, "to": s.to, "score": round3(s.score),
"cross_community": s.cross_community,
})).collect::<Vec<_>>(),
});
return serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
}
let mut out = format!("Why {} matters\n\n", shorten_path(&node));
out.push_str(&format!(
"Dependency degree: {dep_degree} (in {dep_in} · out {dep_out})\n"
));
match god_rank {
Some(r) if r <= 12 => {
out.push_str(&format!("God-node: yes — rank #{r} (most connected)\n"));
}
Some(r) => out.push_str(&format!("God-node rank: #{r}\n")),
None => out.push_str("God-node: no dependency edges\n"),
}
match bridge_entry {
Some((i, b)) => out.push_str(&format!(
"Bridge (betweenness): {:.2} — rank #{} (sits on many shortest paths)\n",
b.betweenness,
i + 1
)),
None => out.push_str("Bridge: not on critical paths\n"),
}
match community_info {
Some(c) => out.push_str(&format!(
"Community: #{} — {} files, cohesion {:.2} (internal {} / external {})\n",
c.id,
c.files.len(),
c.cohesion,
c.internal_edges,
c.external_edges
)),
None => out.push_str("Community: isolated (no module ≥2 files)\n"),
}
out.push_str(&format!(
"Total neighbors (all edge kinds): {} (out {} · in {})\n",
out_all.len() + inc_all.len(),
out_all.len(),
inc_all.len()
));
let top_dependents: Vec<&String> = inc_all
.iter()
.filter(|n| crate::core::graph_analysis::is_dependency_kind(&n.kind))
.map(|n| &n.node)
.take(8)
.collect();
if !top_dependents.is_empty() {
out.push_str(&format!(
"\nTop dependents (fan-in, {}):\n",
top_dependents.len()
));
for d in &top_dependents {
out.push_str(&format!(" {}\n", shorten_path(d)));
}
}
let top_deps: Vec<&String> = out_all
.iter()
.filter(|n| crate::core::graph_analysis::is_dependency_kind(&n.kind))
.map(|n| &n.node)
.take(8)
.collect();
if !top_deps.is_empty() {
out.push_str(&format!(
"\nTop dependencies (fan-out, {}):\n",
top_deps.len()
));
for d in &top_deps {
out.push_str(&format!(" {}\n", shorten_path(d)));
}
}
if !surprising_here.is_empty() {
out.push_str(&format!(
"\nSurprising connections ({}):\n",
surprising_here.len()
));
for s in &surprising_here {
let other = if s.from == node { &s.to } else { &s.from };
out.push_str(&format!(
" {} (score {:.2}{})\n",
shorten_path(other),
s.score,
if s.cross_community {
", cross-community"
} else {
""
}
));
}
}
let tokens = count_tokens(&out);
format!("{out}[ctx_graph explain: {tokens} tok]")
}
fn round3(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
#[cfg(test)]
mod tests {
use super::*;
fn edge(from: &str, to: &str, kind: &str) -> EdgeInfo {
EdgeInfo {
from: from.into(),
to: to.into(),
kind: kind.into(),
weight: 1.0,
}
}
fn sample() -> Adj {
let edges = vec![
edge("src/a.rs", "src/b.rs", "import"),
edge("src/b.rs", "src/c.rs", "import"),
];
let files = vec![
"src/a.rs".to_string(),
"src/b.rs".to_string(),
"src/c.rs".to_string(),
"src/d.rs".to_string(),
];
Adj::build(&edges, &files)
}
#[test]
fn resolve_exact_and_basename() {
let adj = sample();
assert_eq!(adj.resolve("src/a.rs", "/proj").unwrap(), "src/a.rs");
assert_eq!(adj.resolve("c.rs", "/proj").unwrap(), "src/c.rs");
}
#[test]
fn resolve_unknown_errors() {
let adj = sample();
assert!(adj.resolve("nope.rs", "/proj").is_err());
}
#[test]
fn resolve_ambiguous_errors() {
let edges = vec![edge("a/mod.rs", "b/mod.rs", "import")];
let files = vec!["a/mod.rs".to_string(), "b/mod.rs".to_string()];
let adj = Adj::build(&edges, &files);
let err = adj.resolve("mod.rs", "/proj").unwrap_err();
assert!(err.contains("ambiguous"), "got: {err}");
}
#[test]
fn bfs_path_finds_shortest_chain() {
let adj = sample();
let path = adj.bfs_path("src/a.rs", "src/c.rs").unwrap();
assert_eq!(path, vec!["src/a.rs", "src/b.rs", "src/c.rs"]);
}
#[test]
fn bfs_path_is_undirected() {
let adj = sample();
let path = adj.bfs_path("src/c.rs", "src/a.rs").unwrap();
assert_eq!(path, vec!["src/c.rs", "src/b.rs", "src/a.rs"]);
}
#[test]
fn bfs_path_none_when_disconnected() {
let adj = sample();
assert!(adj.bfs_path("src/a.rs", "src/d.rs").is_none());
}
#[test]
fn bfs_path_same_node_is_singleton() {
let adj = sample();
assert_eq!(
adj.bfs_path("src/b.rs", "src/b.rs").unwrap(),
vec!["src/b.rs"]
);
}
#[test]
fn rings_group_by_distance() {
let adj = sample();
let rings = adj.bfs_rings("src/a.rs", 3);
assert_eq!(rings[0], (1, vec!["src/b.rs".to_string()]));
assert_eq!(rings[1], (2, vec!["src/c.rs".to_string()]));
}
#[test]
fn edge_between_reports_direction() {
let adj = sample();
let (dir, kind, conf) = adj.edge_between("src/a.rs", "src/b.rs").unwrap();
assert_eq!(dir, Direction::Forward);
assert_eq!(kind, "import");
assert!((conf - 1.0).abs() < 1e-9);
let (dir2, _, _) = adj.edge_between("src/b.rs", "src/a.rs").unwrap();
assert_eq!(dir2, Direction::Backward);
}
#[test]
fn edge_between_prefers_higher_confidence() {
let edges = vec![
edge("x.rs", "y.rs", "sibling"),
edge("x.rs", "y.rs", "import"),
];
let adj = Adj::build(&edges, &[]);
let (_, kind, conf) = adj.edge_between("x.rs", "y.rs").unwrap();
assert_eq!(kind, "import");
assert!((conf - 1.0).abs() < 1e-9);
}
#[test]
fn neighbors_split_in_and_out() {
let adj = sample();
let out = adj.outgoing("src/b.rs");
let inc = adj.incoming("src/b.rs");
assert_eq!(out.len(), 1);
assert_eq!(out[0].node, "src/c.rs");
assert_eq!(inc.len(), 1);
assert_eq!(inc[0].node, "src/a.rs");
}
}