1use 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 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 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(¤t_tree, &target.storage_path(), blob_hash)
63 }
64
65 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 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 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 pub fn inherit_parent_context(parent: &State) -> Option<ContentHash> {
364 parent.context
365 }
366
367 pub fn union_parent_contexts(&self, parents: &[&State]) -> Result<Option<ContentHash>> {
381 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 return Ok(Some(roots[0]));
392 }
393
394 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 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
431fn 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 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 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}