rto_graph/context.rs
1//! Per-node **context bundles** with a dependency-aware cache.
2//!
3//! A node's *context* is the node plus its one-hop, provenance-labelled
4//! neighbourhood — the same shape as [`crate::query::explain`], but **cached**
5//! and **fingerprinted**. The fingerprint folds in the node's own content *and*
6//! every neighbour's content signature, so a change to the node or to any of its
7//! neighbours (callers, callees, referencing docs) moves the fingerprint and the
8//! cached entry is rebuilt on the next read. This is the codegraph-style
9//! "dirty-propagation" invalidation: because context reaches one hop out, a
10//! changed symbol invalidates exactly its dependents' cached context.
11//!
12//! The cache is content-addressed (the fingerprint *is* the validity check), so
13//! it needs no manual bookkeeping beyond pruning entries for deleted nodes. The
14//! bundle itself is cheap to rebuild today; the cache slot is the durable place a
15//! future, expensive per-node summary would live.
16
17use std::collections::BTreeSet;
18
19use serde::{Deserialize, Serialize};
20
21use crate::store::{Store, StoreError};
22use crate::{Edge, Node, SCHEMA};
23
24/// A compact node summary within a [`NodeContext`]. Owned and round-trippable so
25/// the whole bundle can be cached as JSON and read back.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ContextNode {
28 /// Natural key.
29 pub key: String,
30 /// Kind token (e.g. `fn`, `adr`).
31 pub kind: String,
32 /// Human-facing name.
33 pub name: String,
34 /// Repository-relative path, if any.
35 pub path: Option<String>,
36 /// Language token, if any.
37 pub lang: Option<String>,
38}
39
40impl ContextNode {
41 fn from_node(node: &Node) -> Self {
42 Self {
43 key: node.key.clone(),
44 kind: node.kind.as_str().to_owned(),
45 name: node.name.clone(),
46 path: node.path.clone(),
47 lang: node.lang.clone(),
48 }
49 }
50}
51
52/// One incident edge as seen from the subject: the relationship, its provenance,
53/// and the node on the other end. Owned/round-trippable for caching.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct ContextEdge {
56 /// Edge kind token (e.g. `calls`, `references`).
57 pub kind: String,
58 /// How the edge was produced (`derived` | `authored` | `inferred`).
59 pub provenance: String,
60 /// Confidence score, present only for inferred edges.
61 pub confidence: Option<f64>,
62 /// The natural key of the node at the other end.
63 pub node: String,
64}
65
66/// A node together with its one-hop neighbourhood and a validity `fingerprint`.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct NodeContext {
69 /// Stable schema tag ([`crate::SCHEMA`]).
70 pub schema: String,
71 /// Fingerprint over the node's content and its neighbours' content; a change
72 /// to either moves it, invalidating a cached entry.
73 pub fingerprint: String,
74 /// The subject node.
75 pub node: ContextNode,
76 /// Structured metadata attached to the node.
77 pub meta: serde_json::Value,
78 /// Edges where the subject is the source.
79 pub outgoing: Vec<ContextEdge>,
80 /// Edges where the subject is the destination.
81 pub incoming: Vec<ContextEdge>,
82}
83
84/// Counts from a [`refresh_contexts`] pass.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86pub struct ContextRefresh {
87 /// Cached entries that were rebuilt because their fingerprint had changed
88 /// (the node or a neighbour changed).
89 pub rebuilt: usize,
90 /// Cached entries that were still fresh and reused as-is.
91 pub reused: usize,
92 /// Stale entries pruned because their node no longer exists.
93 pub pruned: usize,
94}
95
96/// FNV-1a (64-bit). Dependency-free and deterministic; used only to fold content
97/// signatures into a fingerprint, so it needs no cryptographic properties.
98fn fnv1a64(bytes: &[u8]) -> u64 {
99 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
100 for &b in bytes {
101 hash ^= u64::from(b);
102 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
103 }
104 hash
105}
106
107/// A content signature for a node: its git blob hash when present (whole-content
108/// identity), else a hash of its kind, name, and metadata (which includes any
109/// captured `content`). Any change to what the node *means* moves this value.
110fn node_signature(node: &Node) -> u64 {
111 if let Some(blob) = &node.blob_hash {
112 return fnv1a64(blob.as_bytes());
113 }
114 let mut s = String::new();
115 s.push_str(node.kind.as_str());
116 s.push('\u{0}');
117 s.push_str(&node.name);
118 s.push('\u{0}');
119 // serde_json serialises object keys in sorted order, so this is stable.
120 s.push_str(&serde_json::to_string(&node.meta).unwrap_or_default());
121 fnv1a64(s.as_bytes())
122}
123
124/// A canonical, sortable descriptor of one incident edge for the fingerprint:
125/// direction, kind, provenance, confidence, the neighbour's key, and the
126/// neighbour's content signature. The confidence is captured by its exact bit
127/// pattern, so any change to it — however small — moves the fingerprint.
128fn edge_descriptor(edge: &Edge, direction: &str, neighbour: &str, neighbour_sig: u64) -> String {
129 let confidence = edge
130 .confidence
131 .map_or_else(String::new, |c| format!("{:016x}", c.to_bits()));
132 format!(
133 "{direction}|{}|{}|{confidence}|{neighbour}|{neighbour_sig:016x}",
134 edge.kind.as_str(),
135 edge.provenance.as_str(),
136 )
137}
138
139/// Compute the fingerprint of `node`'s context: its own signature plus a sorted
140/// list of its incident edges' descriptors (each carrying the neighbour's
141/// signature). Deterministic for a given graph state.
142fn compute_fingerprint(store: &Store, node: &Node) -> Result<String, StoreError> {
143 let mut descriptors: Vec<String> = Vec::new();
144 for edge in store.edges_from(&node.key)? {
145 let sig = store.get_node(&edge.dst)?.map_or(0, |n| node_signature(&n));
146 descriptors.push(edge_descriptor(&edge, "out", &edge.dst, sig));
147 }
148 for edge in store.edges_to(&node.key)? {
149 let sig = store.get_node(&edge.src)?.map_or(0, |n| node_signature(&n));
150 descriptors.push(edge_descriptor(&edge, "in", &edge.src, sig));
151 }
152 // Sort so the fingerprint is independent of edge storage/query order.
153 descriptors.sort();
154 let mut buf = format!("ctx/v1|{:016x}", node_signature(node));
155 for d in &descriptors {
156 buf.push('\n');
157 buf.push_str(d);
158 }
159 Ok(format!("{:016x}", fnv1a64(buf.as_bytes())))
160}
161
162fn out_ref(edge: &Edge) -> ContextEdge {
163 ContextEdge {
164 kind: edge.kind.as_str().to_owned(),
165 provenance: edge.provenance.as_str().to_owned(),
166 confidence: edge.confidence,
167 node: edge.dst.clone(),
168 }
169}
170
171fn in_ref(edge: &Edge) -> ContextEdge {
172 ContextEdge {
173 kind: edge.kind.as_str().to_owned(),
174 provenance: edge.provenance.as_str().to_owned(),
175 confidence: edge.confidence,
176 node: edge.src.clone(),
177 }
178}
179
180fn sort_refs(refs: &mut [ContextEdge]) {
181 refs.sort_by(|a, b| (&a.kind, &a.node, &a.provenance).cmp(&(&b.kind, &b.node, &b.provenance)));
182}
183
184/// Assemble a fresh bundle for `node` from the current graph, with the given
185/// `fingerprint`. Shared by [`build_context`] and the cache-miss path.
186fn fresh_bundle(
187 store: &Store,
188 node: &Node,
189 fingerprint: String,
190) -> Result<NodeContext, StoreError> {
191 let mut outgoing: Vec<ContextEdge> = store.edges_from(&node.key)?.iter().map(out_ref).collect();
192 let mut incoming: Vec<ContextEdge> = store.edges_to(&node.key)?.iter().map(in_ref).collect();
193 sort_refs(&mut outgoing);
194 sort_refs(&mut incoming);
195 Ok(NodeContext {
196 schema: SCHEMA.to_owned(),
197 fingerprint,
198 node: ContextNode::from_node(node),
199 meta: node.meta.clone(),
200 outgoing,
201 incoming,
202 })
203}
204
205/// Build a node's context bundle from the current graph (ignoring the cache).
206/// Returns `None` if no node has that key.
207///
208/// # Errors
209/// Returns [`StoreError`] on query failure.
210pub fn build_context(store: &Store, key: &str) -> Result<Option<NodeContext>, StoreError> {
211 let Some(node) = store.get_node(key)? else {
212 return Ok(None);
213 };
214 let fingerprint = compute_fingerprint(store, &node)?;
215 Ok(Some(fresh_bundle(store, &node, fingerprint)?))
216}
217
218/// Fetch a node's context through the cache: return the cached bundle when its
219/// fingerprint still matches the current graph, otherwise rebuild it, store it,
220/// and return the fresh bundle. Returns `None` (and prunes any stale entry) if
221/// the node no longer exists.
222///
223/// # Errors
224/// Returns [`StoreError`] on query failure, or if a cached entry cannot be
225/// decoded.
226pub fn context(store: &Store, key: &str) -> Result<Option<NodeContext>, StoreError> {
227 let Some(node) = store.get_node(key)? else {
228 store.context_cache_delete(key)?;
229 return Ok(None);
230 };
231 let fingerprint = compute_fingerprint(store, &node)?;
232 if let Some((cached_fp, json)) = store.context_cache_get(key)?
233 && cached_fp == fingerprint
234 {
235 return Ok(Some(serde_json::from_str(&json)?));
236 }
237 // Miss or stale: rebuild from the current graph and cache it.
238 let bundle = fresh_bundle(store, &node, fingerprint.clone())?;
239 store.context_cache_put(key, &fingerprint, &serde_json::to_string(&bundle)?)?;
240 Ok(Some(bundle))
241}
242
243/// The set of nodes whose cached context a change to any of `changed` would
244/// invalidate: the changed nodes themselves plus their one-hop neighbours in
245/// either direction (a node's context reaches exactly one hop out). This makes
246/// the dependency-propagation contract explicit; [`refresh_contexts`] realises
247/// it via fingerprints.
248///
249/// # Errors
250/// Returns [`StoreError`] on query failure.
251pub fn dependents(store: &Store, changed: &[String]) -> Result<BTreeSet<String>, StoreError> {
252 let mut set = BTreeSet::new();
253 for key in changed {
254 // A changed node's own context is dirty, and so is each neighbour's
255 // (their context reaches one hop and includes this node). This holds even
256 // if the node was deleted — its former neighbours are still reachable via
257 // their edges to/from it.
258 set.insert(key.clone());
259 for edge in store.edges_from(key)? {
260 set.insert(edge.dst);
261 }
262 for edge in store.edges_to(key)? {
263 set.insert(edge.src);
264 }
265 }
266 Ok(set)
267}
268
269/// The largest number of edges a [`tool_context`] bundle carries **per
270/// direction**.
271///
272/// This surface has no `limit` parameter — a context bundle is one node's
273/// neighbourhood, so the only honest argument is the node key — which means the
274/// bound is fixed here rather than negotiated per call, and the tool's
275/// description states this number because a model reads that even when it does
276/// not read a schema.
277///
278/// Why a bound exists at all: `context` on a large file node is the biggest
279/// answer this graph can produce for a single key. In this repository
280/// `file:crates/roteiro/src/main.rs` returns 269 outgoing edges — 44,556 bytes of
281/// pretty JSON, roughly 11k tokens — of which 244 are `defines`. The node's own
282/// `meta` is already bounded (extraction caps captured content), so the edge
283/// lists are the whole of the variance, and capping them is the whole of the fix.
284///
285/// 50 per direction holds the worst case near 18 KB while leaving every ADR,
286/// every symbol and every ordinary file untouched — in this repository only file
287/// nodes reach it at all.
288pub const TOOL_CONTEXT_EDGE_CAP: usize = 50;
289
290/// How many edges of one kind were left out of a truncated direction.
291#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292pub struct OmittedEdges {
293 /// Edge kind token (e.g. `defines`).
294 pub kind: String,
295 /// How many edges of that kind the bundle does not carry.
296 pub omitted: usize,
297}
298
299/// One direction of a bounded bundle: the edges kept, and an exact account of
300/// what was dropped to fit.
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct BoundedEdges {
303 /// How many edges this node has in this direction, before the cap.
304 pub total: usize,
305 /// Whether `edges` is a subset of them.
306 pub truncated: bool,
307 /// What was dropped, by edge kind. Empty when `truncated` is false.
308 ///
309 /// This is the difference between a bounded answer and a misleading one: a
310 /// model that asks what `main.rs` imports and is handed 50 of its 269 edges
311 /// must be able to see that 23 `imports` edges exist, rather than conclude
312 /// from their absence that there are none.
313 pub omitted: Vec<OmittedEdges>,
314 /// The edges carried, in the same order [`context`] and [`explain`](crate::explain)
315 /// produce them.
316 pub edges: Vec<ContextEdge>,
317}
318
319/// A node's context bundle, bounded for a model-facing tool surface.
320///
321/// The same shape as [`NodeContext`] with each edge list replaced by a
322/// [`BoundedEdges`] that says how much of it you are looking at.
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324pub struct ToolContext {
325 /// Stable schema tag ([`SCHEMA`]).
326 pub schema: String,
327 /// Fingerprint over the node's content and its neighbours' content.
328 pub fingerprint: String,
329 /// The cap applied to each direction ([`TOOL_CONTEXT_EDGE_CAP`]).
330 pub edge_cap: usize,
331 /// Whether either direction was truncated — the one field a caller has to
332 /// read to know the bundle is partial.
333 pub truncated: bool,
334 /// The subject node.
335 pub node: ContextNode,
336 /// Structured metadata attached to the node.
337 pub meta: serde_json::Value,
338 /// Edges where the subject is the source.
339 pub outgoing: BoundedEdges,
340 /// Edges where the subject is the destination.
341 pub incoming: BoundedEdges,
342}
343
344/// Keep at most `cap` of `refs`, drawing **round-robin across edge kinds** so
345/// every kind present survives, and account for the rest.
346///
347/// Plain truncation of the sorted list would be simpler and worse. The sort is
348/// by `(kind, node, provenance)`, so on `file:…/main.rs` — `contains` 1,
349/// `defines` 244, `imports` 23, `references` 1 — the first 50 are one `contains`
350/// and 49 `defines`, and the bundle silently contains no `imports` or
351/// `references` edge at all. Round-robin gives the small kinds their edges back;
352/// `omitted` then says exactly how much of each large one is missing.
353fn bound_edges(refs: Vec<ContextEdge>, cap: usize) -> BoundedEdges {
354 let total = refs.len();
355 if total <= cap {
356 return BoundedEdges {
357 total,
358 truncated: false,
359 omitted: Vec::new(),
360 edges: refs,
361 };
362 }
363 // `refs` is sorted by kind, so equal kinds are already adjacent: walk it once
364 // into per-kind queues, then deal one from each in turn until the cap is met.
365 let mut by_kind: Vec<(String, Vec<ContextEdge>)> = Vec::new();
366 for r in refs {
367 match by_kind.last_mut() {
368 Some((kind, group)) if *kind == r.kind => group.push(r),
369 _ => by_kind.push((r.kind.clone(), vec![r])),
370 }
371 }
372 let mut kept: Vec<ContextEdge> = Vec::with_capacity(cap);
373 let mut round = 0usize;
374 while kept.len() < cap {
375 let mut dealt = false;
376 for (_, group) in &by_kind {
377 if kept.len() == cap {
378 break;
379 }
380 if round < group.len() {
381 kept.push(group[round].clone());
382 dealt = true;
383 }
384 }
385 // Every queue is exhausted — impossible while `total > cap`, but a `while`
386 // that can only end on a counter is a hang waiting for a future change.
387 if !dealt {
388 break;
389 }
390 round += 1;
391 }
392 let omitted: Vec<OmittedEdges> = by_kind
393 .iter()
394 .filter_map(|(kind, group)| {
395 let carried = kept.iter().filter(|e| e.kind == *kind).count();
396 (group.len() > carried).then(|| OmittedEdges {
397 kind: kind.clone(),
398 omitted: group.len() - carried,
399 })
400 })
401 .collect();
402 // Restore the canonical order, so a bounded bundle reads like a whole one.
403 sort_refs(&mut kept);
404 BoundedEdges {
405 total,
406 truncated: true,
407 omitted,
408 edges: kept,
409 }
410}
411
412/// A node's context bundle for a model-facing tool surface: **read-only** and
413/// **bounded**. Returns `None` if no node has that key.
414///
415/// # Read-only, deliberately
416///
417/// This builds on [`build_context`] and not on [`context`]. `context` is the
418/// cached read, and the cache is not free of side effects: a hit writes nothing
419/// but a miss calls `context_cache_put`, and a key whose node has been deleted
420/// calls `context_cache_delete` — a prune. Pruning is the *maintenance* half of
421/// `roteiro context --refresh`, which exists so that ordinary reads never mutate
422/// the store (ADR-0013), and a tool surface is the last place it should reappear.
423/// `build_context` assembles the identical bundle from the live graph and touches
424/// nothing, which is the whole difference and the reason for the choice.
425///
426/// # Bounded, and it says so
427///
428/// Each direction is capped at [`TOOL_CONTEXT_EDGE_CAP`] and the result carries
429/// `truncated`, each direction's `total`, and per-kind `omitted` counts. A
430/// shortened bundle that did not say it was shortened would be the same defect as
431/// a `limit` that silently returns nothing (issue #393): an answer a caller
432/// cannot tell from a complete one.
433///
434/// # Errors
435/// Returns [`StoreError`] on query failure.
436pub fn tool_context(store: &Store, key: &str) -> Result<Option<ToolContext>, StoreError> {
437 let Some(bundle) = build_context(store, key)? else {
438 return Ok(None);
439 };
440 let outgoing = bound_edges(bundle.outgoing, TOOL_CONTEXT_EDGE_CAP);
441 let incoming = bound_edges(bundle.incoming, TOOL_CONTEXT_EDGE_CAP);
442 Ok(Some(ToolContext {
443 schema: bundle.schema,
444 fingerprint: bundle.fingerprint,
445 edge_cap: TOOL_CONTEXT_EDGE_CAP,
446 truncated: outgoing.truncated || incoming.truncated,
447 node: bundle.node,
448 meta: bundle.meta,
449 outgoing,
450 incoming,
451 }))
452}
453
454/// Refresh every cached context that has gone stale (its node or a neighbour
455/// changed) and prune entries whose node no longer exists. Only existing nodes
456/// that already have a cache entry are considered — this reconciles the cache
457/// with the current graph without eagerly materialising context for every node.
458///
459/// # Errors
460/// Returns [`StoreError`] on query failure or if a cached entry cannot be
461/// decoded.
462pub fn refresh_contexts(store: &Store) -> Result<ContextRefresh, StoreError> {
463 let mut out = ContextRefresh {
464 rebuilt: 0,
465 reused: 0,
466 pruned: 0,
467 };
468 for key in store.context_cache_keys()? {
469 let Some(node) = store.get_node(&key)? else {
470 store.context_cache_delete(&key)?;
471 out.pruned += 1;
472 continue;
473 };
474 let fingerprint = compute_fingerprint(store, &node)?;
475 // Only the fingerprint is needed to decide freshness — avoid reading the
476 // full cached JSON payload for entries that turn out to be fresh.
477 let fresh = store
478 .context_cache_fingerprint(&key)?
479 .is_some_and(|fp| fp == fingerprint);
480 if fresh {
481 out.reused += 1;
482 } else {
483 context(store, &key)?; // rebuilds and stores
484 out.rebuilt += 1;
485 }
486 }
487 Ok(out)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::{
493 TOOL_CONTEXT_EDGE_CAP, build_context, context, dependents, refresh_contexts, tool_context,
494 };
495 use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
496
497 /// A store: doc --references--> caller --calls--> callee.
498 fn seeded() -> Store {
499 let mut store = Store::open_in_memory().expect("store");
500 let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
501 caller.blob_hash = Some("BLOB_A".to_owned());
502 let mut target = Node::new("sym:rust:a.rs#callee", NodeKind::Fn, "callee");
503 target.blob_hash = Some("BLOB_A".to_owned());
504 let mut doc = Node::new("file:docs/g.md", NodeKind::Doc, "g.md");
505 doc.blob_hash = Some("BLOB_DOC".to_owned());
506 let facts = FactSet::new()
507 .with_node(caller)
508 .with_node(target)
509 .with_node(doc)
510 .with_edge(Edge::derived(
511 "sym:rust:a.rs#caller",
512 "sym:rust:a.rs#callee",
513 EdgeKind::Calls,
514 ))
515 .with_edge(Edge::authored(
516 "file:docs/g.md",
517 "sym:rust:a.rs#caller",
518 EdgeKind::References,
519 ));
520 store.apply_factset(&facts).expect("apply");
521 store
522 }
523
524 /// A store with `count` symbols of each of three edge kinds hanging off one
525 /// file node — the shape a large source file has, at a size that trips the cap.
526 fn wide(count: usize) -> Store {
527 let mut store = Store::open_in_memory().expect("store");
528 let mut facts =
529 FactSet::new().with_node(Node::new("file:wide.rs", NodeKind::File, "wide.rs"));
530 // Deliberately lopsided, as a real file is: many `defines`, few of the
531 // rest. Sorted order puts every `defines` before every `imports`, which is
532 // what plain truncation would drop whole.
533 for (kind, n) in [
534 (EdgeKind::Defines, count),
535 (EdgeKind::Imports, 3),
536 (EdgeKind::References, 2),
537 ] {
538 for i in 0..n {
539 let key = format!("sym:rust:wide.rs#{}{i:04}", kind.as_str());
540 facts = facts
541 .with_node(Node::new(key.clone(), NodeKind::Fn, "s"))
542 .with_edge(Edge::derived("file:wide.rs", key, kind.clone()));
543 }
544 }
545 store.apply_factset(&facts).expect("apply");
546 store
547 }
548
549 #[test]
550 fn a_small_bundle_is_carried_whole_and_says_it_was_not_truncated() {
551 let store = seeded();
552 let out = tool_context(&store, "sym:rust:a.rs#caller")
553 .expect("ctx")
554 .expect("present");
555 assert!(!out.truncated);
556 assert_eq!(out.edge_cap, TOOL_CONTEXT_EDGE_CAP);
557 assert_eq!(out.outgoing.total, 1);
558 assert!(!out.outgoing.truncated);
559 assert!(out.outgoing.omitted.is_empty());
560 assert_eq!(out.outgoing.edges.len(), 1);
561 assert_eq!(out.incoming.total, 1);
562 assert_eq!(out.incoming.edges.len(), 1);
563 // Identical to the unbounded bundle it wraps, edge for edge.
564 let whole = build_context(&store, "sym:rust:a.rs#caller")
565 .expect("ctx")
566 .expect("present");
567 assert_eq!(out.outgoing.edges, whole.outgoing);
568 assert_eq!(out.incoming.edges, whole.incoming);
569 assert_eq!(out.fingerprint, whole.fingerprint);
570 }
571
572 /// The bound, and the accounting that keeps it from being a silent answer.
573 #[test]
574 fn a_truncated_bundle_reports_the_cap_the_totals_and_what_it_dropped() {
575 let store = wide(200);
576 let out = tool_context(&store, "file:wide.rs")
577 .expect("ctx")
578 .expect("present");
579
580 assert!(out.truncated, "205 edges must not fit under the cap");
581 assert_eq!(out.outgoing.total, 205, "the count before the cap");
582 assert!(out.outgoing.truncated);
583 assert_eq!(out.outgoing.edges.len(), TOOL_CONTEXT_EDGE_CAP);
584 // The dropped edges are accounted for by kind, and the numbers reconcile:
585 // total = carried + omitted.
586 let omitted: usize = out.outgoing.omitted.iter().map(|o| o.omitted).sum();
587 assert_eq!(omitted + out.outgoing.edges.len(), out.outgoing.total);
588 assert_eq!(
589 out.outgoing
590 .omitted
591 .iter()
592 .find(|o| o.kind == "defines")
593 .map(|o| o.omitted),
594 // Round-robin fills the two small kinds first (3 + 2), so the cap
595 // leaves `cap - 5` of the 200 `defines`. Written as a saturating
596 // expression so raising the cap fails this test rather than failing
597 // to compile it.
598 Some(205usize.saturating_sub(TOOL_CONTEXT_EDGE_CAP)),
599 "{:?}",
600 out.outgoing.omitted
601 );
602
603 // The other direction is untouched and says so.
604 assert_eq!(out.incoming.total, 0);
605 assert!(!out.incoming.truncated);
606 }
607
608 /// Round-robin, not head-of-list. Sorted order is `defines` … then `imports`
609 /// then `references`, so a plain `truncate(50)` would hand back 50 `defines`
610 /// and let a model conclude the file imports nothing. Every kind present must
611 /// survive the cap.
612 #[test]
613 fn truncation_keeps_every_edge_kind_rather_than_the_first_fifty() {
614 let store = wide(200);
615 let out = tool_context(&store, "file:wide.rs")
616 .expect("ctx")
617 .expect("present");
618 for kind in ["defines", "imports", "references"] {
619 assert!(
620 out.outgoing.edges.iter().any(|e| e.kind == kind),
621 "`{kind}` must survive truncation: {:?}",
622 out.outgoing
623 .edges
624 .iter()
625 .map(|e| &e.kind)
626 .collect::<std::collections::BTreeSet<_>>()
627 );
628 }
629 // The small kinds fit entirely, so they are not in `omitted` at all.
630 assert!(
631 !out.outgoing
632 .omitted
633 .iter()
634 .any(|o| o.kind == "imports" || o.kind == "references"),
635 "{:?}",
636 out.outgoing.omitted
637 );
638 // And the kept edges are still in canonical order.
639 let mut sorted = out.outgoing.edges.clone();
640 super::sort_refs(&mut sorted);
641 assert_eq!(sorted, out.outgoing.edges);
642 }
643
644 /// The read-only contract. `context` writes a cache entry on a miss and
645 /// *prunes* one for a deleted node; `tool_context` must do neither, so a tool
646 /// call never mutates the store (ADR-0013).
647 #[test]
648 fn tool_context_never_writes_to_the_cache() {
649 let store = seeded();
650 assert!(store.context_cache_keys().unwrap().is_empty());
651
652 tool_context(&store, "sym:rust:a.rs#caller")
653 .expect("ctx")
654 .expect("present");
655 assert!(
656 store.context_cache_keys().unwrap().is_empty(),
657 "a tool read must not populate the cache",
658 );
659
660 // And a hit on a key with a *stale* cache entry must not prune it: pruning
661 // is `roteiro context --refresh`'s maintenance, not a read's.
662 store
663 .context_cache_put("sym:rust:a.rs#ghost", "stale-fingerprint", "{}")
664 .expect("put");
665 let missing = tool_context(&store, "sym:rust:a.rs#ghost").expect("ctx");
666 assert!(missing.is_none(), "no such node");
667 assert_eq!(
668 store.context_cache_keys().unwrap(),
669 vec!["sym:rust:a.rs#ghost".to_owned()],
670 "a missing node must not prune its cache entry",
671 );
672 }
673
674 #[test]
675 fn context_is_cached_then_served_from_cache() {
676 let store = seeded();
677 // First read is a miss: it populates the cache.
678 let first = context(&store, "sym:rust:a.rs#caller")
679 .expect("ctx")
680 .expect("present");
681 assert_eq!(first.node.key, "sym:rust:a.rs#caller");
682 assert_eq!(first.outgoing.len(), 1, "calls callee");
683 assert_eq!(first.incoming.len(), 1, "referenced by doc");
684 assert_eq!(
685 store
686 .context_cache_get("sym:rust:a.rs#caller")
687 .expect("get")
688 .expect("cached")
689 .0,
690 first.fingerprint,
691 );
692 // Second read returns the identical bundle from the cache.
693 let second = context(&store, "sym:rust:a.rs#caller")
694 .expect("ctx")
695 .expect("present");
696 assert_eq!(first, second);
697 }
698
699 #[test]
700 fn changing_a_dependency_invalidates_dependent_context() {
701 let store = seeded();
702 // Warm the cache for all three nodes.
703 let before = refresh_first_read(&store);
704 assert_eq!(before.rebuilt, 0, "warming reads are misses, not refreshes");
705
706 // The caller's cached fingerprint before the change.
707 let caller_fp_before = context(&store, "sym:rust:a.rs#caller")
708 .expect("ctx")
709 .expect("present")
710 .fingerprint;
711
712 // Change the *callee*'s content (new blob). The caller depends on it.
713 let mut target = store
714 .get_node("sym:rust:a.rs#callee")
715 .expect("get")
716 .expect("present");
717 target.blob_hash = Some("BLOB_A2".to_owned());
718 store.upsert_node(&target).expect("upsert");
719
720 // `dependents` names exactly who should be dirtied: the callee and its
721 // neighbour, the caller.
722 let deps = dependents(&store, &["sym:rust:a.rs#callee".to_owned()]).expect("deps");
723 assert!(deps.contains("sym:rust:a.rs#caller"));
724
725 // The caller's fingerprint has moved (its neighbour changed), so a fresh
726 // read rebuilds it — its cached context is invalidated.
727 let caller_fp_after = context(&store, "sym:rust:a.rs#caller")
728 .expect("ctx")
729 .expect("present")
730 .fingerprint;
731 assert_ne!(
732 caller_fp_before, caller_fp_after,
733 "dependent context must be invalidated when a dependency changes",
734 );
735
736 // The unrelated doc did not change and is not a dependent of the callee,
737 // so a refresh reuses it while rebuilding the affected nodes.
738 let report = refresh_contexts(&store).expect("refresh");
739 assert!(report.rebuilt >= 1, "affected contexts rebuilt");
740 assert_eq!(report.pruned, 0);
741 }
742
743 /// Read context for every node once, warming the cache; returns a refresh
744 /// report taken immediately after (which should show everything fresh).
745 fn refresh_first_read(store: &Store) -> super::ContextRefresh {
746 for key in store.all_keys().expect("keys") {
747 context(store, &key).expect("ctx");
748 }
749 refresh_contexts(store).expect("refresh")
750 }
751
752 #[test]
753 fn deleted_node_context_is_pruned() {
754 let mut store = seeded();
755 context(&store, "file:docs/g.md")
756 .expect("ctx")
757 .expect("present");
758 assert!(
759 store
760 .context_cache_get("file:docs/g.md")
761 .expect("get")
762 .is_some()
763 );
764 // Rebuild the graph without the doc node.
765 let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
766 caller.blob_hash = Some("BLOB_A".to_owned());
767 store
768 .rebuild(&FactSet::new().with_node(caller), None)
769 .expect("rebuild");
770 // The cache entry survives rebuild but is pruned on refresh; a direct read
771 // also returns None and clears it.
772 assert!(context(&store, "file:docs/g.md").expect("ctx").is_none());
773 assert!(
774 store
775 .context_cache_get("file:docs/g.md")
776 .expect("get")
777 .is_none()
778 );
779 }
780
781 #[test]
782 fn build_context_matches_cached() {
783 let store = seeded();
784 let built = build_context(&store, "sym:rust:a.rs#caller")
785 .expect("build")
786 .expect("present");
787 let cached = context(&store, "sym:rust:a.rs#caller")
788 .expect("ctx")
789 .expect("present");
790 assert_eq!(built, cached);
791 assert!(
792 build_context(&store, "sym:rust:a.rs#ghost")
793 .expect("b")
794 .is_none()
795 );
796 }
797}