1use anyhow::Result;
2use lazily::{Context as LazyContext, SlotHandle};
3use std::collections::{BTreeMap, BTreeSet, BinaryHeap, VecDeque};
4
5use crate::types::*;
6
7pub(crate) fn graph_node_matches_filters(
8 node: &GraphNode,
9 filters: &[GraphPropertyFilter],
10) -> bool {
11 filters
12 .iter()
13 .all(|filter| node.properties.get(&filter.key) == Some(&filter.value))
14}
15
16#[derive(Debug, Clone, Eq, PartialEq)]
17pub(crate) struct ScoredQueueEntry {
18 pub id: String,
19 pub depth: usize,
20 pub score: i64,
21}
22
23impl Ord for ScoredQueueEntry {
24 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
25 self.score
26 .cmp(&other.score)
27 .then_with(|| other.depth.cmp(&self.depth))
28 .then_with(|| self.id.cmp(&other.id))
29 }
30}
31
32impl PartialOrd for ScoredQueueEntry {
33 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
34 Some(self.cmp(other))
35 }
36}
37
38pub(crate) fn compute_neighborhood_score(
39 strategy: &NeighborhoodScoring,
40 depth: usize,
41 edge_kind: &str,
42 _node: &GraphNode,
43 degree_map: &BTreeMap<String, usize>,
44) -> i64 {
45 match strategy {
46 NeighborhoodScoring::BreadthFirst => {
47 (120i64.saturating_sub((depth as i64).saturating_mul(18))).max(0)
48 }
49 NeighborhoodScoring::EdgeKindWeighted => {
50 let depth_score = (120i64.saturating_sub((depth as i64).saturating_mul(18))).max(0);
51 let kind_score = edge_kind_weighted_score(edge_kind);
52 depth_score.saturating_add(kind_score)
53 }
54 NeighborhoodScoring::DegreeWeighted => {
55 let depth_score = (120i64.saturating_sub((depth as i64).saturating_mul(18))).max(0);
56 let degree = degree_map.values().copied().max().unwrap_or(1) as i64;
57 let degree_bonus = if degree <= 3 {
58 20
59 } else if degree <= 10 {
60 10
61 } else {
62 0
63 };
64 depth_score.saturating_add(degree_bonus)
65 }
66 }
67}
68
69#[derive(Clone)]
70pub(crate) struct NeighborhoodLayerState {
71 pub nodes: BTreeMap<String, GraphNode>,
72 pub edges: BTreeMap<(String, String, String), GraphEdge>,
73 pub frontier: Vec<String>,
74}
75
76#[derive(Clone)]
77pub(crate) struct NeighborhoodFetchedLayer {
78 pub edges: Vec<GraphEdge>,
79 pub nodes: Vec<GraphNode>,
80}
81
82#[derive(Clone)]
83pub(crate) struct RankedNeighborhoodLayerState {
84 pub nodes: BTreeMap<String, GraphNode>,
85 pub edges: BTreeMap<(String, String, String), GraphEdge>,
86 pub queue: BinaryHeap<ScoredQueueEntry>,
87 pub seen: BTreeSet<String>,
88 pub pruned_count: usize,
89 pub total_discovered: usize,
90 pub degree_map: BTreeMap<String, usize>,
91}
92
93#[derive(Clone)]
94pub(crate) struct RankedNeighborhoodFetchedExpansion {
95 pub edges: Vec<GraphEdge>,
96 pub neighbor_nodes: BTreeMap<String, GraphNode>,
97}
98
99pub(crate) fn graph_cache_error(message: String) -> anyhow::Error {
100 anyhow::anyhow!("{message}")
101}
102
103pub(crate) fn fetch_neighborhood_layer<S: GraphStore + ?Sized>(
104 store: &S,
105 frontier: &[String],
106 known_nodes: &BTreeMap<String, GraphNode>,
107 kind: Option<&str>,
108) -> Result<NeighborhoodFetchedLayer> {
109 let mut edges = Vec::new();
110 let mut missing_ids = Vec::new();
111 for current in frontier {
112 let outgoing = store.outgoing_edges(current, kind)?;
113 for edge in outgoing {
114 if !known_nodes.contains_key(&edge.to_id) {
115 missing_ids.push(edge.to_id.clone());
116 }
117 edges.push(edge);
118 }
119 }
120 missing_ids.sort();
121 missing_ids.dedup();
122 let mut nodes = Vec::new();
123 for id in missing_ids {
124 if !known_nodes.contains_key(&id)
125 && let Some(node) = store.node(&id)?
126 {
127 nodes.push(node);
128 }
129 }
130 Ok(NeighborhoodFetchedLayer { edges, nodes })
131}
132
133pub(crate) fn fetch_ranked_neighborhood_expansion<S: GraphStore + ?Sized>(
134 store: &S,
135 entry: &ScoredQueueEntry,
136 state: &RankedNeighborhoodLayerState,
137 kind: Option<&str>,
138) -> Result<RankedNeighborhoodFetchedExpansion> {
139 let edges = store.outgoing_edges(&entry.id, kind)?;
140 let mut neighbor_nodes = BTreeMap::new();
141 for edge in &edges {
142 if state.seen.contains(&edge.to_id) {
143 continue;
144 }
145 if let Some(node) = store.node(&edge.to_id)? {
146 neighbor_nodes.insert(edge.to_id.clone(), node);
147 }
148 }
149 Ok(RankedNeighborhoodFetchedExpansion {
150 edges,
151 neighbor_nodes,
152 })
153}
154
155pub(crate) fn edge_kind_weighted_score(edge_kind: &str) -> i64 {
156 match edge_kind {
157 "semantic_relation" => 34,
158 "mentions_entity" | "mentions_concept" | "tagged_entity" | "tagged_concept"
159 | "related_concept" => 28,
160 "mentions" => 22,
161 "calls" => 20,
162 "requests_context" | "scopes_context" | "scopes_source" | "explains_result" => 18,
163 "defines" | "contains" | "belongs_to" => 12,
164 kind if kind.contains("community") => 20,
165 kind if kind.contains("semantic")
166 || kind.contains("concept")
167 || kind.contains("entity") =>
168 {
169 24
170 }
171 _ => 8,
172 }
173}
174
175pub(crate) fn graph_edge_matches_filters(
176 edge: &GraphEdge,
177 filters: &[GraphPropertyFilter],
178) -> bool {
179 filters
180 .iter()
181 .all(|filter| edge.properties.get(&filter.key) == Some(&filter.value))
182}
183
184pub fn apply_graph_query_page(
185 mut nodes: Vec<GraphNode>,
186 mut edges: Vec<GraphEdge>,
187 options: GraphQueryOptions,
188 mut diagnostics: Vec<String>,
189) -> GraphPagedSubgraph {
190 nodes.sort_by(|left, right| left.id.cmp(&right.id));
191 edges.sort_by(|left, right| {
192 left.from_id
193 .cmp(&right.from_id)
194 .then(left.kind.cmp(&right.kind))
195 .then(left.to_id.cmp(&right.to_id))
196 });
197
198 let before_filter = nodes.len();
199 if !options.property_filters.is_empty() {
200 nodes.retain(|node| graph_node_matches_filters(node, &options.property_filters));
201 }
202 let after_filter = nodes.len();
203
204 if let Some(cursor) = &options.cursor {
205 nodes.retain(|node| node.id > *cursor);
206 }
207
208 let before_limit = nodes.len();
209 let mut next_cursor = None;
210 if let Some(limit) = options.limit
211 && nodes.len() > limit
212 {
213 next_cursor = nodes
214 .get(limit.saturating_sub(1))
215 .map(|node| node.id.clone());
216 nodes.truncate(limit);
217 }
218
219 let node_ids = nodes
220 .iter()
221 .map(|node| node.id.as_str())
222 .collect::<BTreeSet<_>>();
223 edges.retain(|edge| {
224 node_ids.contains(edge.from_id.as_str()) && node_ids.contains(edge.to_id.as_str())
225 });
226
227 if after_filter != before_filter {
228 diagnostics.push(format!(
229 "property filters removed {} node(s)",
230 before_filter.saturating_sub(after_filter)
231 ));
232 }
233 if options.cursor.is_some() {
234 diagnostics.push("cursor is exclusive and ordered by node id".to_string());
235 }
236 if next_cursor.is_some() {
237 diagnostics.push(
238 "result was truncated; pass page.next_cursor as --cursor for the next page".to_string(),
239 );
240 }
241
242 GraphPagedSubgraph {
243 page: GraphQueryPage {
244 cursor: options.cursor,
245 limit: options.limit,
246 next_cursor,
247 returned_nodes: nodes.len(),
248 returned_edges: edges.len(),
249 truncated: options.limit.is_some_and(|limit| before_limit > limit),
250 diagnostics,
251 },
252 nodes,
253 edges,
254 }
255}
256
257pub fn apply_graph_edge_query_page(
258 mut edges: Vec<GraphEdge>,
259 options: GraphQueryOptions,
260 mut diagnostics: Vec<String>,
261) -> GraphPagedSubgraph {
262 edges.sort_by_key(graph_edge_id);
263
264 let before_filter = edges.len();
265 if !options.property_filters.is_empty() {
266 edges.retain(|edge| graph_edge_matches_filters(edge, &options.property_filters));
267 }
268 let after_filter = edges.len();
269
270 if let Some(cursor) = &options.cursor {
271 edges.retain(|edge| graph_edge_id(edge) > *cursor);
272 }
273
274 let before_limit = edges.len();
275 let mut next_cursor = None;
276 if let Some(limit) = options.limit
277 && edges.len() > limit
278 {
279 next_cursor = edges.get(limit.saturating_sub(1)).map(graph_edge_id);
280 edges.truncate(limit);
281 }
282
283 if after_filter != before_filter {
284 diagnostics.push(format!(
285 "edge property filters removed {} edge(s)",
286 before_filter.saturating_sub(after_filter)
287 ));
288 }
289 if options.cursor.is_some() {
290 diagnostics.push("cursor is exclusive and ordered by edge id".to_string());
291 }
292 if next_cursor.is_some() {
293 diagnostics.push(
294 "result was truncated; pass page.next_cursor as --cursor for the next page".to_string(),
295 );
296 }
297
298 GraphPagedSubgraph {
299 page: GraphQueryPage {
300 cursor: options.cursor,
301 limit: options.limit,
302 next_cursor,
303 returned_nodes: 0,
304 returned_edges: edges.len(),
305 truncated: options.limit.is_some_and(|limit| before_limit > limit),
306 diagnostics,
307 },
308 nodes: Vec::new(),
309 edges,
310 }
311}
312
313pub trait GraphStore {
314 fn upsert_node(&self, node: &GraphNode) -> Result<()>;
315 fn upsert_edge(&self, edge: &GraphEdge) -> Result<()>;
316 fn delete_node(&self, id: &str) -> Result<usize>;
317 fn delete_edge(&self, from_id: &str, to_id: &str, kind: &str) -> Result<usize>;
318 fn node(&self, id: &str) -> Result<Option<GraphNode>>;
319 fn all_nodes(&self) -> Result<Vec<GraphNode>>;
320 fn all_edges(&self) -> Result<Vec<GraphEdge>>;
321 fn edge(&self, edge_id: &str) -> Result<Option<GraphEdge>> {
322 Ok(self
323 .all_edges()?
324 .into_iter()
325 .find(|edge| graph_edge_id(edge) == edge_id))
326 }
327 fn graph_counts(&self) -> Result<(usize, usize)> {
328 Ok((self.all_nodes()?.len(), self.all_edges()?.len()))
329 }
330 fn sample_edge(&self, kind: Option<&str>) -> Result<Option<GraphEdge>> {
331 let mut edges = self
332 .all_edges()?
333 .into_iter()
334 .filter(|edge| edge.from_id != edge.to_id)
335 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
336 .collect::<Vec<_>>();
337 edges.sort_by(|left, right| {
338 left.from_id
339 .cmp(&right.from_id)
340 .then(left.kind.cmp(&right.kind))
341 .then(left.to_id.cmp(&right.to_id))
342 });
343 Ok(edges.into_iter().next())
344 }
345 fn sample_edge_with_property(&self) -> Result<Option<(GraphEdge, GraphPropertyFilter)>> {
346 let mut probes = Vec::new();
347 for edge in self.all_edges()? {
348 if edge.from_id == edge.to_id {
349 continue;
350 }
351 if let Some((key, value)) = edge
352 .properties
353 .iter()
354 .next()
355 .map(|(key, value)| (key.clone(), value.clone()))
356 {
357 probes.push((edge, GraphPropertyFilter { key, value }));
358 }
359 }
360 probes.sort_by(|(left_edge, left_filter), (right_edge, right_filter)| {
361 left_filter
362 .key
363 .cmp(&right_filter.key)
364 .then(left_filter.value.cmp(&right_filter.value))
365 .then_with(|| graph_edge_id(left_edge).cmp(&graph_edge_id(right_edge)))
366 });
367 Ok(probes.into_iter().next())
368 }
369 fn nodes_by_kind(&self, kind: &str) -> Result<Vec<GraphNode>>;
370 fn outgoing_edges(&self, from_id: &str, kind: Option<&str>) -> Result<Vec<GraphEdge>>;
371 fn incident_edges(&self, node_id: &str, kind: Option<&str>) -> Result<Vec<GraphEdge>> {
372 let mut edges = self
373 .all_edges()?
374 .into_iter()
375 .filter(|edge| edge.from_id == node_id || edge.to_id == node_id)
376 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
377 .collect::<Vec<_>>();
378 edges.sort_by_key(graph_edge_id);
379 Ok(edges)
380 }
381 fn paged_edges(
382 &self,
383 kind: Option<&str>,
384 options: GraphQueryOptions,
385 ) -> Result<GraphPagedSubgraph> {
386 let edges = self
387 .all_edges()?
388 .into_iter()
389 .filter(|edge| kind.is_none_or(|kind| edge.kind == kind))
390 .collect::<Vec<_>>();
391 Ok(apply_graph_edge_query_page(edges, options, Vec::new()))
392 }
393 fn paged_incident_edges(
394 &self,
395 node_id: &str,
396 kind: Option<&str>,
397 options: GraphQueryOptions,
398 ) -> Result<GraphPagedSubgraph> {
399 Ok(apply_graph_edge_query_page(
400 self.incident_edges(node_id, kind)?,
401 options,
402 Vec::new(),
403 ))
404 }
405 fn edges_between_nodes(&self, node_ids: &BTreeSet<String>) -> Result<Vec<GraphEdge>> {
406 let mut edges = BTreeMap::<(String, String, String), GraphEdge>::new();
407 for from_id in node_ids {
408 for edge in self.outgoing_edges(from_id, None)? {
409 if node_ids.contains(&edge.to_id) {
410 edges
411 .entry((edge.from_id.clone(), edge.kind.clone(), edge.to_id.clone()))
412 .or_insert(edge);
413 }
414 }
415 }
416 Ok(edges.into_values().collect())
417 }
418 fn shortest_path(
419 &self,
420 from_id: &str,
421 to_id: &str,
422 kind: Option<&str>,
423 ) -> Result<Option<GraphPath>>;
424 fn shortest_path_with_max_hops(
425 &self,
426 from_id: &str,
427 to_id: &str,
428 kind: Option<&str>,
429 max_hops: Option<usize>,
430 ) -> Result<Option<GraphPath>> {
431 shortest_path_using_outgoing(from_id, to_id, kind, max_hops, |current, kind| {
432 self.outgoing_edges(current, kind)
433 })
434 }
435 fn neighborhood(
436 &self,
437 center_id: &str,
438 depth: usize,
439 kind: Option<&str>,
440 ) -> Result<Option<GraphSubgraph>> {
441 let Some(center) = self.node(center_id)? else {
442 return Ok(None);
443 };
444 let ctx = LazyContext::new();
445 let initial = NeighborhoodLayerState {
446 nodes: BTreeMap::from([(center_id.to_string(), center)]),
447 edges: BTreeMap::new(),
448 frontier: vec![center_id.to_string()],
449 };
450 let mut layer: SlotHandle<std::result::Result<NeighborhoodLayerState, String>> =
451 ctx.slot(move |_| Ok(initial.clone()));
452 let mut state = ctx.get(&layer).map_err(graph_cache_error)?;
453
454 for _ in 0..depth {
455 if state.frontier.is_empty() {
456 break;
457 }
458 let fetched = fetch_neighborhood_layer(self, &state.frontier, &state.nodes, kind)?;
459 let previous = layer;
460 layer = ctx.slot(move |ctx| {
461 let mut state = ctx.get(&previous)?;
462 for edge in &fetched.edges {
463 let edge_key = (edge.from_id.clone(), edge.kind.clone(), edge.to_id.clone());
464 state.edges.entry(edge_key).or_insert_with(|| edge.clone());
465 }
466 state.frontier.clear();
467 for node in &fetched.nodes {
468 if !state.nodes.contains_key(&node.id) {
469 state.frontier.push(node.id.clone());
470 state.nodes.insert(node.id.clone(), node.clone());
471 }
472 }
473 Ok(state)
474 });
475 state = ctx.get(&layer).map_err(graph_cache_error)?;
476 }
477
478 Ok(Some(
479 GraphSubgraph {
480 nodes: state.nodes.into_values().collect(),
481 edges: state.edges.into_values().collect(),
482 }
483 .sorted(),
484 ))
485 }
486 fn paged_nodes_by_kind(
487 &self,
488 kind: &str,
489 options: GraphQueryOptions,
490 ) -> Result<GraphPagedSubgraph> {
491 Ok(apply_graph_query_page(
492 self.nodes_by_kind(kind)?,
493 Vec::new(),
494 options,
495 Vec::new(),
496 ))
497 }
498 fn paged_neighborhood(
499 &self,
500 center_id: &str,
501 depth: usize,
502 kind: Option<&str>,
503 options: GraphQueryOptions,
504 ) -> Result<Option<GraphPagedSubgraph>> {
505 Ok(self.neighborhood(center_id, depth, kind)?.map(|subgraph| {
506 apply_graph_query_page(subgraph.nodes, subgraph.edges, options, Vec::new())
507 }))
508 }
509 fn ranked_neighborhood(
510 &self,
511 center_id: &str,
512 options: &RankedNeighborhoodOptions,
513 ) -> Result<Option<RankedNeighborhoodResult>> {
514 let Some(center) = self.node(center_id)? else {
515 return Ok(None);
516 };
517 let ctx = LazyContext::new();
518 let initial = RankedNeighborhoodLayerState {
519 nodes: BTreeMap::from([(center_id.to_string(), center)]),
520 edges: BTreeMap::new(),
521 queue: BinaryHeap::from([ScoredQueueEntry {
522 id: center_id.to_string(),
523 depth: 0usize,
524 score: i64::MAX,
525 }]),
526 seen: BTreeSet::from([center_id.to_string()]),
527 pruned_count: 0,
528 total_discovered: 1,
529 degree_map: BTreeMap::new(),
530 };
531 let mut layer: SlotHandle<std::result::Result<RankedNeighborhoodLayerState, String>> =
532 ctx.slot(move |_| Ok(initial.clone()));
533 let mut state = ctx.get(&layer).map_err(graph_cache_error)?;
534 let options = options.clone();
535
536 while let Some(entry) = state.queue.peek().cloned() {
537 let fetched = if entry.depth < options.depth {
538 Some(fetch_ranked_neighborhood_expansion(
539 self,
540 &entry,
541 &state,
542 options.edge_kind.as_deref(),
543 )?)
544 } else {
545 None
546 };
547 let previous = layer;
548 let options = options.clone();
549 layer = ctx.slot(move |ctx| {
550 let mut state = ctx.get(&previous)?;
551 let Some(entry) = state.queue.pop() else {
552 return Ok(state);
553 };
554 let Some(fetched) = &fetched else {
555 return Ok(state);
556 };
557 for edge in &fetched.edges {
558 let edge_key = (edge.from_id.clone(), edge.kind.clone(), edge.to_id.clone());
559 state.edges.entry(edge_key).or_insert_with(|| edge.clone());
560 *state.degree_map.entry(edge.from_id.clone()).or_default() += 1;
561 *state.degree_map.entry(edge.to_id.clone()).or_default() += 1;
562 if state.seen.contains(&edge.to_id) {
563 continue;
564 }
565 state.seen.insert(edge.to_id.clone());
566 state.total_discovered += 1;
567 let Some(neighbor) = fetched.neighbor_nodes.get(&edge.to_id) else {
568 continue;
569 };
570 if state.nodes.len() > options.max_nodes {
571 state.pruned_count += 1;
572 continue;
573 }
574 let score = compute_neighborhood_score(
575 &options.scoring,
576 entry.depth + 1,
577 &edge.kind,
578 neighbor,
579 &state.degree_map,
580 );
581 state.nodes.insert(edge.to_id.clone(), neighbor.clone());
582 state.queue.push(ScoredQueueEntry {
583 id: edge.to_id.clone(),
584 depth: entry.depth + 1,
585 score,
586 });
587 }
588 Ok(state)
589 });
590 state = ctx.get(&layer).map_err(graph_cache_error)?;
591 }
592
593 let node_ids: BTreeSet<_> = state.nodes.keys().cloned().collect();
594 state
595 .edges
596 .retain(|_, edge| node_ids.contains(&edge.from_id) && node_ids.contains(&edge.to_id));
597
598 Ok(Some(RankedNeighborhoodResult {
599 nodes: state.nodes.into_values().collect(),
600 edges: state.edges.into_values().collect(),
601 pruned_count: state.pruned_count,
602 total_discovered: state.total_discovered,
603 }))
604 }
605 fn reachable_nodes_by_kind(
606 &self,
607 from_id: &str,
608 kind: &str,
609 depth: usize,
610 limit: usize,
611 ) -> Result<Vec<(GraphNode, GraphPath)>> {
612 let mut rows = BTreeMap::<String, (GraphNode, GraphPath)>::new();
613 let mut seen = BTreeSet::from([from_id.to_string()]);
614 let mut queue = VecDeque::from([(from_id.to_string(), vec![from_id.to_string()])]);
615
616 while let Some((current, path)) = queue.pop_front() {
617 let current_depth = path.len().saturating_sub(1);
618 if current_depth >= depth {
619 continue;
620 }
621 for edge in self.outgoing_edges(¤t, None)? {
622 if !seen.insert(edge.to_id.clone()) {
623 continue;
624 }
625 let Some(node) = self.node(&edge.to_id)? else {
626 continue;
627 };
628 let mut next_path = path.clone();
629 next_path.push(edge.to_id.clone());
630 let graph_path = GraphPath {
631 hops: next_path.len().saturating_sub(1),
632 nodes: next_path.clone(),
633 };
634 if node.kind == kind {
635 rows.entry(node.id.clone()).or_insert((node, graph_path));
636 }
637 queue.push_back((edge.to_id, next_path));
638 }
639 }
640 let mut rows = rows.into_values().collect::<Vec<_>>();
641 rows.sort_by(|(left_node, left_path), (right_node, right_path)| {
642 left_path
643 .hops
644 .cmp(&right_path.hops)
645 .then(left_node.label.cmp(&right_node.label))
646 .then(left_node.id.cmp(&right_node.id))
647 });
648 if limit > 0 && rows.len() > limit {
649 rows.truncate(limit);
650 }
651 Ok(rows)
652 }
653
654 fn reachable_nodes_by_kinds(
655 &self,
656 from_id: &str,
657 kinds: &[&str],
658 depth: usize,
659 limit: usize,
660 ) -> Result<BTreeMap<String, Vec<(GraphNode, GraphPath)>>> {
661 let mut rows = BTreeMap::new();
662 for kind in kinds {
663 rows.insert(
664 (*kind).to_string(),
665 self.reachable_nodes_by_kind(from_id, kind, depth, limit)?,
666 );
667 }
668 Ok(rows)
669 }
670
671 fn resolve_evidence_target(&self, target: &str, kinds: &[&str]) -> Result<Option<GraphNode>> {
672 if let Some(node) = self.node(target)? {
673 return Ok(Some(node));
674 }
675 let normalized = target.trim().trim_start_matches('#');
676 for kind in kinds {
677 let mut candidates = self
678 .nodes_by_kind(kind)?
679 .into_iter()
680 .filter(|node| {
681 node.properties.get("handle").map(String::as_str) == Some(target)
682 || node.properties.get("ref_id").map(String::as_str) == Some(normalized)
683 || node.label == target
684 || node.label == format!("#{normalized}")
685 })
686 .collect::<Vec<_>>();
687 candidates.sort_by(|left, right| left.id.cmp(&right.id));
688 if let Some(candidate) = candidates.into_iter().next() {
689 return Ok(Some(candidate));
690 }
691 }
692 Ok(None)
693 }
694}
695
696pub fn shortest_path_using_outgoing(
697 from_id: &str,
698 to_id: &str,
699 kind: Option<&str>,
700 max_hops: Option<usize>,
701 mut outgoing_edges: impl FnMut(&str, Option<&str>) -> Result<Vec<GraphEdge>>,
702) -> Result<Option<GraphPath>> {
703 if from_id == to_id {
704 return Ok(Some(GraphPath {
705 nodes: vec![from_id.to_string()],
706 hops: 0,
707 }));
708 }
709
710 let mut queue = VecDeque::new();
711 let mut parent = BTreeMap::<String, (usize, String)>::new();
712 parent.insert(from_id.to_string(), (0, String::new()));
713 queue.push_back(from_id.to_string());
714
715 while let Some(current) = queue.pop_front() {
716 let current_depth = parent.get(¤t).map(|(d, _)| *d).unwrap_or(0);
717 if max_hops.is_some_and(|max_hops| current_depth >= max_hops) {
718 continue;
719 }
720 for edge in outgoing_edges(¤t, kind)? {
721 if parent.contains_key(&edge.to_id) {
722 continue;
723 }
724 parent.insert(edge.to_id.clone(), (current_depth + 1, current.clone()));
725 if edge.to_id == to_id {
726 let mut nodes = vec![to_id.to_string()];
727 let mut cursor = to_id;
728 while let Some((_, previous)) = parent.get(cursor) {
729 if previous.is_empty() {
730 break;
731 }
732 nodes.push(previous.clone());
733 cursor = previous;
734 }
735 nodes.reverse();
736 let hops = nodes.len().saturating_sub(1);
737 return Ok(Some(GraphPath { nodes, hops }));
738 }
739 queue.push_back(edge.to_id);
740 }
741 }
742
743 Ok(None)
744}