Skip to main content

gonzalo_graph/
assembly.rs

1//! Assemble a view's manifest into a queryable graph (ADR 0012, ticket C1).
2//!
3//! The manifest (`path -> content_hash`) is the identity layer; the blob store
4//! holds the path-agnostic slices. Assembly fetches each referenced slice and
5//! inserts it under its manifest path, re-attaching the path the slice itself
6//! does not carry.
7
8use crate::{CodeGraph, GraphStore, InMemoryGraphStore};
9use gonzalo_core::{BlobStore, CoreError, Manifest, Result};
10
11/// Assemble `manifest` into an in-memory graph by fetching each slice from
12/// `blobs` and inserting it under its path.
13///
14/// **Tolerates missing targets** (ADR 0012): a manifest entry whose blob is not
15/// present is an honest dangling reference — skipped, not an error — so a view
16/// mid-sync still assembles the slices it does have. A blob that exists but is
17/// not a valid serialized slice *is* an error (corrupt store).
18pub async fn assemble<B: BlobStore + ?Sized>(
19    manifest: &Manifest,
20    blobs: &B,
21) -> Result<InMemoryGraphStore> {
22    let mut store = InMemoryGraphStore::new();
23    for (path, hash) in &manifest.entries {
24        if let Some(bytes) = blobs.get_blob(hash).await? {
25            let graph =
26                CodeGraph::from_slice_bytes(&bytes).map_err(|e| CoreError::Serde(e.to_string()))?;
27            store.insert(path, graph);
28        }
29    }
30    Ok(store)
31}
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36    use crate::build_rust;
37    use gonzalo_store_fs::FsStore;
38
39    fn fresh_store() -> FsStore {
40        let dir = tempfile::tempdir().expect("tempdir");
41        FsStore::new(dir.keep())
42    }
43
44    /// Store a slice's bytes and return its content hash.
45    async fn put_slice(blobs: &FsStore, src: &str) -> gonzalo_core::ContentHash {
46        blobs
47            .put_blob(&build_rust(src).to_slice_bytes())
48            .await
49            .unwrap()
50    }
51
52    #[tokio::test]
53    async fn assembles_slices_under_their_manifest_paths() {
54        let blobs = fresh_store();
55        let lib = put_slice(&blobs, "fn helper() {}").await;
56        let main = put_slice(&blobs, "fn main() { helper(); }").await;
57
58        let mut manifest = Manifest::new();
59        manifest.insert("src/lib.rs", lib);
60        manifest.insert("src/main.rs", main);
61
62        let graph = assemble(&manifest, &blobs).await.unwrap();
63
64        // Definition resolves to the path the manifest placed it under.
65        let defs = graph.definitions("helper");
66        assert_eq!(defs.len(), 1);
67        assert_eq!(defs[0].path, "src/lib.rs");
68        // The cross-file call is present and attributed to the calling file.
69        let callers = graph.callers_of("helper");
70        assert_eq!(callers, vec!["main".to_string()]);
71        assert!(
72            graph
73                .symbols_in_file("src/main.rs")
74                .iter()
75                .any(|s| s.name == "main")
76        );
77    }
78
79    #[tokio::test]
80    async fn tolerates_a_missing_slice_blob() {
81        let blobs = fresh_store();
82        let present = put_slice(&blobs, "fn present() {}").await;
83
84        let mut manifest = Manifest::new();
85        manifest.insert("present.rs", present);
86        // A manifest entry whose slice was never stored (or already GC'd).
87        manifest.insert("missing.rs", gonzalo_core::ContentHash::of(b"never stored"));
88
89        let graph = assemble(&manifest, &blobs).await.unwrap();
90        assert_eq!(graph.definitions("present").len(), 1);
91        assert!(graph.symbols_in_file("missing.rs").is_empty());
92    }
93
94    #[tokio::test]
95    async fn path_comes_from_manifest_so_identical_content_dedups() {
96        let blobs = fresh_store();
97        // The same file content assembled under two paths: one stored blob,
98        // two manifest entries — the path is supplied at assembly, not baked in.
99        let hash = put_slice(&blobs, "fn shared() {}").await;
100        let same = put_slice(&blobs, "fn shared() {}").await;
101        assert_eq!(hash, same, "identical content must dedup to one blob");
102
103        let mut manifest = Manifest::new();
104        manifest.insert("a.rs", hash.clone());
105        manifest.insert("vendor/a.rs", hash);
106
107        let graph = assemble(&manifest, &blobs).await.unwrap();
108        let mut paths: Vec<String> = graph
109            .definitions("shared")
110            .into_iter()
111            .map(|l| l.path)
112            .collect();
113        paths.sort();
114        assert_eq!(paths, vec!["a.rs".to_string(), "vendor/a.rs".to_string()]);
115    }
116
117    #[tokio::test]
118    async fn empty_manifest_assembles_empty_graph() {
119        let blobs = fresh_store();
120        let graph = assemble(&Manifest::new(), &blobs).await.unwrap();
121        assert!(graph.slices().is_empty());
122    }
123}