Skip to main content

lean_ctx/tools/
ctx_graph_primitives.rs

1//! Graph primitives for `ctx_graph`: `neighbors`, `path` (shortest path) and
2//! `explain`. These are graphify-style traversal/inspection helpers that work on
3//! the same file-level graph the dashboard renders (`GraphProvider::edges`), so
4//! the MCP answers and the visual graph always agree.
5//!
6//! All three accept `format="json"` for machine consumption; otherwise they emit
7//! compact, token-light text with a `[ctx_graph <action>: N tok]` footer like the
8//! other actions.
9
10use std::collections::{HashMap, HashSet, VecDeque};
11
12use crate::core::graph_analysis::edge_confidence;
13use crate::core::graph_index;
14use crate::core::graph_provider::{self, EdgeInfo};
15use crate::core::protocol::shorten_path;
16use crate::core::tokens::count_tokens;
17
18/// One adjacency entry: a neighbour reached via an edge of `kind`/`weight`.
19struct NeighborRef {
20    node: String,
21    kind: String,
22    weight: f64,
23}
24
25/// A directed, file-level adjacency view built once from `GraphProvider::edges`.
26/// Node ids are repo-relative file paths — identical to the dashboard graph.
27struct Adj {
28    nodes: Vec<String>,
29    node_set: HashSet<String>,
30    out: HashMap<String, Vec<NeighborRef>>,
31    inc: HashMap<String, Vec<NeighborRef>>,
32}
33
34impl Adj {
35    fn build(edges: &[EdgeInfo], file_paths: &[String]) -> Self {
36        let mut out: HashMap<String, Vec<NeighborRef>> = HashMap::new();
37        let mut inc: HashMap<String, Vec<NeighborRef>> = HashMap::new();
38        let mut node_set: HashSet<String> = HashSet::new();
39        for p in file_paths {
40            node_set.insert(p.clone());
41        }
42        for e in edges {
43            node_set.insert(e.from.clone());
44            node_set.insert(e.to.clone());
45            out.entry(e.from.clone()).or_default().push(NeighborRef {
46                node: e.to.clone(),
47                kind: e.kind.clone(),
48                weight: e.weight,
49            });
50            inc.entry(e.to.clone()).or_default().push(NeighborRef {
51                node: e.from.clone(),
52                kind: e.kind.clone(),
53                weight: e.weight,
54            });
55        }
56        let mut nodes: Vec<String> = node_set.iter().cloned().collect();
57        nodes.sort();
58        Self {
59            nodes,
60            node_set,
61            out,
62            inc,
63        }
64    }
65
66    /// Resolve a user-supplied path to a concrete graph node. Tries an exact
67    /// repo-relative match first, then a unique path/basename suffix match.
68    fn resolve(&self, input: &str, root: &str) -> Result<String, String> {
69        let rel = graph_index::graph_relative_key(input, root);
70        if self.node_set.contains(&rel) {
71            return Ok(rel);
72        }
73        let needle = graph_index::graph_match_key(&rel);
74        if self.node_set.contains(&needle) {
75            return Ok(needle);
76        }
77        let base = needle.rsplit('/').next().unwrap_or(&needle).to_string();
78        let suffix = format!("/{needle}");
79        let base_suffix = format!("/{base}");
80        let cands: Vec<&String> = self
81            .nodes
82            .iter()
83            .filter(|n| {
84                let nk = graph_index::graph_match_key(n);
85                nk == needle || nk.ends_with(&suffix) || nk == base || nk.ends_with(&base_suffix)
86            })
87            .collect();
88        match cands.len() {
89            0 => Err(format!(
90                "Node not found in graph: {input}\nRun ctx_graph action='build' to (re)index, or pass a path that exists in the project."
91            )),
92            1 => Ok(cands[0].clone()),
93            _ => {
94                // Show full repo-relative paths (not basenames) so the caller
95                // can actually tell the candidates apart.
96                let list = cands
97                    .iter()
98                    .take(10)
99                    .map(|c| format!("  {c}"))
100                    .collect::<Vec<_>>()
101                    .join("\n");
102                let more = if cands.len() > 10 {
103                    format!("\n  … and {} more", cands.len() - 10)
104                } else {
105                    String::new()
106                };
107                Err(format!(
108                    "'{input}' is ambiguous ({} matches) — pass a more specific path:\n{list}{more}",
109                    cands.len()
110                ))
111            }
112        }
113    }
114
115    fn outgoing(&self, node: &str) -> Vec<&NeighborRef> {
116        let mut v: Vec<&NeighborRef> = self
117            .out
118            .get(node)
119            .map(|x| x.iter().collect())
120            .unwrap_or_default();
121        v.sort_by(|a, b| a.node.cmp(&b.node).then_with(|| a.kind.cmp(&b.kind)));
122        v
123    }
124
125    fn incoming(&self, node: &str) -> Vec<&NeighborRef> {
126        let mut v: Vec<&NeighborRef> = self
127            .inc
128            .get(node)
129            .map(|x| x.iter().collect())
130            .unwrap_or_default();
131        v.sort_by(|a, b| a.node.cmp(&b.node).then_with(|| a.kind.cmp(&b.kind)));
132        v
133    }
134
135    /// Unique undirected neighbours (out ∪ inc), deterministically sorted.
136    fn undirected_neighbors(&self, node: &str) -> Vec<String> {
137        let mut set: HashSet<&str> = HashSet::new();
138        if let Some(v) = self.out.get(node) {
139            for nb in v {
140                set.insert(nb.node.as_str());
141            }
142        }
143        if let Some(v) = self.inc.get(node) {
144            for nb in v {
145                set.insert(nb.node.as_str());
146            }
147        }
148        let mut out: Vec<String> = set.into_iter().map(str::to_string).collect();
149        out.sort();
150        out
151    }
152
153    /// The strongest edge directly connecting `a` and `b`, with its direction.
154    /// Direction: `Forward` = a→b, `Backward` = b→a.
155    fn edge_between(&self, a: &str, b: &str) -> Option<(Direction, String, f64)> {
156        let mut best: Option<(Direction, String, f64)> = None;
157        let mut consider = |dir: Direction, kind: &str, weight: f64| {
158            let conf = edge_confidence(kind, weight);
159            if best.as_ref().is_none_or(|(_, _, c)| conf > *c) {
160                best = Some((dir, kind.to_string(), conf));
161            }
162        };
163        if let Some(v) = self.out.get(a) {
164            for nb in v.iter().filter(|nb| nb.node == b) {
165                consider(Direction::Forward, &nb.kind, nb.weight);
166            }
167        }
168        if let Some(v) = self.inc.get(a) {
169            for nb in v.iter().filter(|nb| nb.node == b) {
170                consider(Direction::Backward, &nb.kind, nb.weight);
171            }
172        }
173        best
174    }
175
176    /// Shortest undirected path `from → to` (BFS, deterministic). Includes both
177    /// endpoints. `None` when the two nodes are in different components.
178    fn bfs_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
179        if from == to {
180            return Some(vec![from.to_string()]);
181        }
182        let mut prev: HashMap<String, String> = HashMap::new();
183        let mut visited: HashSet<String> = HashSet::new();
184        let mut queue: VecDeque<String> = VecDeque::new();
185        visited.insert(from.to_string());
186        queue.push_back(from.to_string());
187        while let Some(cur) = queue.pop_front() {
188            for nb in self.undirected_neighbors(&cur) {
189                if visited.contains(&nb) {
190                    continue;
191                }
192                visited.insert(nb.clone());
193                prev.insert(nb.clone(), cur.clone());
194                if nb == to {
195                    return Some(reconstruct(&prev, from, to));
196                }
197                queue.push_back(nb);
198            }
199        }
200        None
201    }
202
203    /// BFS distance rings from `start` (undirected), capped at `max_depth`.
204    /// Returns distance → sorted node list (excludes the start node).
205    fn bfs_rings(&self, start: &str, max_depth: usize) -> Vec<(usize, Vec<String>)> {
206        let mut dist: HashMap<String, usize> = HashMap::new();
207        let mut queue: VecDeque<String> = VecDeque::new();
208        dist.insert(start.to_string(), 0);
209        queue.push_back(start.to_string());
210        while let Some(cur) = queue.pop_front() {
211            let d = dist[&cur];
212            if d >= max_depth {
213                continue;
214            }
215            for nb in self.undirected_neighbors(&cur) {
216                if !dist.contains_key(&nb) {
217                    dist.insert(nb.clone(), d + 1);
218                    queue.push_back(nb);
219                }
220            }
221        }
222        let mut rings: HashMap<usize, Vec<String>> = HashMap::new();
223        for (node, d) in dist {
224            if d == 0 {
225                continue;
226            }
227            rings.entry(d).or_default().push(node);
228        }
229        let mut out: Vec<(usize, Vec<String>)> = rings
230            .into_iter()
231            .map(|(d, mut nodes)| {
232                nodes.sort();
233                (d, nodes)
234            })
235            .collect();
236        out.sort_by_key(|(d, _)| *d);
237        out
238    }
239}
240
241#[derive(Clone, Copy, PartialEq, Debug)]
242enum Direction {
243    Forward,
244    Backward,
245}
246
247impl Direction {
248    fn arrow(self) -> &'static str {
249        match self {
250            Direction::Forward => "->",
251            Direction::Backward => "<-",
252        }
253    }
254}
255
256fn reconstruct(prev: &HashMap<String, String>, from: &str, to: &str) -> Vec<String> {
257    let mut chain = vec![to.to_string()];
258    let mut cur = to.to_string();
259    while cur != from {
260        match prev.get(&cur) {
261            Some(p) => {
262                chain.push(p.clone());
263                cur = p.clone();
264            }
265            None => break,
266        }
267    }
268    chain.reverse();
269    chain
270}
271
272fn open_graph(root: &str) -> Result<graph_provider::OpenGraphProvider, String> {
273    graph_provider::open_or_build(root)
274        .ok_or_else(|| "No graph index found. Run ctx_graph with action='build' first.".to_string())
275}
276
277fn is_json(format: Option<&str>) -> bool {
278    matches!(format, Some(f) if f.eq_ignore_ascii_case("json"))
279}
280
281/// `ctx_graph action=neighbors` — immediate (and optionally multi-hop) graph
282/// neighbours of a file, split by direction and annotated with edge kind.
283pub fn neighbors(
284    path: Option<&str>,
285    root: &str,
286    depth: Option<usize>,
287    format: Option<&str>,
288) -> String {
289    let Some(input) = path else {
290        return "path is required for 'neighbors' action".to_string();
291    };
292    let open = match open_graph(root) {
293        Ok(o) => o,
294        Err(e) => return e,
295    };
296    let gp = &open.provider;
297    let adj = Adj::build(&gp.edges(), &gp.file_paths());
298    let node = match adj.resolve(input, root) {
299        Ok(n) => n,
300        Err(e) => return e,
301    };
302    let depth = depth.unwrap_or(1).clamp(1, 6);
303    let outgoing = adj.outgoing(&node);
304    let incoming = adj.incoming(&node);
305    let rings = if depth > 1 {
306        adj.bfs_rings(&node, depth)
307    } else {
308        Vec::new()
309    };
310
311    if is_json(format) {
312        let out_json: Vec<_> = outgoing
313            .iter()
314            .map(|n| {
315                serde_json::json!({
316                    "node": n.node,
317                    "kind": n.kind,
318                    "confidence": round3(edge_confidence(&n.kind, n.weight)),
319                })
320            })
321            .collect();
322        let in_json: Vec<_> = incoming
323            .iter()
324            .map(|n| {
325                serde_json::json!({
326                    "node": n.node,
327                    "kind": n.kind,
328                    "confidence": round3(edge_confidence(&n.kind, n.weight)),
329                })
330            })
331            .collect();
332        let rings_json: Vec<_> = rings
333            .iter()
334            .map(|(d, nodes)| serde_json::json!({ "distance": d, "count": nodes.len(), "nodes": nodes }))
335            .collect();
336        let val = serde_json::json!({
337            "node": node,
338            "outgoing": out_json,
339            "incoming": in_json,
340            "rings": rings_json,
341        });
342        return serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
343    }
344
345    let mut out = format!("Neighbors of {}\n", shorten_path(&node));
346    out.push_str(&format!(
347        "\nOutgoing ({}) — this file depends on / references:\n",
348        outgoing.len()
349    ));
350    if outgoing.is_empty() {
351        out.push_str("  (none)\n");
352    } else {
353        for n in &outgoing {
354            out.push_str(&format!(
355                "  -> {:<48} {:<10} conf {:.2}\n",
356                shorten_path(&n.node),
357                n.kind,
358                edge_confidence(&n.kind, n.weight)
359            ));
360        }
361    }
362    out.push_str(&format!(
363        "\nIncoming ({}) — files that depend on / reference this:\n",
364        incoming.len()
365    ));
366    if incoming.is_empty() {
367        out.push_str("  (none)\n");
368    } else {
369        for n in &incoming {
370            out.push_str(&format!(
371                "  <- {:<48} {:<10} conf {:.2}\n",
372                shorten_path(&n.node),
373                n.kind,
374                edge_confidence(&n.kind, n.weight)
375            ));
376        }
377    }
378    if depth > 1 {
379        let total: usize = rings.iter().map(|(_, n)| n.len()).sum();
380        out.push_str(&format!("\nReachable within {depth} hops: {total} nodes\n"));
381        for (d, nodes) in &rings {
382            out.push_str(&format!("  {} hop(s): {} nodes\n", d, nodes.len()));
383        }
384    }
385    let tokens = count_tokens(&out);
386    format!("{out}[ctx_graph neighbors: {tokens} tok]")
387}
388
389/// `ctx_graph action=path` — shortest connection between two files, with the
390/// edge kind/direction of each hop.
391pub fn shortest_path(
392    from: Option<&str>,
393    to: Option<&str>,
394    root: &str,
395    format: Option<&str>,
396) -> String {
397    let (Some(a), Some(b)) = (from, to) else {
398        return "Both 'path' (from) and 'to' are required for 'path' action".to_string();
399    };
400    let open = match open_graph(root) {
401        Ok(o) => o,
402        Err(e) => return e,
403    };
404    let gp = &open.provider;
405    let adj = Adj::build(&gp.edges(), &gp.file_paths());
406    let na = match adj.resolve(a, root) {
407        Ok(n) => n,
408        Err(e) => return e,
409    };
410    let nb = match adj.resolve(b, root) {
411        Ok(n) => n,
412        Err(e) => return e,
413    };
414
415    let Some(chain) = adj.bfs_path(&na, &nb) else {
416        if is_json(format) {
417            return serde_json::json!({
418                "from": na, "to": nb, "connected": false, "path": [],
419            })
420            .to_string();
421        }
422        return format!(
423            "No path between {} and {} — they live in different components of the dependency graph.",
424            shorten_path(&na),
425            shorten_path(&nb)
426        );
427    };
428
429    let hops = chain.len().saturating_sub(1);
430    if is_json(format) {
431        let steps: Vec<_> = chain
432            .windows(2)
433            .map(|w| {
434                let (dir, kind, conf) = adj.edge_between(&w[0], &w[1]).unwrap_or((
435                    Direction::Forward,
436                    "related".to_string(),
437                    0.5,
438                ));
439                serde_json::json!({
440                    "from": w[0],
441                    "to": w[1],
442                    "direction": if dir == Direction::Forward { "forward" } else { "backward" },
443                    "kind": kind,
444                    "confidence": round3(conf),
445                })
446            })
447            .collect();
448        let val = serde_json::json!({
449            "from": na, "to": nb, "connected": true, "hops": hops,
450            "path": chain, "steps": steps,
451        });
452        return serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
453    }
454
455    let mut out = format!(
456        "Shortest path {} -> {} ({} hops):\n\n",
457        shorten_path(&na),
458        shorten_path(&nb),
459        hops
460    );
461    out.push_str(&format!("  {}\n", shorten_path(&chain[0])));
462    for w in chain.windows(2) {
463        let (dir, kind, conf) = adj.edge_between(&w[0], &w[1]).unwrap_or((
464            Direction::Forward,
465            "related".to_string(),
466            0.5,
467        ));
468        out.push_str(&format!(
469            "    {} {} (conf {:.2})\n  {}\n",
470            dir.arrow(),
471            kind,
472            conf,
473            shorten_path(&w[1])
474        ));
475    }
476    let tokens = count_tokens(&out);
477    format!("{out}[ctx_graph path: {tokens} tok]")
478}
479
480/// `ctx_graph action=explain` — why a file matters: degree, community, bridge
481/// score, god-node rank and its most important couplings. Reuses the same
482/// analyses the dashboard shows.
483pub fn explain(path: Option<&str>, root: &str, format: Option<&str>) -> String {
484    let Some(input) = path else {
485        return "path is required for 'explain' action".to_string();
486    };
487    let open = match open_graph(root) {
488        Ok(o) => o,
489        Err(e) => return e,
490    };
491    let gp = &open.provider;
492    let edges = gp.edges();
493    let adj = Adj::build(&edges, &gp.file_paths());
494    let node = match adj.resolve(input, root) {
495        Ok(n) => n,
496        Err(e) => return e,
497    };
498
499    let community = crate::core::community::detect_communities_for_provider(gp, root);
500    let community_map = community.assignment_min_size(2);
501    let god = crate::core::graph_analysis::compute_god_nodes(&edges, usize::MAX);
502    let bridges = crate::core::graph_analysis::compute_bridge_nodes(&edges, usize::MAX);
503    let surprising = crate::core::graph_analysis::find_surprising_connections(
504        &edges,
505        &community_map,
506        usize::MAX,
507    );
508
509    let god_entry = god.iter().enumerate().find(|(_, g)| g.path == node);
510    let (dep_in, dep_out, dep_degree) =
511        god_entry.map_or((0, 0, 0), |(_, g)| (g.in_degree, g.out_degree, g.degree));
512    let god_rank = god_entry.map(|(i, _)| i + 1);
513    let bridge_entry = bridges.iter().enumerate().find(|(_, b)| b.path == node);
514    let community_id = community_map.get(&node).copied();
515    let community_info =
516        community_id.and_then(|id| community.communities.iter().find(|c| c.id == id));
517    let surprising_here: Vec<_> = surprising
518        .iter()
519        .filter(|s| s.from == node || s.to == node)
520        .take(8)
521        .collect();
522
523    let out_all = adj.outgoing(&node);
524    let inc_all = adj.incoming(&node);
525
526    if is_json(format) {
527        let val = serde_json::json!({
528            "node": node,
529            "dependency_degree": { "in": dep_in, "out": dep_out, "total": dep_degree },
530            "god_node_rank": god_rank,
531            "is_god_node": god_rank.is_some_and(|r| r <= 12),
532            "bridge": bridge_entry.map(|(i, b)| serde_json::json!({
533                "rank": i + 1, "betweenness": round3(b.betweenness),
534            })),
535            "community": community_info.map(|c| serde_json::json!({
536                "id": c.id, "files": c.files.len(),
537                "cohesion": round3(c.cohesion),
538                "internal_edges": c.internal_edges, "external_edges": c.external_edges,
539            })),
540            "neighbors_all_kinds": { "out": out_all.len(), "in": inc_all.len() },
541            "surprising_connections": surprising_here.iter().map(|s| serde_json::json!({
542                "from": s.from, "to": s.to, "score": round3(s.score),
543                "cross_community": s.cross_community,
544            })).collect::<Vec<_>>(),
545        });
546        return serde_json::to_string_pretty(&val).unwrap_or_else(|_| "{}".to_string());
547    }
548
549    let mut out = format!("Why {} matters\n\n", shorten_path(&node));
550    out.push_str(&format!(
551        "Dependency degree: {dep_degree} (in {dep_in} · out {dep_out})\n"
552    ));
553    match god_rank {
554        Some(r) if r <= 12 => {
555            out.push_str(&format!("God-node: yes — rank #{r} (most connected)\n"));
556        }
557        Some(r) => out.push_str(&format!("God-node rank: #{r}\n")),
558        None => out.push_str("God-node: no dependency edges\n"),
559    }
560    match bridge_entry {
561        Some((i, b)) => out.push_str(&format!(
562            "Bridge (betweenness): {:.2} — rank #{} (sits on many shortest paths)\n",
563            b.betweenness,
564            i + 1
565        )),
566        None => out.push_str("Bridge: not on critical paths\n"),
567    }
568    match community_info {
569        Some(c) => out.push_str(&format!(
570            "Community: #{} — {} files, cohesion {:.2} (internal {} / external {})\n",
571            c.id,
572            c.files.len(),
573            c.cohesion,
574            c.internal_edges,
575            c.external_edges
576        )),
577        None => out.push_str("Community: isolated (no module ≥2 files)\n"),
578    }
579    out.push_str(&format!(
580        "Total neighbors (all edge kinds): {} (out {} · in {})\n",
581        out_all.len() + inc_all.len(),
582        out_all.len(),
583        inc_all.len()
584    ));
585
586    let top_dependents: Vec<&String> = inc_all
587        .iter()
588        .filter(|n| crate::core::graph_analysis::is_dependency_kind(&n.kind))
589        .map(|n| &n.node)
590        .take(8)
591        .collect();
592    if !top_dependents.is_empty() {
593        out.push_str(&format!(
594            "\nTop dependents (fan-in, {}):\n",
595            top_dependents.len()
596        ));
597        for d in &top_dependents {
598            out.push_str(&format!("  {}\n", shorten_path(d)));
599        }
600    }
601    let top_deps: Vec<&String> = out_all
602        .iter()
603        .filter(|n| crate::core::graph_analysis::is_dependency_kind(&n.kind))
604        .map(|n| &n.node)
605        .take(8)
606        .collect();
607    if !top_deps.is_empty() {
608        out.push_str(&format!(
609            "\nTop dependencies (fan-out, {}):\n",
610            top_deps.len()
611        ));
612        for d in &top_deps {
613            out.push_str(&format!("  {}\n", shorten_path(d)));
614        }
615    }
616    if !surprising_here.is_empty() {
617        out.push_str(&format!(
618            "\nSurprising connections ({}):\n",
619            surprising_here.len()
620        ));
621        for s in &surprising_here {
622            let other = if s.from == node { &s.to } else { &s.from };
623            out.push_str(&format!(
624                "  {} (score {:.2}{})\n",
625                shorten_path(other),
626                s.score,
627                if s.cross_community {
628                    ", cross-community"
629                } else {
630                    ""
631                }
632            ));
633        }
634    }
635    let tokens = count_tokens(&out);
636    format!("{out}[ctx_graph explain: {tokens} tok]")
637}
638
639fn round3(v: f64) -> f64 {
640    (v * 1000.0).round() / 1000.0
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    fn edge(from: &str, to: &str, kind: &str) -> EdgeInfo {
648        EdgeInfo {
649            from: from.into(),
650            to: to.into(),
651            kind: kind.into(),
652            weight: 1.0,
653        }
654    }
655
656    /// a -> b -> c, plus an isolated d. Used by most traversal tests.
657    fn sample() -> Adj {
658        let edges = vec![
659            edge("src/a.rs", "src/b.rs", "import"),
660            edge("src/b.rs", "src/c.rs", "import"),
661        ];
662        let files = vec![
663            "src/a.rs".to_string(),
664            "src/b.rs".to_string(),
665            "src/c.rs".to_string(),
666            "src/d.rs".to_string(),
667        ];
668        Adj::build(&edges, &files)
669    }
670
671    #[test]
672    fn resolve_exact_and_basename() {
673        let adj = sample();
674        // Exact repo-relative match.
675        assert_eq!(adj.resolve("src/a.rs", "/proj").unwrap(), "src/a.rs");
676        // Unique basename match.
677        assert_eq!(adj.resolve("c.rs", "/proj").unwrap(), "src/c.rs");
678    }
679
680    #[test]
681    fn resolve_unknown_errors() {
682        let adj = sample();
683        assert!(adj.resolve("nope.rs", "/proj").is_err());
684    }
685
686    #[test]
687    fn resolve_ambiguous_errors() {
688        let edges = vec![edge("a/mod.rs", "b/mod.rs", "import")];
689        let files = vec!["a/mod.rs".to_string(), "b/mod.rs".to_string()];
690        let adj = Adj::build(&edges, &files);
691        let err = adj.resolve("mod.rs", "/proj").unwrap_err();
692        assert!(err.contains("ambiguous"), "got: {err}");
693    }
694
695    #[test]
696    fn bfs_path_finds_shortest_chain() {
697        let adj = sample();
698        let path = adj.bfs_path("src/a.rs", "src/c.rs").unwrap();
699        assert_eq!(path, vec!["src/a.rs", "src/b.rs", "src/c.rs"]);
700    }
701
702    #[test]
703    fn bfs_path_is_undirected() {
704        // Reverse direction still connects (edges are followed both ways).
705        let adj = sample();
706        let path = adj.bfs_path("src/c.rs", "src/a.rs").unwrap();
707        assert_eq!(path, vec!["src/c.rs", "src/b.rs", "src/a.rs"]);
708    }
709
710    #[test]
711    fn bfs_path_none_when_disconnected() {
712        let adj = sample();
713        assert!(adj.bfs_path("src/a.rs", "src/d.rs").is_none());
714    }
715
716    #[test]
717    fn bfs_path_same_node_is_singleton() {
718        let adj = sample();
719        assert_eq!(
720            adj.bfs_path("src/b.rs", "src/b.rs").unwrap(),
721            vec!["src/b.rs"]
722        );
723    }
724
725    #[test]
726    fn rings_group_by_distance() {
727        let adj = sample();
728        let rings = adj.bfs_rings("src/a.rs", 3);
729        assert_eq!(rings[0], (1, vec!["src/b.rs".to_string()]));
730        assert_eq!(rings[1], (2, vec!["src/c.rs".to_string()]));
731    }
732
733    #[test]
734    fn edge_between_reports_direction() {
735        let adj = sample();
736        let (dir, kind, conf) = adj.edge_between("src/a.rs", "src/b.rs").unwrap();
737        assert_eq!(dir, Direction::Forward);
738        assert_eq!(kind, "import");
739        assert!((conf - 1.0).abs() < 1e-9);
740        // Reverse view is Backward.
741        let (dir2, _, _) = adj.edge_between("src/b.rs", "src/a.rs").unwrap();
742        assert_eq!(dir2, Direction::Backward);
743    }
744
745    #[test]
746    fn edge_between_prefers_higher_confidence() {
747        // Two parallel edges: a weak sibling and a strong import. import wins.
748        let edges = vec![
749            edge("x.rs", "y.rs", "sibling"),
750            edge("x.rs", "y.rs", "import"),
751        ];
752        let adj = Adj::build(&edges, &[]);
753        let (_, kind, conf) = adj.edge_between("x.rs", "y.rs").unwrap();
754        assert_eq!(kind, "import");
755        assert!((conf - 1.0).abs() < 1e-9);
756    }
757
758    #[test]
759    fn neighbors_split_in_and_out() {
760        let adj = sample();
761        let out = adj.outgoing("src/b.rs");
762        let inc = adj.incoming("src/b.rs");
763        assert_eq!(out.len(), 1);
764        assert_eq!(out[0].node, "src/c.rs");
765        assert_eq!(inc.len(), 1);
766        assert_eq!(inc[0].node, "src/a.rs");
767    }
768}