Skip to main content

gonzalo_core/
manifest.rs

1//! Per-view code-graph manifests: the identity layer over content-addressed
2//! slices (ADR 0012).
3//!
4//! A manifest maps every path in a view to the [`ContentHash`] of the slice
5//! that currently populates it: `(repo, view_id) -> { path -> content_hash }`.
6//! Slices are content-addressed and shared across worktrees; the manifest is
7//! what gives a *view* its identity and lets assembly resolve `path -> slice`.
8//! It is regenerable from source, so a divergence is reconciled last-writer-wins
9//! ([`MergeClass::Derived`]) rather than surfaced as a conflict.
10//!
11//! [`MergeClass::Derived`]: crate::MergeClass::Derived
12
13use crate::{Body, ContentHash, CoreError, RecordKey, Result};
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16
17/// The collection segment under which every view's manifest is addressed.
18const MANIFEST_COLLECTION: &str = "graph-manifest";
19
20/// A per-view manifest body: path -> the content hash of the populating slice.
21///
22/// Backed by a [`BTreeMap`] so serialization has deterministic key order — a
23/// manifest with the same entries always hashes identically, which keeps its
24/// record revision stable under content-addressed dedup.
25#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct Manifest {
27    pub entries: BTreeMap<String, ContentHash>,
28}
29
30impl Manifest {
31    /// An empty manifest.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// The stable [`RecordKey`] addressing the manifest for `(repo, view_id)`.
37    /// A view has exactly one manifest, so this is a pure function of the pair.
38    pub fn key(repo: impl Into<String>, view_id: impl Into<String>) -> RecordKey {
39        RecordKey::new(repo, MANIFEST_COLLECTION, view_id)
40    }
41
42    /// The collection segment every manifest is addressed under. A
43    /// [`KeyPrefix`](crate::KeyPrefix) with this collection and no namespace
44    /// lists every view's manifest across all repos — the set GC must union to
45    /// mark live slices.
46    pub fn collection() -> &'static str {
47        MANIFEST_COLLECTION
48    }
49
50    /// Record that `path` is populated by the slice with content hash `hash`.
51    pub fn insert(&mut self, path: impl Into<String>, hash: ContentHash) {
52        self.entries.insert(path.into(), hash);
53    }
54
55    /// The content hash of the slice populating `path`, if the view has one.
56    pub fn get(&self, path: &str) -> Option<&ContentHash> {
57        self.entries.get(path)
58    }
59
60    /// Serialize into an inline record [`Body`] (deterministic key order).
61    pub fn to_body(&self) -> Body {
62        Body::Inline(
63            serde_json::to_vec(&self.entries).expect("BTreeMap<String, ContentHash> serializes"),
64        )
65    }
66
67    /// Reconstruct a manifest from a record [`Body`]. Errors if the body bytes
68    /// are not a valid serialized manifest.
69    pub fn from_body(body: &Body) -> Result<Self> {
70        let entries =
71            serde_json::from_slice(body.bytes()).map_err(|e| CoreError::Serde(e.to_string()))?;
72        Ok(Self { entries })
73    }
74
75    /// Reconcile this manifest against the `desired` `path -> content_hash` set
76    /// of a working tree, returning the change sets and the reconciled manifest.
77    ///
78    /// The A/M/D classification is a pure **set difference**, so it is robust to
79    /// missed events — a full reconcile always converges the manifest onto the
80    /// tree regardless of how the desired set was sourced (a `git diff` stream is
81    /// only an optimization for building it). Unchanged paths (present in both
82    /// with an equal hash) appear in none of the change sets. Each set is sorted
83    /// for deterministic output, and the reconciled manifest equals `desired`.
84    pub fn reconcile(&self, desired: &BTreeMap<String, ContentHash>) -> Reconciliation {
85        let mut added = Vec::new();
86        let mut modified = Vec::new();
87        for (path, hash) in desired {
88            match self.entries.get(path) {
89                None => added.push(path.clone()),
90                Some(current) if current != hash => modified.push(path.clone()),
91                Some(_) => {} // unchanged
92            }
93        }
94        let deleted = self
95            .entries
96            .keys()
97            .filter(|path| !desired.contains_key(*path))
98            .cloned()
99            .collect();
100        // BTreeMap iteration is already key-sorted, so `added`/`modified`/
101        // `deleted` come out sorted without an explicit sort.
102        Reconciliation {
103            added,
104            modified,
105            deleted,
106            manifest: Manifest {
107                entries: desired.clone(),
108            },
109        }
110    }
111}
112
113/// Build the desired `path -> content_hash` set for a working tree by hashing
114/// each file's content. The output feeds [`Manifest::reconcile`].
115pub fn desired_set<P, C>(entries: impl IntoIterator<Item = (P, C)>) -> BTreeMap<String, ContentHash>
116where
117    P: Into<String>,
118    C: AsRef<[u8]>,
119{
120    entries
121        .into_iter()
122        .map(|(path, content)| (path.into(), ContentHash::of(content.as_ref())))
123        .collect()
124}
125
126/// The result of reconciling a [`Manifest`] against a working tree: the change
127/// sets (each sorted) plus the reconciled manifest, which equals the tree.
128#[derive(Clone, Debug, Default, PartialEq, Eq)]
129pub struct Reconciliation {
130    /// Paths present in the tree but not the old manifest.
131    pub added: Vec<String>,
132    /// Paths in both whose content hash changed.
133    pub modified: Vec<String>,
134    /// Paths in the old manifest but no longer in the tree.
135    pub deleted: Vec<String>,
136    /// The manifest after reconciliation (equal to the desired tree set).
137    pub manifest: Manifest,
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::RecordKind;
144
145    fn hash(s: &str) -> ContentHash {
146        ContentHash::of(s.as_bytes())
147    }
148
149    #[test]
150    fn key_addresses_repo_and_view_as_namespace_and_id() {
151        let k = Manifest::key("acme/widgets", "main");
152        assert_eq!(k.namespace, "acme/widgets");
153        assert_eq!(k.collection, MANIFEST_COLLECTION);
154        assert_eq!(k.id, "main");
155    }
156
157    #[test]
158    fn key_is_stable_for_the_same_repo_and_view() {
159        assert_eq!(Manifest::key("r", "v"), Manifest::key("r", "v"));
160        assert_ne!(Manifest::key("r", "v"), Manifest::key("r", "w"));
161        assert_ne!(Manifest::key("r", "v"), Manifest::key("s", "v"));
162    }
163
164    #[test]
165    fn insert_and_get_resolve_path_to_slice_hash() {
166        let mut m = Manifest::new();
167        m.insert("src/lib.rs", hash("slice-a"));
168        assert_eq!(m.get("src/lib.rs"), Some(&hash("slice-a")));
169        assert_eq!(m.get("src/absent.rs"), None);
170    }
171
172    #[test]
173    fn body_round_trips() {
174        let mut m = Manifest::new();
175        m.insert("src/main.rs", hash("s1"));
176        m.insert("src/lib.rs", hash("s2"));
177
178        let restored = Manifest::from_body(&m.to_body()).unwrap();
179        assert_eq!(restored, m);
180    }
181
182    #[test]
183    fn body_bytes_are_deterministic_regardless_of_insert_order() {
184        // BTreeMap key order -> the same entries always serialize identically,
185        // so two independently-built manifests with equal contents share one
186        // revision under content-addressed dedup.
187        let mut a = Manifest::new();
188        a.insert("b.rs", hash("x"));
189        a.insert("a.rs", hash("y"));
190
191        let mut b = Manifest::new();
192        b.insert("a.rs", hash("y"));
193        b.insert("b.rs", hash("x"));
194
195        assert_eq!(a.to_body().bytes(), b.to_body().bytes());
196    }
197
198    #[test]
199    fn from_body_rejects_non_manifest_bytes() {
200        let garbage = Body::Inline(b"not json at all".to_vec());
201        assert!(matches!(
202            Manifest::from_body(&garbage),
203            Err(CoreError::Serde(_))
204        ));
205    }
206
207    #[test]
208    fn manifest_kind_is_derived() {
209        assert_eq!(
210            RecordKind::GraphManifest.merge_class(),
211            crate::MergeClass::Derived
212        );
213    }
214
215    #[test]
216    fn desired_set_hashes_each_tree_entry() {
217        let desired = desired_set([("a.rs", "one"), ("b.rs", "two")]);
218        assert_eq!(desired.get("a.rs"), Some(&hash("one")));
219        assert_eq!(desired.get("b.rs"), Some(&hash("two")));
220        assert_eq!(desired.len(), 2);
221    }
222
223    #[test]
224    fn reconcile_classifies_added_modified_deleted() {
225        let mut current = Manifest::new();
226        current.insert("keep.rs", hash("same")); // unchanged
227        current.insert("edit.rs", hash("old")); // modified
228        current.insert("gone.rs", hash("bye")); // deleted
229
230        let desired = desired_set([("keep.rs", "same"), ("edit.rs", "new"), ("add.rs", "fresh")]);
231
232        let r = current.reconcile(&desired);
233        assert_eq!(r.added, vec!["add.rs".to_string()]);
234        assert_eq!(r.modified, vec!["edit.rs".to_string()]);
235        assert_eq!(r.deleted, vec!["gone.rs".to_string()]);
236    }
237
238    #[test]
239    fn reconciled_manifest_equals_the_desired_tree() {
240        let mut current = Manifest::new();
241        current.insert("gone.rs", hash("bye"));
242        let desired = desired_set([("add.rs", "fresh")]);
243
244        let r = current.reconcile(&desired);
245        assert_eq!(r.manifest.entries, desired);
246    }
247
248    #[test]
249    fn reconcile_reports_nothing_when_tree_matches_manifest() {
250        let mut current = Manifest::new();
251        current.insert("a.rs", hash("x"));
252        current.insert("b.rs", hash("y"));
253        let desired = desired_set([("a.rs", "x"), ("b.rs", "y")]);
254
255        let r = current.reconcile(&desired);
256        assert!(r.added.is_empty());
257        assert!(r.modified.is_empty());
258        assert!(r.deleted.is_empty());
259        assert_eq!(r.manifest.entries, current.entries);
260    }
261
262    #[test]
263    fn reconcile_from_empty_marks_all_added() {
264        let desired = desired_set([("b.rs", "2"), ("a.rs", "1")]);
265        let r = Manifest::new().reconcile(&desired);
266        // Sorted, deterministic.
267        assert_eq!(r.added, vec!["a.rs".to_string(), "b.rs".to_string()]);
268        assert!(r.modified.is_empty());
269        assert!(r.deleted.is_empty());
270    }
271
272    #[test]
273    fn reconcile_to_empty_marks_all_deleted() {
274        let mut current = Manifest::new();
275        current.insert("b.rs", hash("2"));
276        current.insert("a.rs", hash("1"));
277
278        let r = current.reconcile(&BTreeMap::new());
279        assert_eq!(r.deleted, vec!["a.rs".to_string(), "b.rs".to_string()]);
280        assert!(r.added.is_empty());
281        assert!(r.modified.is_empty());
282        assert!(r.manifest.entries.is_empty());
283    }
284}