Skip to main content

lean_ctx_server/tools/
ctx_graph_primitives.rs

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