1use 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
19struct NeighborRef {
21 node: String,
22 kind: String,
23 weight: f64,
24}
25
26struct 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 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 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 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 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 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 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
282pub 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
390pub 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
481pub 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 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 assert_eq!(adj.resolve("src/a.rs", "/proj").unwrap(), "src/a.rs");
677 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 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 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 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}