Skip to main content

rto_graph/
query.rs

1//! The agent- and human-facing query surface over the graph.
2//!
3//! Everything here is a read-only view built from the store's typed queries,
4//! serialised under a **stable, versioned** JSON schema ([`SCHEMA`]) so agents
5//! can depend on the shape. The primitives are [`explain`] (a node and its
6//! provenance-labelled neighbourhood), [`list_kind`] (all nodes of a kind),
7//! [`path`] (a shortest path between two nodes), [`debt`] (the intent-debt marker
8//! inventory), and [`search`] (relevance-ranked node search). All return
9//! mixed-provenance results — the "one query surface" from ADR-0001 — with every
10//! edge carrying its `provenance`.
11
12use std::collections::{BTreeMap, VecDeque};
13
14use serde::Serialize;
15
16use crate::store::{Store, StoreError};
17use crate::{Edge, NodeKind};
18
19/// The versioned schema tag emitted on every query result. Bump the version on
20/// any breaking change to the shape.
21pub const SCHEMA: &str = "roteiro.query/v1";
22
23/// A compact node summary (used in listings and as the subject of an
24/// [`Explanation`]).
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct NodeSummary {
27    /// Natural key.
28    pub key: String,
29    /// Kind token (e.g. `fn`, `adr`).
30    pub kind: String,
31    /// Human-facing name.
32    pub name: String,
33    /// Repository-relative path, if any.
34    pub path: Option<String>,
35    /// Language token, if any.
36    pub lang: Option<String>,
37}
38
39impl NodeSummary {
40    fn from_node(node: &crate::Node) -> Self {
41        Self {
42            key: node.key.clone(),
43            kind: node.kind.as_str().to_owned(),
44            name: node.name.clone(),
45            path: node.path.clone(),
46            lang: node.lang.clone(),
47        }
48    }
49}
50
51/// One end of an edge as seen from a subject node: the relationship, how it was
52/// produced, and the node on the other end.
53#[derive(Debug, Clone, PartialEq, Serialize)]
54pub struct EdgeRef {
55    /// Edge kind token (e.g. `calls`, `references`).
56    pub kind: String,
57    /// How the edge was produced (`derived` | `authored` | `inferred`).
58    pub provenance: &'static str,
59    /// Confidence score, present only for inferred edges.
60    pub confidence: Option<f64>,
61    /// The natural key of the node at the other end.
62    pub node: String,
63}
64
65/// A node together with its provenance-labelled neighbourhood.
66#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct Explanation {
68    /// Stable schema tag ([`SCHEMA`]).
69    pub schema: &'static str,
70    /// The subject node.
71    pub node: NodeSummary,
72    /// Structured metadata attached to the node.
73    pub meta: serde_json::Value,
74    /// Edges where the subject is the source.
75    pub outgoing: Vec<EdgeRef>,
76    /// Edges where the subject is the destination.
77    pub incoming: Vec<EdgeRef>,
78}
79
80/// A listing of all nodes of one kind.
81#[derive(Debug, Clone, PartialEq, Serialize)]
82pub struct Listing {
83    /// Stable schema tag ([`SCHEMA`]).
84    pub schema: &'static str,
85    /// The kind that was listed.
86    pub kind: String,
87    /// Matching nodes, ordered by key.
88    pub nodes: Vec<NodeSummary>,
89}
90
91/// One intent-debt finding in a [`DebtReport`].
92#[derive(Debug, Clone, PartialEq, Serialize)]
93pub struct DebtItem {
94    /// Natural key of the marker node (`marker:<path>#<line>`).
95    pub key: String,
96    /// Category token (`todo` | `fixme` | `hack` | `stub` | `deferred`).
97    pub category: String,
98    /// The marker text (the trimmed source line).
99    pub text: String,
100    /// Repository-relative path of the source file, if any.
101    pub path: Option<String>,
102    /// 1-based line number, if recorded.
103    pub line: Option<u32>,
104}
105
106/// The intent-debt inventory: every `marker` node, grouped and listed. A
107/// deterministic, provenance-`derived` view of what is incomplete or deferred.
108#[derive(Debug, Clone, PartialEq, Serialize)]
109pub struct DebtReport {
110    /// Stable schema tag ([`SCHEMA`]).
111    pub schema: &'static str,
112    /// Total markers in the report (after any category filter).
113    pub total: usize,
114    /// Count per category, ordered by category token.
115    pub by_category: BTreeMap<String, usize>,
116    /// The markers, ordered by `(path, line, key)`.
117    pub items: Vec<DebtItem>,
118}
119
120/// Inventory intent-debt markers in the graph, optionally restricted to the
121/// given `categories` (empty means all). Ordered by `(path, line)` so output is
122/// stable and reads top-to-bottom per file.
123///
124/// # Errors
125/// Returns [`StoreError`] on query failure.
126pub fn debt(store: &Store, categories: &[String]) -> Result<DebtReport, StoreError> {
127    let filter: std::collections::BTreeSet<&str> = categories.iter().map(String::as_str).collect();
128    let mut items = Vec::new();
129    let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
130    for node in store.nodes_by_kind(&NodeKind::Marker)? {
131        let category = node
132            .meta
133            .get("category")
134            .and_then(serde_json::Value::as_str)
135            .unwrap_or("other")
136            .to_owned();
137        if !filter.is_empty() && !filter.contains(category.as_str()) {
138            continue;
139        }
140        let text = node
141            .meta
142            .get("text")
143            .and_then(serde_json::Value::as_str)
144            .unwrap_or(node.name.as_str())
145            .to_owned();
146        let line = node
147            .meta
148            .get("line")
149            .and_then(serde_json::Value::as_u64)
150            .and_then(|l| u32::try_from(l).ok());
151        *by_category.entry(category.clone()).or_default() += 1;
152        items.push(DebtItem {
153            key: node.key.clone(),
154            category,
155            text,
156            path: node.path.clone(),
157            line,
158        });
159    }
160    items.sort_by(|a, b| (&a.path, a.line, &a.key).cmp(&(&b.path, b.line, &b.key)));
161    Ok(DebtReport {
162        schema: SCHEMA,
163        total: items.len(),
164        by_category,
165        items,
166    })
167}
168
169/// One step along a [`Path`]: the edge traversed and the node it leads to.
170#[derive(Debug, Clone, PartialEq, Serialize)]
171pub struct PathHop {
172    /// Edge kind token (e.g. `calls`, `contains`).
173    pub kind: String,
174    /// How the edge was produced.
175    pub provenance: &'static str,
176    /// Confidence score, present only for inferred edges.
177    pub confidence: Option<f64>,
178    /// The direction the edge was traversed relative to the previous node
179    /// (`outgoing` = along the edge, `incoming` = against it).
180    pub direction: &'static str,
181    /// The natural key of the node this hop arrives at.
182    pub node: String,
183}
184
185/// A shortest path between two nodes. Edges are followed in either direction
186/// (the graph is treated as undirected for reachability), and each hop records
187/// the actual direction and provenance of the edge used.
188#[derive(Debug, Clone, PartialEq, Serialize)]
189pub struct Path {
190    /// Stable schema tag ([`SCHEMA`]).
191    pub schema: &'static str,
192    /// Natural key of the start node.
193    pub from: String,
194    /// Natural key of the goal node.
195    pub to: String,
196    /// Whether a path (including the trivial empty one) was found.
197    pub found: bool,
198    /// Number of hops (edges) in the path; `0` when `from == to`.
199    pub length: usize,
200    /// The hops from `from` to `to`, in order.
201    pub hops: Vec<PathHop>,
202}
203
204fn out_ref(edge: &Edge) -> EdgeRef {
205    EdgeRef {
206        kind: edge.kind.as_str().to_owned(),
207        provenance: edge.provenance.as_str(),
208        confidence: edge.confidence,
209        node: edge.dst.clone(),
210    }
211}
212
213fn in_ref(edge: &Edge) -> EdgeRef {
214    EdgeRef {
215        kind: edge.kind.as_str().to_owned(),
216        provenance: edge.provenance.as_str(),
217        confidence: edge.confidence,
218        node: edge.src.clone(),
219    }
220}
221
222fn sort_refs(refs: &mut [EdgeRef]) {
223    // Include provenance so edges differing only in provenance have a total,
224    // stable order; with the edge-uniqueness constraint this key is unique.
225    refs.sort_by(|a, b| (&a.kind, &a.node, a.provenance).cmp(&(&b.kind, &b.node, b.provenance)));
226}
227
228/// Explain a node: its record plus every incoming and outgoing edge, each
229/// labelled with provenance. Returns `None` if no node has that key.
230///
231/// # Errors
232/// Returns [`StoreError`] on query failure.
233pub fn explain(store: &Store, key: &str) -> Result<Option<Explanation>, StoreError> {
234    let Some(node) = store.get_node(key)? else {
235        return Ok(None);
236    };
237    let mut outgoing: Vec<EdgeRef> = store.edges_from(key)?.iter().map(out_ref).collect();
238    let mut incoming: Vec<EdgeRef> = store.edges_to(key)?.iter().map(in_ref).collect();
239    sort_refs(&mut outgoing);
240    sort_refs(&mut incoming);
241    Ok(Some(Explanation {
242        schema: SCHEMA,
243        node: NodeSummary::from_node(&node),
244        meta: node.meta,
245        outgoing,
246        incoming,
247    }))
248}
249
250/// List every node of the given `kind`, ordered by key.
251///
252/// # Errors
253/// Returns [`StoreError`] on query failure.
254pub fn list_kind(store: &Store, kind: &NodeKind) -> Result<Listing, StoreError> {
255    let nodes = store
256        .nodes_by_kind(kind)?
257        .iter()
258        .map(NodeSummary::from_node)
259        .collect();
260    Ok(Listing {
261        schema: SCHEMA,
262        kind: kind.as_str().to_owned(),
263        nodes,
264    })
265}
266
267/// A relevance-ranked search hit: a node summary plus its score.
268#[derive(Debug, Clone, PartialEq, Serialize)]
269pub struct SearchHit {
270    /// Relevance score (higher is better); see [`search`] for how it is derived.
271    pub score: u32,
272    /// The matching node.
273    #[serde(flatten)]
274    pub node: NodeSummary,
275}
276
277/// Deterministically search node names/keys/paths for `query`, ranked by
278/// relevance, returning at most `limit` hits. Case-insensitive; every
279/// whitespace/`::`-separated token in `query` must appear somewhere in the node.
280/// Scoring favours an exact name match, then a name substring, then per-token
281/// hits across name/key/path. Ties break by key so results are stable.
282///
283/// # Errors
284/// Returns [`StoreError`] on query failure.
285pub fn search(store: &Store, query: &str, limit: usize) -> Result<Vec<SearchHit>, StoreError> {
286    if limit == 0 {
287        return Ok(Vec::new());
288    }
289    let q = query.trim().to_lowercase();
290    // Tokens are separated by whitespace or the `::` path separator; a lone `:`
291    // (as in a `sym:rust:…` key) does not split a token.
292    let tokens: Vec<&str> = q.split("::").flat_map(str::split_whitespace).collect();
293    if tokens.is_empty() {
294        return Ok(Vec::new());
295    }
296
297    let mut hits: Vec<SearchHit> = Vec::new();
298    for node in store.all_nodes()? {
299        let name = node.name.to_lowercase();
300        let key = node.key.to_lowercase();
301        let path = node.path.as_deref().unwrap_or("").to_lowercase();
302        // Require every token to appear somewhere, so a multi-word query narrows.
303        if !tokens
304            .iter()
305            .all(|t| name.contains(t) || key.contains(t) || path.contains(t))
306        {
307            continue;
308        }
309        let mut relevance = 0u32;
310        if name == q {
311            relevance += 100;
312        } else if name.contains(&q) {
313            relevance += 60;
314        }
315        for t in &tokens {
316            if name.contains(t) {
317                relevance += 12;
318            } else if key.contains(t) {
319                relevance += 6;
320            } else if path.contains(t) {
321                relevance += 3;
322            }
323        }
324        hits.push(SearchHit {
325            score: relevance,
326            node: NodeSummary::from_node(&node),
327        });
328    }
329    // Highest score first; ties by key for a stable, deterministic order.
330    hits.sort_by(|a, b| {
331        b.score
332            .cmp(&a.score)
333            .then_with(|| a.node.key.cmp(&b.node.key))
334    });
335    hits.truncate(limit);
336    Ok(hits)
337}
338
339/// A candidate step out of a node during traversal: the edge used and the node
340/// on the other end. Ordered so BFS expansion is deterministic.
341struct Step {
342    node: String,
343    hop: PathHop,
344}
345
346/// All one-hop steps out of `key`, following edges in either direction, sorted
347/// for deterministic traversal.
348fn steps_from(store: &Store, key: &str) -> Result<Vec<Step>, StoreError> {
349    let mut steps = Vec::new();
350    for edge in store.edges_from(key)? {
351        steps.push(Step {
352            node: edge.dst.clone(),
353            hop: hop(&edge, "outgoing", edge.dst.clone()),
354        });
355    }
356    for edge in store.edges_to(key)? {
357        steps.push(Step {
358            node: edge.src.clone(),
359            hop: hop(&edge, "incoming", edge.src.clone()),
360        });
361    }
362    steps.sort_by(|a, b| {
363        (&a.node, &a.hop.kind, a.hop.provenance, a.hop.direction).cmp(&(
364            &b.node,
365            &b.hop.kind,
366            b.hop.provenance,
367            b.hop.direction,
368        ))
369    });
370    Ok(steps)
371}
372
373fn hop(edge: &Edge, direction: &'static str, node: String) -> PathHop {
374    PathHop {
375        kind: edge.kind.as_str().to_owned(),
376        provenance: edge.provenance.as_str(),
377        confidence: edge.confidence,
378        direction,
379        node,
380    }
381}
382
383/// Find a shortest path from `from` to `to`, following edges in either
384/// direction. Returns a [`Path`] with `found = false` (and no hops) if either
385/// endpoint is absent or `to` is unreachable; `from == to` yields the trivial
386/// zero-length path.
387///
388/// The search is breadth-first with deterministic neighbour ordering, so the
389/// returned path is stable for a given graph.
390///
391/// # Errors
392/// Returns [`StoreError`] on query failure.
393pub fn path(store: &Store, from: &str, to: &str) -> Result<Path, StoreError> {
394    let not_found = |found: bool, hops: Vec<PathHop>| Path {
395        schema: SCHEMA,
396        from: from.to_owned(),
397        to: to.to_owned(),
398        found,
399        length: hops.len(),
400        hops,
401    };
402
403    // Both endpoints must exist in the graph.
404    if store.get_node(from)?.is_none() || store.get_node(to)?.is_none() {
405        return Ok(not_found(false, Vec::new()));
406    }
407    if from == to {
408        return Ok(not_found(true, Vec::new()));
409    }
410
411    // BFS, recording for each visited node the (predecessor, hop) that reached
412    // it so the path can be reconstructed.
413    let mut came_from: BTreeMap<String, (String, PathHop)> = BTreeMap::new();
414    let mut queue: VecDeque<String> = VecDeque::new();
415    queue.push_back(from.to_owned());
416    came_from.insert(from.to_owned(), (String::new(), placeholder_hop()));
417
418    while let Some(current) = queue.pop_front() {
419        if current == to {
420            break;
421        }
422        for step in steps_from(store, &current)? {
423            if came_from.contains_key(&step.node) {
424                continue;
425            }
426            came_from.insert(step.node.clone(), (current.clone(), step.hop));
427            queue.push_back(step.node);
428        }
429    }
430
431    // Walk predecessors back from `to` to `from`, then reverse. Every node in
432    // `came_from` other than `from` has a real predecessor, so this terminates
433    // at `from`. If the chain is ever broken (an invariant violation), treat it
434    // as no path rather than silently returning a partial one.
435    let mut hops = Vec::new();
436    let mut cursor = to.to_owned();
437    while cursor != from {
438        let Some((prev, hop)) = came_from.get(&cursor) else {
439            return Ok(not_found(false, Vec::new()));
440        };
441        hops.push(hop.clone());
442        cursor = prev.clone();
443    }
444    hops.reverse();
445    Ok(not_found(true, hops))
446}
447
448/// A sentinel hop for the BFS start node (never emitted in a result).
449fn placeholder_hop() -> PathHop {
450    PathHop {
451        kind: String::new(),
452        provenance: "derived",
453        confidence: None,
454        direction: "outgoing",
455        node: String::new(),
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::{SCHEMA, explain, list_kind, path, search};
462    use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
463
464    fn seeded() -> Store {
465        let mut store = Store::open_in_memory().expect("store");
466        let facts = FactSet::new()
467            .with_node(Node::new("sym:rust:a.rs#main", NodeKind::Fn, "main"))
468            .with_node(Node::new("sym:rust:a.rs#helper", NodeKind::Fn, "helper"))
469            .with_node(Node::new("adr:0001", NodeKind::Adr, "Build Roteiro"))
470            .with_edge(Edge::derived(
471                "sym:rust:a.rs#main",
472                "sym:rust:a.rs#helper",
473                EdgeKind::Calls,
474            ))
475            .with_edge(Edge::authored(
476                "adr:0001",
477                "sym:rust:a.rs#main",
478                EdgeKind::References,
479            ));
480        store.apply_factset(&facts).expect("apply");
481        store
482    }
483
484    #[test]
485    fn search_ranks_by_relevance_and_is_bounded() {
486        let store = seeded();
487        // An exact name match outranks a substring match.
488        let hits = search(&store, "helper", 10).expect("search");
489        assert_eq!(hits[0].node.key, "sym:rust:a.rs#helper");
490        assert!(hits[0].score >= 100, "exact name match scores high");
491
492        // Every token must appear: "main roteiro" matches nothing (no node has both).
493        assert!(
494            search(&store, "main roteiro", 10)
495                .expect("search")
496                .is_empty()
497        );
498
499        // A lone `:` does not split a token: `sym:rust` is one token matching the
500        // code-symbol keys but not `adr:0001`.
501        let by_prefix = search(&store, "sym:rust", 10).expect("search");
502        assert!(!by_prefix.is_empty());
503        assert!(
504            by_prefix
505                .iter()
506                .all(|h| h.node.key.starts_with("sym:rust:"))
507        );
508
509        // A blank query yields nothing; the limit is respected.
510        assert!(search(&store, "   ", 10).expect("search").is_empty());
511        assert!(search(&store, "a.rs", 1).expect("search").len() <= 1);
512    }
513
514    #[test]
515    fn explain_reports_labelled_neighbourhood() {
516        let store = seeded();
517        let ex = explain(&store, "sym:rust:a.rs#main")
518            .expect("query")
519            .expect("present");
520        assert_eq!(ex.schema, SCHEMA);
521        assert_eq!(ex.node.kind, "fn");
522
523        // Outgoing: derived call to helper.
524        assert_eq!(ex.outgoing.len(), 1);
525        assert_eq!(ex.outgoing[0].kind, "calls");
526        assert_eq!(ex.outgoing[0].provenance, "derived");
527        assert_eq!(ex.outgoing[0].node, "sym:rust:a.rs#helper");
528
529        // Incoming: authored reference from the ADR.
530        assert_eq!(ex.incoming.len(), 1);
531        assert_eq!(ex.incoming[0].provenance, "authored");
532        assert_eq!(ex.incoming[0].node, "adr:0001");
533    }
534
535    #[test]
536    fn explain_missing_node_is_none() {
537        let store = seeded();
538        assert!(explain(&store, "sym:rust:a.rs#ghost").expect("q").is_none());
539    }
540
541    #[test]
542    fn edges_differing_only_in_provenance_are_ordered() {
543        // Two edges A->B with the same kind but different provenance must sort
544        // into a stable, deterministic order (authored before derived).
545        let mut store = Store::open_in_memory().expect("store");
546        let facts = FactSet::new()
547            .with_node(Node::new("a", NodeKind::Fn, "a"))
548            .with_node(Node::new("b", NodeKind::Fn, "b"))
549            .with_edge(Edge::derived("a", "b", EdgeKind::References))
550            .with_edge(Edge::authored("a", "b", EdgeKind::References));
551        store.apply_factset(&facts).expect("apply");
552
553        let ex = explain(&store, "a").expect("q").expect("present");
554        let provs: Vec<_> = ex.outgoing.iter().map(|e| e.provenance).collect();
555        assert_eq!(provs, ["authored", "derived"]);
556    }
557
558    #[test]
559    fn list_kind_is_ordered() {
560        let store = seeded();
561        let listing = list_kind(&store, &NodeKind::Fn).expect("list");
562        let keys: Vec<_> = listing.nodes.iter().map(|n| n.key.as_str()).collect();
563        assert_eq!(keys, ["sym:rust:a.rs#helper", "sym:rust:a.rs#main"]);
564    }
565
566    #[test]
567    fn json_schema_is_stable() {
568        let store = seeded();
569        let ex = explain(&store, "adr:0001").expect("q").expect("present");
570        let json = serde_json::to_value(&ex).expect("json");
571        assert_eq!(json["schema"], SCHEMA);
572        assert_eq!(json["node"]["key"], "adr:0001");
573        assert_eq!(json["node"]["kind"], "adr");
574        // Outgoing authored reference is present with its provenance label.
575        assert_eq!(json["outgoing"][0]["kind"], "references");
576        assert_eq!(json["outgoing"][0]["provenance"], "authored");
577        assert_eq!(json["outgoing"][0]["node"], "sym:rust:a.rs#main");
578        assert!(json["outgoing"][0]["confidence"].is_null());
579    }
580
581    #[test]
582    fn path_crosses_provenance_and_direction() {
583        // adr:0001 --authored/references--> main --derived/calls--> helper.
584        // A path from the ADR to helper must traverse both, each hop labelled.
585        let store = seeded();
586        let p = path(&store, "adr:0001", "sym:rust:a.rs#helper").expect("path");
587        assert!(p.found);
588        assert_eq!(p.length, 2);
589        assert_eq!(p.schema, SCHEMA);
590
591        assert_eq!(p.hops[0].kind, "references");
592        assert_eq!(p.hops[0].provenance, "authored");
593        assert_eq!(p.hops[0].direction, "outgoing");
594        assert_eq!(p.hops[0].node, "sym:rust:a.rs#main");
595
596        assert_eq!(p.hops[1].kind, "calls");
597        assert_eq!(p.hops[1].provenance, "derived");
598        assert_eq!(p.hops[1].node, "sym:rust:a.rs#helper");
599    }
600
601    #[test]
602    fn path_follows_edges_against_direction() {
603        // From helper back to the ADR: both edges are traversed against their
604        // stored direction, so each hop is `incoming`.
605        let store = seeded();
606        let p = path(&store, "sym:rust:a.rs#helper", "adr:0001").expect("path");
607        assert!(p.found);
608        assert_eq!(p.length, 2);
609        assert!(p.hops.iter().all(|h| h.direction == "incoming"));
610        assert_eq!(p.hops.last().unwrap().node, "adr:0001");
611    }
612
613    #[test]
614    fn path_same_node_is_trivial() {
615        let store = seeded();
616        let p = path(&store, "adr:0001", "adr:0001").expect("path");
617        assert!(p.found);
618        assert_eq!(p.length, 0);
619        assert!(p.hops.is_empty());
620    }
621
622    #[test]
623    fn path_missing_endpoint_or_unreachable_is_not_found() {
624        let mut store = Store::open_in_memory().expect("store");
625        // Two disconnected components: a-b and an isolated island.
626        let facts = FactSet::new()
627            .with_node(Node::new("a", NodeKind::Fn, "a"))
628            .with_node(Node::new("b", NodeKind::Fn, "b"))
629            .with_node(Node::new("island", NodeKind::Fn, "island"))
630            .with_edge(Edge::derived("a", "b", EdgeKind::Calls));
631        store.apply_factset(&facts).expect("apply");
632
633        // Absent endpoint.
634        let missing = path(&store, "a", "ghost").expect("path");
635        assert!(!missing.found);
636        assert!(missing.hops.is_empty());
637
638        // Present but unreachable.
639        let unreachable = path(&store, "a", "island").expect("path");
640        assert!(!unreachable.found);
641        assert!(unreachable.hops.is_empty());
642    }
643
644    #[test]
645    fn path_is_shortest() {
646        // a-b-c-d chain plus a direct a-d edge: the path must take the shortcut.
647        let mut store = Store::open_in_memory().expect("store");
648        let facts = FactSet::new()
649            .with_node(Node::new("a", NodeKind::Fn, "a"))
650            .with_node(Node::new("b", NodeKind::Fn, "b"))
651            .with_node(Node::new("c", NodeKind::Fn, "c"))
652            .with_node(Node::new("d", NodeKind::Fn, "d"))
653            .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
654            .with_edge(Edge::derived("b", "c", EdgeKind::Calls))
655            .with_edge(Edge::derived("c", "d", EdgeKind::Calls))
656            .with_edge(Edge::derived("a", "d", EdgeKind::Calls));
657        store.apply_factset(&facts).expect("apply");
658
659        let p = path(&store, "a", "d").expect("path");
660        assert!(p.found);
661        assert_eq!(p.length, 1, "the direct a->d edge is the shortest path");
662        assert_eq!(p.hops[0].node, "d");
663    }
664}