1use 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
18struct NeighborRef {
20 node: String,
21 kind: String,
22 weight: f64,
23}
24
25struct 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 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 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 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 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 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 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
281pub 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
389pub 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
480pub 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 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 assert_eq!(adj.resolve("src/a.rs", "/proj").unwrap(), "src/a.rs");
676 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 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 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 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}