1use std::collections::BTreeSet;
18
19use serde::{Deserialize, Serialize};
20
21use crate::store::{Store, StoreError};
22use crate::{Edge, Node, SCHEMA};
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct ContextNode {
28 pub key: String,
30 pub kind: String,
32 pub name: String,
34 pub path: Option<String>,
36 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct ContextEdge {
56 pub kind: String,
58 pub provenance: String,
60 pub confidence: Option<f64>,
62 pub node: String,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct NodeContext {
69 pub schema: String,
71 pub fingerprint: String,
74 pub node: ContextNode,
76 pub meta: serde_json::Value,
78 pub outgoing: Vec<ContextEdge>,
80 pub incoming: Vec<ContextEdge>,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86pub struct ContextRefresh {
87 pub rebuilt: usize,
90 pub reused: usize,
92 pub pruned: usize,
94}
95
96fn 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
107fn 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 s.push_str(&serde_json::to_string(&node.meta).unwrap_or_default());
121 fnv1a64(s.as_bytes())
122}
123
124fn 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
139fn 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 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
184fn 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
205pub 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
218pub 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 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
243pub fn dependents(store: &Store, changed: &[String]) -> Result<BTreeSet<String>, StoreError> {
252 let mut set = BTreeSet::new();
253 for key in changed {
254 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
269pub fn refresh_contexts(store: &Store) -> Result<ContextRefresh, StoreError> {
278 let mut out = ContextRefresh {
279 rebuilt: 0,
280 reused: 0,
281 pruned: 0,
282 };
283 for key in store.context_cache_keys()? {
284 let Some(node) = store.get_node(&key)? else {
285 store.context_cache_delete(&key)?;
286 out.pruned += 1;
287 continue;
288 };
289 let fingerprint = compute_fingerprint(store, &node)?;
290 let fresh = store
293 .context_cache_fingerprint(&key)?
294 .is_some_and(|fp| fp == fingerprint);
295 if fresh {
296 out.reused += 1;
297 } else {
298 context(store, &key)?; out.rebuilt += 1;
300 }
301 }
302 Ok(out)
303}
304
305#[cfg(test)]
306mod tests {
307 use super::{build_context, context, dependents, refresh_contexts};
308 use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
309
310 fn seeded() -> Store {
312 let mut store = Store::open_in_memory().expect("store");
313 let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
314 caller.blob_hash = Some("BLOB_A".to_owned());
315 let mut target = Node::new("sym:rust:a.rs#callee", NodeKind::Fn, "callee");
316 target.blob_hash = Some("BLOB_A".to_owned());
317 let mut doc = Node::new("file:docs/g.md", NodeKind::Doc, "g.md");
318 doc.blob_hash = Some("BLOB_DOC".to_owned());
319 let facts = FactSet::new()
320 .with_node(caller)
321 .with_node(target)
322 .with_node(doc)
323 .with_edge(Edge::derived(
324 "sym:rust:a.rs#caller",
325 "sym:rust:a.rs#callee",
326 EdgeKind::Calls,
327 ))
328 .with_edge(Edge::authored(
329 "file:docs/g.md",
330 "sym:rust:a.rs#caller",
331 EdgeKind::References,
332 ));
333 store.apply_factset(&facts).expect("apply");
334 store
335 }
336
337 #[test]
338 fn context_is_cached_then_served_from_cache() {
339 let store = seeded();
340 let first = context(&store, "sym:rust:a.rs#caller")
342 .expect("ctx")
343 .expect("present");
344 assert_eq!(first.node.key, "sym:rust:a.rs#caller");
345 assert_eq!(first.outgoing.len(), 1, "calls callee");
346 assert_eq!(first.incoming.len(), 1, "referenced by doc");
347 assert_eq!(
348 store
349 .context_cache_get("sym:rust:a.rs#caller")
350 .expect("get")
351 .expect("cached")
352 .0,
353 first.fingerprint,
354 );
355 let second = context(&store, "sym:rust:a.rs#caller")
357 .expect("ctx")
358 .expect("present");
359 assert_eq!(first, second);
360 }
361
362 #[test]
363 fn changing_a_dependency_invalidates_dependent_context() {
364 let store = seeded();
365 let before = refresh_first_read(&store);
367 assert_eq!(before.rebuilt, 0, "warming reads are misses, not refreshes");
368
369 let caller_fp_before = context(&store, "sym:rust:a.rs#caller")
371 .expect("ctx")
372 .expect("present")
373 .fingerprint;
374
375 let mut target = store
377 .get_node("sym:rust:a.rs#callee")
378 .expect("get")
379 .expect("present");
380 target.blob_hash = Some("BLOB_A2".to_owned());
381 store.upsert_node(&target).expect("upsert");
382
383 let deps = dependents(&store, &["sym:rust:a.rs#callee".to_owned()]).expect("deps");
386 assert!(deps.contains("sym:rust:a.rs#caller"));
387
388 let caller_fp_after = context(&store, "sym:rust:a.rs#caller")
391 .expect("ctx")
392 .expect("present")
393 .fingerprint;
394 assert_ne!(
395 caller_fp_before, caller_fp_after,
396 "dependent context must be invalidated when a dependency changes",
397 );
398
399 let report = refresh_contexts(&store).expect("refresh");
402 assert!(report.rebuilt >= 1, "affected contexts rebuilt");
403 assert_eq!(report.pruned, 0);
404 }
405
406 fn refresh_first_read(store: &Store) -> super::ContextRefresh {
409 for key in store.all_keys().expect("keys") {
410 context(store, &key).expect("ctx");
411 }
412 refresh_contexts(store).expect("refresh")
413 }
414
415 #[test]
416 fn deleted_node_context_is_pruned() {
417 let mut store = seeded();
418 context(&store, "file:docs/g.md")
419 .expect("ctx")
420 .expect("present");
421 assert!(
422 store
423 .context_cache_get("file:docs/g.md")
424 .expect("get")
425 .is_some()
426 );
427 let mut caller = Node::new("sym:rust:a.rs#caller", NodeKind::Fn, "caller");
429 caller.blob_hash = Some("BLOB_A".to_owned());
430 store
431 .rebuild(&FactSet::new().with_node(caller), None)
432 .expect("rebuild");
433 assert!(context(&store, "file:docs/g.md").expect("ctx").is_none());
436 assert!(
437 store
438 .context_cache_get("file:docs/g.md")
439 .expect("get")
440 .is_none()
441 );
442 }
443
444 #[test]
445 fn build_context_matches_cached() {
446 let store = seeded();
447 let built = build_context(&store, "sym:rust:a.rs#caller")
448 .expect("build")
449 .expect("present");
450 let cached = context(&store, "sym:rust:a.rs#caller")
451 .expect("ctx")
452 .expect("present");
453 assert_eq!(built, cached);
454 assert!(
455 build_context(&store, "sym:rust:a.rs#ghost")
456 .expect("b")
457 .is_none()
458 );
459 }
460}