Skip to main content

repo/
repository_context.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context annotation helpers for attaching metadata to file and state targets.
3
4use std::{
5    collections::BTreeMap,
6    path::{Component, Path, PathBuf},
7};
8
9use objects::{
10    object::{
11        Annotation, AnnotationScope, Blob, ContentHash, ContextBlob, ContextTarget, EntryType,
12        State, Tree, TreeEntry,
13    },
14    store::ObjectStore,
15};
16
17use super::{HeddleError, Repository, Result};
18
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct ContextEntry {
21    pub target: ContextTarget,
22    pub blob: ContextBlob,
23}
24
25impl Repository {
26    /// Get the context blob for a target from the given state's context tree.
27    pub fn get_context_blob(
28        &self,
29        context_root: &ContentHash,
30        target: &ContextTarget,
31    ) -> Result<Option<ContextBlob>> {
32        let Some(blob_hash) = self.lookup_context_leaf_for_target(context_root, target)? else {
33            return Ok(None);
34        };
35        let Some(blob) = self.store.get_blob(&blob_hash)? else {
36            return Ok(None);
37        };
38        ContextBlob::decode(blob.content())
39            .map(Some)
40            .map_err(|e| HeddleError::InvalidObject(format!("invalid context blob: {e}")))
41    }
42
43    /// Store a context blob at a target, returning the new context tree root hash.
44    ///
45    /// If `context_root` is None, creates a new context tree from scratch.
46    pub fn set_context_blob(
47        &self,
48        context_root: Option<&ContentHash>,
49        target: &ContextTarget,
50        blob: &ContextBlob,
51    ) -> Result<ContentHash> {
52        let bytes = blob
53            .encode()
54            .map_err(|e| HeddleError::InvalidObject(format!("encode context: {e}")))?;
55        let blob_hash = self.store.put_blob(&Blob::new(bytes))?;
56
57        let current_tree = match context_root {
58            Some(root) => self.require_tree(root)?,
59            None => Tree::new(),
60        };
61
62        self.insert_leaf_at_path(&current_tree, &target.storage_path(), blob_hash)
63    }
64
65    /// Remove context at a target (optionally filtered by scope).
66    ///
67    /// Returns the new context tree root, or None if the tree is now empty.
68    pub fn remove_context_at_target(
69        &self,
70        context_root: &ContentHash,
71        target: &ContextTarget,
72        scope: Option<&AnnotationScope>,
73    ) -> Result<Option<ContentHash>> {
74        if let Some(scope) = scope {
75            if let Some(mut blob) = self.get_context_blob(context_root, target)? {
76                blob.annotations.retain(|a| !a.scope.matches(scope));
77                if blob.annotations.is_empty() {
78                    return self.remove_context_target(context_root, target);
79                }
80                let new_root = self.set_context_blob(Some(context_root), target, &blob)?;
81                return Ok(Some(new_root));
82            }
83            return Ok(Some(*context_root));
84        }
85
86        self.remove_context_target(context_root, target)
87    }
88
89    pub fn remove_context_target(
90        &self,
91        context_root: &ContentHash,
92        target: &ContextTarget,
93    ) -> Result<Option<ContentHash>> {
94        self.remove_leaf_at_path(context_root, &target.storage_path())
95    }
96
97    /// List all context entries in the tree, optionally filtered by file prefix.
98    pub fn list_context_entries(
99        &self,
100        context_root: &ContentHash,
101        prefix: Option<&Path>,
102    ) -> Result<Vec<ContextEntry>> {
103        let tree = match self.store.get_tree(context_root)? {
104            Some(t) => t,
105            None => return Ok(Vec::new()),
106        };
107        let mut results = BTreeMap::new();
108        self.walk_context_tree(
109            &tree,
110            &PathBuf::new(),
111            prefix,
112            &mut results,
113            ContextWalkMode::CanonicalOnly,
114        )?;
115        Ok(results
116            .into_iter()
117            .map(|(_, (target, blob))| ContextEntry { target, blob })
118            .collect())
119    }
120
121    pub fn find_annotation(
122        &self,
123        context_root: &ContentHash,
124        annotation_id: &str,
125    ) -> Result<Option<(ContextTarget, ContextBlob, usize)>> {
126        for entry in self.list_context_entries(context_root, None)? {
127            if let Some(index) = entry
128                .blob
129                .annotations
130                .iter()
131                .position(|annotation| annotation.annotation_id == annotation_id)
132            {
133                return Ok(Some((entry.target, entry.blob, index)));
134            }
135        }
136        Ok(None)
137    }
138
139    pub(crate) fn canonicalize_context_root(
140        &self,
141        context_root: &ContentHash,
142    ) -> Result<(ContentHash, bool)> {
143        let mut root = *context_root;
144        let mut changed = false;
145
146        for entry in self.list_context_entries_for_migration(context_root)? {
147            let Some(legacy_path) = legacy_storage_path_for_target(&entry.target) else {
148                continue;
149            };
150            if legacy_path == entry.target.storage_path() {
151                continue;
152            }
153            let Some(legacy_hash) = self.lookup_context_leaf(&root, &legacy_path)? else {
154                continue;
155            };
156
157            if self
158                .lookup_context_leaf(&root, &entry.target.storage_path())?
159                .is_some()
160            {
161                if let Some(new_root) = self.remove_leaf_at_path(&root, &legacy_path)? {
162                    root = new_root;
163                }
164                changed = true;
165                continue;
166            }
167
168            let Some(blob) = self.store.get_blob(&legacy_hash)? else {
169                continue;
170            };
171            let context = ContextBlob::decode(blob.content())
172                .map_err(|e| HeddleError::InvalidObject(format!("invalid context blob: {e}")))?;
173            let updated = self.set_context_blob(Some(&root), &entry.target, &context)?;
174            root = self
175                .remove_leaf_at_path(&updated, &legacy_path)?
176                .unwrap_or(updated);
177            changed = true;
178        }
179
180        Ok((root, changed))
181    }
182
183    fn list_context_entries_for_migration(
184        &self,
185        context_root: &ContentHash,
186    ) -> Result<Vec<ContextEntry>> {
187        let tree = match self.store.get_tree(context_root)? {
188            Some(t) => t,
189            None => return Ok(Vec::new()),
190        };
191        let mut results = BTreeMap::new();
192        self.walk_context_tree(
193            &tree,
194            &PathBuf::new(),
195            None,
196            &mut results,
197            ContextWalkMode::IncludeLegacyDirectPaths,
198        )?;
199        Ok(results
200            .into_iter()
201            .map(|(_, (target, blob))| ContextEntry { target, blob })
202            .collect())
203    }
204
205    // --- private helpers ---
206
207    fn lookup_context_leaf_for_target(
208        &self,
209        root: &ContentHash,
210        target: &ContextTarget,
211    ) -> Result<Option<ContentHash>> {
212        self.lookup_context_leaf(root, &target.storage_path())
213    }
214
215    fn lookup_context_leaf(&self, root: &ContentHash, path: &Path) -> Result<Option<ContentHash>> {
216        let Some((name, rest)) = split_path(path) else {
217            return Ok(None);
218        };
219        let Some(tree) = self.store.get_tree(root)? else {
220            return Ok(None);
221        };
222        let Some(entry) = tree.get(name) else {
223            return Ok(None);
224        };
225        if rest.as_os_str().is_empty() {
226            return Ok(entry.blob_hash());
227        }
228        if !entry.is_tree() {
229            return Ok(None);
230        }
231        let Some(tree_hash) = entry.tree_hash() else {
232            return Ok(None);
233        };
234        self.lookup_context_leaf(&tree_hash, rest)
235    }
236
237    fn insert_leaf_at_path(
238        &self,
239        tree: &Tree,
240        path: &Path,
241        blob_hash: ContentHash,
242    ) -> Result<ContentHash> {
243        let Some((name, rest)) = split_path(path) else {
244            return Err(HeddleError::InvalidObject("empty path".to_string()));
245        };
246
247        let mut new_tree = tree.clone();
248
249        if rest.as_os_str().is_empty() {
250            new_tree.insert(TreeEntry::file(name, blob_hash, false)?);
251        } else {
252            let subtree = tree
253                .get(name)
254                .filter(|e| e.is_tree())
255                .and_then(|e| e.tree_hash())
256                .and_then(|hash| self.store.get_tree(&hash).ok().flatten())
257                .unwrap_or_default();
258
259            let sub_hash = self.insert_leaf_at_path(&subtree, rest, blob_hash)?;
260            new_tree.insert(TreeEntry::directory(name, sub_hash)?);
261        }
262
263        self.store.put_tree(&new_tree)
264    }
265
266    fn remove_leaf_at_path(&self, root: &ContentHash, path: &Path) -> Result<Option<ContentHash>> {
267        let Some(tree) = self.store.get_tree(root)? else {
268            return Ok(None);
269        };
270        let Some((name, rest)) = split_path(path) else {
271            return Ok(None);
272        };
273
274        let mut new_tree = tree.clone();
275
276        if rest.as_os_str().is_empty() {
277            new_tree.remove(name);
278        } else {
279            let Some(entry) = tree.get(name) else {
280                return Ok(Some(*root));
281            };
282            if !entry.is_tree() {
283                return Ok(Some(*root));
284            }
285            let Some(tree_hash) = entry.tree_hash() else {
286                return Ok(Some(*root));
287            };
288            match self.remove_leaf_at_path(&tree_hash, rest)? {
289                Some(sub_hash) => {
290                    new_tree.insert(TreeEntry::directory(name, sub_hash)?);
291                }
292                None => {
293                    new_tree.remove(name);
294                }
295            }
296        }
297
298        if new_tree.is_empty() {
299            Ok(None)
300        } else {
301            Ok(Some(self.store.put_tree(&new_tree)?))
302        }
303    }
304
305    fn walk_context_tree(
306        &self,
307        tree: &Tree,
308        current_path: &Path,
309        prefix: Option<&Path>,
310        results: &mut BTreeMap<String, (ContextTarget, ContextBlob)>,
311        mode: ContextWalkMode,
312    ) -> Result<()> {
313        for entry in tree.entries() {
314            let entry_path = current_path.join(entry.name());
315            match entry.entry_type() {
316                EntryType::Tree => {
317                    if let Some(prefix) = prefix
318                        && !prefix.starts_with(&entry_path)
319                        && !entry_path.starts_with(prefix)
320                        && !entry_path.starts_with("__files")
321                        && !entry_path.starts_with("__states")
322                    {
323                        continue;
324                    }
325                    if let Some(tree_hash) = entry.tree_hash()
326                        && let Some(subtree) = self.store.get_tree(&tree_hash)?
327                    {
328                        self.walk_context_tree(&subtree, &entry_path, prefix, results, mode)?;
329                    }
330                }
331                EntryType::Blob => {
332                    let Some(target) = context_target_from_entry_path(&entry_path, mode) else {
333                        continue;
334                    };
335                    if let Some(prefix) = prefix
336                        && let Some(path) = target.path()
337                        && !Path::new(path).starts_with(prefix)
338                    {
339                        continue;
340                    }
341                    if let Some(blob_hash) = entry.blob_hash()
342                        && let Some(blob) = self.store.get_blob(&blob_hash)?
343                        && let Ok(context) = ContextBlob::decode(blob.content())
344                    {
345                        results.insert(context_entry_key(&target), (target, context));
346                    }
347                }
348                EntryType::Symlink | EntryType::Gitlink | EntryType::Spoollink => {}
349            }
350        }
351        Ok(())
352    }
353
354    /// Carry a single parent state's context tree forward onto a new
355    /// snapshot. Because context trees are content-addressed, this is a
356    /// pointer copy: the new state's `context` field gets the same
357    /// `ContentHash` as the parent. Annotations attached upstream remain
358    /// active at the new state, and the existing on-demand staleness check
359    /// (which compares the stored `source_hash` against the current bytes
360    /// at the anchor) naturally reports drift caused by the new tree.
361    ///
362    /// Returns `None` when the parent has no context tree.
363    pub fn inherit_parent_context(parent: &State) -> Option<ContentHash> {
364        parent.context
365    }
366
367    /// Build a unioned context tree across multiple parent states for a
368    /// merge snapshot. Annotations from every parent appear in the result;
369    /// when the same `annotation_id` is present on more than one parent the
370    /// revision with the latest `created_at` wins (with a stable tiebreak
371    /// on revision_id so the merge stays deterministic).
372    ///
373    /// Targets that only exist on one side propagate unchanged (single-blob
374    /// pointer copy via the existing tree). Targets present on both sides
375    /// are merged blob-by-blob: annotations are deduped by id, the per-id
376    /// revisions are picked by latest-`created_at`, and the resulting blob
377    /// is rewritten via `set_context_blob`.
378    ///
379    /// Returns `None` when none of the parents has any context.
380    pub fn union_parent_contexts(&self, parents: &[&State]) -> Result<Option<ContentHash>> {
381        // Fast paths: nothing or single-parent.
382        let mut roots: Vec<ContentHash> = parents.iter().filter_map(|p| p.context).collect();
383        if roots.is_empty() {
384            return Ok(None);
385        }
386        if roots.len() == 1 {
387            return Ok(Some(roots.pop().expect("len == 1")));
388        }
389        if roots.iter().all(|r| *r == roots[0]) {
390            // All parents pointed at the same context tree; pointer copy.
391            return Ok(Some(roots[0]));
392        }
393
394        // Walk every parent and merge by `context_entry_key`. Each entry's
395        // blob gets unioned into a running map; ties are broken by the
396        // revision-comparator below.
397        let mut merged: BTreeMap<String, (ContextTarget, ContextBlob)> = BTreeMap::new();
398        for parent_root in &roots {
399            for entry in self.list_context_entries(parent_root, None)? {
400                let key = context_entry_key(&entry.target);
401                match merged.remove(&key) {
402                    None => {
403                        merged.insert(key, (entry.target, entry.blob));
404                    }
405                    Some((target, existing)) => {
406                        let merged_blob = merge_context_blobs(existing, entry.blob);
407                        merged.insert(key, (target, merged_blob));
408                    }
409                }
410            }
411        }
412
413        if merged.is_empty() {
414            return Ok(None);
415        }
416
417        // Rebuild the tree from scratch by writing each blob.
418        let mut root: Option<ContentHash> = None;
419        for (_, (target, blob)) in merged {
420            if blob.annotations.is_empty() {
421                continue;
422            }
423            let new_root = self.set_context_blob(root.as_ref(), &target, &blob)?;
424            root = Some(new_root);
425        }
426
427        Ok(root)
428    }
429}
430
431/// Merge two `ContextBlob`s by unioning their annotations on
432/// `annotation_id`. When an id appears in both, the annotation with the
433/// later current-revision `created_at` wins; ties are broken by
434/// `revision_id` to keep the result deterministic. The resulting blob
435/// preserves `format_version` from `left`.
436fn merge_context_blobs(left: ContextBlob, right: ContextBlob) -> ContextBlob {
437    let format_version = left.format_version;
438    let mut by_id: BTreeMap<String, Annotation> = BTreeMap::new();
439    for annotation in left.annotations.into_iter().chain(right.annotations) {
440        match by_id.remove(&annotation.annotation_id) {
441            None => {
442                by_id.insert(annotation.annotation_id.clone(), annotation);
443            }
444            Some(existing) => {
445                let winner = pick_newer_annotation(existing, annotation);
446                by_id.insert(winner.annotation_id.clone(), winner);
447            }
448        }
449    }
450    ContextBlob {
451        format_version,
452        annotations: by_id.into_values().collect(),
453    }
454}
455
456fn pick_newer_annotation(a: Annotation, b: Annotation) -> Annotation {
457    let ts_a = a
458        .current_revision()
459        .map(|r| r.created_at)
460        .unwrap_or(i64::MIN);
461    let ts_b = b
462        .current_revision()
463        .map(|r| r.created_at)
464        .unwrap_or(i64::MIN);
465    if ts_a > ts_b {
466        a
467    } else if ts_b > ts_a {
468        b
469    } else {
470        // Deterministic tiebreak on the revision_id of each side's current
471        // revision (lexicographic — revision_ids are stable strings).
472        let rev_a = a
473            .current_revision()
474            .map(|r| r.revision_id.as_str())
475            .unwrap_or("");
476        let rev_b = b
477            .current_revision()
478            .map(|r| r.revision_id.as_str())
479            .unwrap_or("");
480        if rev_a >= rev_b { a } else { b }
481    }
482}
483
484fn context_entry_key(target: &ContextTarget) -> String {
485    match target {
486        ContextTarget::File { path } => format!("file:{path}"),
487        ContextTarget::State { change_id } => format!("state:{}", change_id.to_string_full()),
488    }
489}
490
491#[derive(Clone, Copy)]
492enum ContextWalkMode {
493    CanonicalOnly,
494    IncludeLegacyDirectPaths,
495}
496
497fn context_target_from_entry_path(path: &Path, mode: ContextWalkMode) -> Option<ContextTarget> {
498    ContextTarget::from_storage_path(path).or_else(|| match mode {
499        ContextWalkMode::CanonicalOnly => None,
500        ContextWalkMode::IncludeLegacyDirectPaths => legacy_context_target_from_path(path),
501    })
502}
503
504fn legacy_context_target_from_path(path: &Path) -> Option<ContextTarget> {
505    match path.components().next()? {
506        Component::Normal(part) if part == "__files" || part == "__states" => None,
507        Component::Normal(_) => ContextTarget::file(path.to_string_lossy().to_string()).ok(),
508        _ => None,
509    }
510}
511
512fn legacy_storage_path_for_target(target: &ContextTarget) -> Option<PathBuf> {
513    match target {
514        ContextTarget::File { path } => Some(PathBuf::from(path)),
515        ContextTarget::State { .. } => None,
516    }
517}
518
519fn split_path(path: &Path) -> Option<(&str, &Path)> {
520    let mut components = path.components();
521    let first = components.next()?;
522    let std::path::Component::Normal(name) = first else {
523        return None;
524    };
525    Some((name.to_str()?, components.as_path()))
526}
527
528#[cfg(test)]
529mod tests {
530    use crypto::{Ed25519Signer, StateSigningExt};
531    use objects::object::{Annotation, AnnotationKind, Attribution, Blob, ChangeId, Principal};
532    use tempfile::TempDir;
533
534    use super::{Repository, *};
535
536    fn setup() -> (TempDir, Repository) {
537        let dir = TempDir::new().unwrap();
538        let repo = Repository::init_default(dir.path()).unwrap();
539        (dir, repo)
540    }
541
542    fn make_annotation(scope: AnnotationScope, content: &str) -> Annotation {
543        Annotation::new(
544            scope,
545            AnnotationKind::Rationale,
546            content.to_string(),
547            vec![],
548            "test@example.com".to_string(),
549            1700000000,
550            None,
551            None,
552        )
553    }
554
555    fn legacy_context_root(repo: &Repository, path: &str, blob: &ContextBlob) -> ContentHash {
556        let bytes = blob.encode().unwrap();
557        let blob_hash = repo.store.put_blob(&Blob::new(bytes)).unwrap();
558        repo.insert_leaf_at_path(&Tree::new(), Path::new(path), blob_hash)
559            .unwrap()
560    }
561
562    #[test]
563    fn get_and_set_context_blob_for_file_target() {
564        let (_dir, repo) = setup();
565        let target = ContextTarget::file("src/main.rs").unwrap();
566        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "Entry point")]);
567
568        let root = repo.set_context_blob(None, &target, &blob).unwrap();
569        let retrieved = repo.get_context_blob(&root, &target).unwrap().unwrap();
570
571        assert_eq!(retrieved, blob);
572    }
573
574    #[test]
575    fn supports_state_targets() {
576        let (_dir, repo) = setup();
577        let target = ContextTarget::state(ChangeId::generate());
578        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "review note")]);
579
580        let root = repo.set_context_blob(None, &target, &blob).unwrap();
581        let retrieved = repo.get_context_blob(&root, &target).unwrap().unwrap();
582        assert_eq!(retrieved, blob);
583    }
584
585    #[test]
586    fn remove_context_blob_by_scope() {
587        let (_dir, repo) = setup();
588        let target = ContextTarget::file("src/lib.rs").unwrap();
589        let blob = ContextBlob::new(vec![
590            make_annotation(AnnotationScope::File, "file-level"),
591            make_annotation(AnnotationScope::Lines(1, 10), "range-level"),
592        ]);
593
594        let root = repo.set_context_blob(None, &target, &blob).unwrap();
595        let new_root = repo
596            .remove_context_at_target(&root, &target, Some(&AnnotationScope::Lines(1, 10)))
597            .unwrap()
598            .unwrap();
599        let remaining = repo.get_context_blob(&new_root, &target).unwrap().unwrap();
600
601        assert_eq!(remaining.annotations.len(), 1);
602        assert_eq!(
603            remaining
604                .annotations
605                .first()
606                .unwrap()
607                .current_revision()
608                .unwrap()
609                .content,
610            "file-level"
611        );
612    }
613
614    #[test]
615    fn list_context_entries_filters_by_prefix() {
616        let (_dir, repo) = setup();
617        let target1 = ContextTarget::file("src/main.rs").unwrap();
618        let target2 = ContextTarget::file("src/lib.rs").unwrap();
619        let target3 = ContextTarget::file("tests/test.rs").unwrap();
620        let blob1 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "first")]);
621        let blob2 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "second")]);
622        let blob3 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "third")]);
623
624        let root1 = repo.set_context_blob(None, &target1, &blob1).unwrap();
625        let root2 = repo
626            .set_context_blob(Some(&root1), &target2, &blob2)
627            .unwrap();
628        let root3 = repo
629            .set_context_blob(Some(&root2), &target3, &blob3)
630            .unwrap();
631
632        let all = repo.list_context_entries(&root3, None).unwrap();
633        assert_eq!(all.len(), 3);
634
635        let src_only = repo
636            .list_context_entries(&root3, Some(Path::new("src")))
637            .unwrap();
638        assert_eq!(src_only.len(), 2);
639
640        let exact_root_file = repo
641            .list_context_entries(&root3, Some(Path::new("tests/test.rs")))
642            .unwrap();
643        assert_eq!(exact_root_file.len(), 1);
644    }
645
646    #[test]
647    fn legacy_direct_file_context_is_migration_only() {
648        let (_dir, repo) = setup();
649        let target = ContextTarget::file("src/main.rs").unwrap();
650        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "legacy")]);
651        let root = legacy_context_root(&repo, "src/main.rs", &blob);
652
653        assert_eq!(repo.get_context_blob(&root, &target).unwrap(), None);
654
655        let entries = repo.list_context_entries(&root, None).unwrap();
656        assert!(entries.is_empty());
657
658        let (canonical_root, changed) = repo.canonicalize_context_root(&root).unwrap();
659        assert!(changed);
660        assert_eq!(
661            repo.get_context_blob(&canonical_root, &target).unwrap(),
662            Some(blob)
663        );
664        let tree = repo.store.get_tree(&canonical_root).unwrap().unwrap();
665        assert!(tree.get("src").is_none());
666        assert!(tree.get("__files").is_some());
667    }
668
669    #[test]
670    fn direct_context_canonicalization_requires_signature_decision() {
671        let (_dir, repo) = setup();
672        let target = ContextTarget::file("src/main.rs").unwrap();
673        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "legacy")]);
674        let legacy_root = legacy_context_root(&repo, "src/main.rs", &blob);
675        let canonical_root = repo.set_context_blob(None, &target, &blob).unwrap();
676        assert_ne!(
677            legacy_root, canonical_root,
678            "legacy direct-path and canonical context roots must be distinct fixtures",
679        );
680
681        let signer = Ed25519Signer::generate().expect("generate signer");
682        let mut state = State::new_snapshot(
683            ContentHash::compute(b"tree"),
684            vec![],
685            Attribution::human(Principal::new("Test", "test@example.com")),
686        )
687        .with_context(legacy_root);
688        state.sign(&signer).expect("sign legacy-context state");
689        state
690            .verify_signature()
691            .expect("legacy context state verifies before rewrite");
692
693        let rewritten = state.clone().with_context(canonical_root);
694        rewritten
695            .verify_signature()
696            .expect_err("canonicalizing context root without re-signing invalidates the signature");
697    }
698
699    #[test]
700    fn find_annotation_returns_target_and_index() {
701        let (_dir, repo) = setup();
702        let target = ContextTarget::file("src/main.rs").unwrap();
703        let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "first")]);
704        let annotation_id = blob.annotations[0].annotation_id.clone();
705        let root = repo.set_context_blob(None, &target, &blob).unwrap();
706
707        let found = repo
708            .find_annotation(&root, &annotation_id)
709            .unwrap()
710            .unwrap();
711        assert_eq!(found.0, target);
712        assert_eq!(found.2, 0);
713    }
714
715    /// Build a synthetic State whose `context` field points at a freshly
716    /// rooted context tree containing the given annotations on a single
717    /// file target. The state's `tree` is left at its default — the helpers
718    /// under test never inspect it.
719    fn state_with_context(repo: &Repository, path: &str, anns: Vec<Annotation>) -> State {
720        let target = ContextTarget::file(path).unwrap();
721        let blob = ContextBlob::new(anns);
722        let root = repo.set_context_blob(None, &target, &blob).unwrap();
723        let mut state = State::new_snapshot(
724            ContentHash::compute(b""),
725            vec![],
726            objects::object::Attribution::human(objects::object::Principal::new(
727                "test",
728                "test@example.com",
729            )),
730        );
731        state = state.with_context(root);
732        state
733    }
734
735    fn ann_with_id(id: &str, content: &str, created_at: i64) -> Annotation {
736        let mut a = Annotation::new(
737            AnnotationScope::File,
738            AnnotationKind::Rationale,
739            content.to_string(),
740            vec![],
741            "test@example.com".to_string(),
742            created_at,
743            None,
744            None,
745        );
746        a.annotation_id = id.to_string();
747        a
748    }
749
750    #[test]
751    fn inherit_parent_context_passes_through_pointer() {
752        let (_dir, repo) = setup();
753        let parent = state_with_context(
754            &repo,
755            "src/lib.rs",
756            vec![make_annotation(AnnotationScope::File, "first")],
757        );
758        let inherited = Repository::inherit_parent_context(&parent);
759        assert_eq!(inherited, parent.context);
760    }
761
762    #[test]
763    fn inherit_parent_context_yields_none_when_parent_has_none() {
764        let parent = State::new_snapshot(
765            ContentHash::compute(b""),
766            vec![],
767            objects::object::Attribution::human(objects::object::Principal::new(
768                "test",
769                "test@example.com",
770            )),
771        );
772        assert_eq!(Repository::inherit_parent_context(&parent), None);
773    }
774
775    #[test]
776    fn union_parent_contexts_returns_none_for_empty_parents() {
777        let (_dir, repo) = setup();
778        let p = State::new_snapshot(
779            ContentHash::compute(b""),
780            vec![],
781            objects::object::Attribution::human(objects::object::Principal::new(
782                "test",
783                "test@example.com",
784            )),
785        );
786        let merged = repo.union_parent_contexts(&[&p, &p]).unwrap();
787        assert_eq!(merged, None);
788    }
789
790    #[test]
791    fn union_parent_contexts_pointer_copies_when_one_side_has_context() {
792        let (_dir, repo) = setup();
793        let parent_with = state_with_context(
794            &repo,
795            "src/lib.rs",
796            vec![make_annotation(AnnotationScope::File, "first")],
797        );
798        let parent_without = State::new_snapshot(
799            ContentHash::compute(b""),
800            vec![],
801            objects::object::Attribution::human(objects::object::Principal::new(
802                "test",
803                "test@example.com",
804            )),
805        );
806        let merged = repo
807            .union_parent_contexts(&[&parent_with, &parent_without])
808            .unwrap();
809        assert_eq!(merged, parent_with.context);
810    }
811
812    #[test]
813    fn union_parent_contexts_carries_disjoint_annotations() {
814        let (_dir, repo) = setup();
815        let left = state_with_context(
816            &repo,
817            "src/lib.rs",
818            vec![ann_with_id("ann-a", "left side", 1)],
819        );
820        let right = state_with_context(
821            &repo,
822            "src/main.rs",
823            vec![ann_with_id("ann-b", "right side", 1)],
824        );
825        let merged = repo
826            .union_parent_contexts(&[&left, &right])
827            .unwrap()
828            .expect("merged context root");
829        let entries = repo.list_context_entries(&merged, None).unwrap();
830        assert_eq!(entries.len(), 2);
831        let mut ids: Vec<String> = entries
832            .iter()
833            .flat_map(|e| e.blob.annotations.iter().map(|a| a.annotation_id.clone()))
834            .collect();
835        ids.sort();
836        assert_eq!(ids, vec!["ann-a".to_string(), "ann-b".to_string()]);
837    }
838
839    #[test]
840    fn union_parent_contexts_dedupes_same_id_with_newest_revision_wins() {
841        let (_dir, repo) = setup();
842        let older = ann_with_id("ann-shared", "older content", 1);
843        let newer = ann_with_id("ann-shared", "newer content", 9);
844        let left = state_with_context(&repo, "src/lib.rs", vec![older]);
845        let right = state_with_context(&repo, "src/lib.rs", vec![newer]);
846        let merged = repo
847            .union_parent_contexts(&[&left, &right])
848            .unwrap()
849            .expect("merged context root");
850        let entries = repo.list_context_entries(&merged, None).unwrap();
851        assert_eq!(entries.len(), 1);
852        let blob = &entries[0].blob;
853        assert_eq!(blob.annotations.len(), 1);
854        let revision = blob.annotations[0]
855            .current_revision()
856            .expect("annotation has a revision");
857        assert_eq!(revision.content, "newer content");
858    }
859}