1use std::{
5 collections::BTreeMap,
6 path::{Path, PathBuf},
7};
8
9use objects::object::{
10 Annotation, AnnotationScope, Blob, ContentHash, ContextBlob, ContextTarget, EntryType, State,
11 Tree, TreeEntry,
12};
13
14use super::{HeddleError, Repository, Result};
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ContextEntry {
18 pub target: ContextTarget,
19 pub blob: ContextBlob,
20}
21
22impl Repository {
23 pub fn get_context_blob(
25 &self,
26 context_root: &ContentHash,
27 target: &ContextTarget,
28 ) -> Result<Option<ContextBlob>> {
29 let Some(blob_hash) = self.lookup_context_leaf_for_target(context_root, target)? else {
30 return Ok(None);
31 };
32 let Some(blob) = self.store.get_blob(&blob_hash)? else {
33 return Ok(None);
34 };
35 ContextBlob::decode(blob.content())
36 .map(Some)
37 .map_err(|e| HeddleError::InvalidObject(format!("invalid context blob: {e}")))
38 }
39
40 pub fn set_context_blob(
44 &self,
45 context_root: Option<&ContentHash>,
46 target: &ContextTarget,
47 blob: &ContextBlob,
48 ) -> Result<ContentHash> {
49 let bytes = blob
50 .encode()
51 .map_err(|e| HeddleError::InvalidObject(format!("encode context: {e}")))?;
52 let blob_hash = self.store.put_blob(&Blob::new(bytes))?;
53
54 let current_tree = match context_root {
55 Some(root) => self.store.get_tree(root)?.unwrap_or_default(),
56 None => Tree::new(),
57 };
58
59 let mut root_hash =
60 self.insert_leaf_at_path(¤t_tree, &target.storage_path(), blob_hash)?;
61
62 if let (Some(existing_root), Some(legacy_path)) =
63 (context_root, target.legacy_storage_path())
64 && legacy_path != target.storage_path()
65 && self
66 .lookup_context_leaf(existing_root, &legacy_path)?
67 .is_some()
68 {
69 root_hash = self
70 .remove_leaf_at_path(&root_hash, &legacy_path)?
71 .unwrap_or(root_hash);
72 }
73
74 Ok(root_hash)
75 }
76
77 pub fn remove_context_at_target(
81 &self,
82 context_root: &ContentHash,
83 target: &ContextTarget,
84 scope: Option<&AnnotationScope>,
85 ) -> Result<Option<ContentHash>> {
86 if let Some(scope) = scope {
87 if let Some(mut blob) = self.get_context_blob(context_root, target)? {
88 blob.annotations.retain(|a| !a.scope.matches(scope));
89 if blob.annotations.is_empty() {
90 return self.remove_context_target(context_root, target);
91 }
92 let new_root = self.set_context_blob(Some(context_root), target, &blob)?;
93 return Ok(Some(new_root));
94 }
95 return Ok(Some(*context_root));
96 }
97
98 self.remove_context_target(context_root, target)
99 }
100
101 pub fn remove_context_target(
102 &self,
103 context_root: &ContentHash,
104 target: &ContextTarget,
105 ) -> Result<Option<ContentHash>> {
106 let mut current = self.remove_leaf_at_path(context_root, &target.storage_path())?;
107 if current.is_none()
108 && let Some(legacy_path) = target.legacy_storage_path()
109 {
110 current = self.remove_leaf_at_path(context_root, &legacy_path)?;
111 }
112 Ok(current)
113 }
114
115 pub fn list_context_entries(
117 &self,
118 context_root: &ContentHash,
119 prefix: Option<&Path>,
120 ) -> Result<Vec<ContextEntry>> {
121 let tree = match self.store.get_tree(context_root)? {
122 Some(t) => t,
123 None => return Ok(Vec::new()),
124 };
125 let mut results = BTreeMap::new();
126 self.walk_context_tree(&tree, &PathBuf::new(), prefix, &mut results)?;
127 Ok(results
128 .into_iter()
129 .map(|(_, (target, blob))| ContextEntry { target, blob })
130 .collect())
131 }
132
133 pub fn find_annotation(
134 &self,
135 context_root: &ContentHash,
136 annotation_id: &str,
137 ) -> Result<Option<(ContextTarget, ContextBlob, usize)>> {
138 for entry in self.list_context_entries(context_root, None)? {
139 if let Some(index) = entry
140 .blob
141 .annotations
142 .iter()
143 .position(|annotation| annotation.annotation_id == annotation_id)
144 {
145 return Ok(Some((entry.target, entry.blob, index)));
146 }
147 }
148 Ok(None)
149 }
150
151 fn lookup_context_leaf_for_target(
154 &self,
155 root: &ContentHash,
156 target: &ContextTarget,
157 ) -> Result<Option<ContentHash>> {
158 let storage_path = target.storage_path();
159 if let Some(hash) = self.lookup_context_leaf(root, &storage_path)? {
160 return Ok(Some(hash));
161 }
162 if let Some(legacy_path) = target.legacy_storage_path() {
163 return self.lookup_context_leaf(root, &legacy_path);
164 }
165 Ok(None)
166 }
167
168 fn lookup_context_leaf(&self, root: &ContentHash, path: &Path) -> Result<Option<ContentHash>> {
169 let Some((name, rest)) = split_path(path) else {
170 return Ok(None);
171 };
172 let Some(tree) = self.store.get_tree(root)? else {
173 return Ok(None);
174 };
175 let Some(entry) = tree.get(name) else {
176 return Ok(None);
177 };
178 if rest.as_os_str().is_empty() {
179 return Ok(entry.is_blob().then_some(entry.hash));
180 }
181 if !entry.is_tree() {
182 return Ok(None);
183 }
184 self.lookup_context_leaf(&entry.hash, rest)
185 }
186
187 fn insert_leaf_at_path(
188 &self,
189 tree: &Tree,
190 path: &Path,
191 blob_hash: ContentHash,
192 ) -> Result<ContentHash> {
193 let Some((name, rest)) = split_path(path) else {
194 return Err(HeddleError::InvalidObject("empty path".to_string()));
195 };
196
197 let mut new_tree = tree.clone();
198
199 if rest.as_os_str().is_empty() {
200 new_tree.insert(TreeEntry::file(name, blob_hash, false)?);
201 } else {
202 let subtree = tree
203 .get(name)
204 .filter(|e| e.is_tree())
205 .and_then(|e| self.store.get_tree(&e.hash).ok().flatten())
206 .unwrap_or_default();
207
208 let sub_hash = self.insert_leaf_at_path(&subtree, rest, blob_hash)?;
209 new_tree.insert(TreeEntry::directory(name, sub_hash)?);
210 }
211
212 self.store.put_tree(&new_tree)
213 }
214
215 fn remove_leaf_at_path(&self, root: &ContentHash, path: &Path) -> Result<Option<ContentHash>> {
216 let Some(tree) = self.store.get_tree(root)? else {
217 return Ok(None);
218 };
219 let Some((name, rest)) = split_path(path) else {
220 return Ok(None);
221 };
222
223 let mut new_tree = tree.clone();
224
225 if rest.as_os_str().is_empty() {
226 new_tree.remove(name);
227 } else {
228 let Some(entry) = tree.get(name) else {
229 return Ok(Some(*root));
230 };
231 if !entry.is_tree() {
232 return Ok(Some(*root));
233 }
234 match self.remove_leaf_at_path(&entry.hash, rest)? {
235 Some(sub_hash) => {
236 new_tree.insert(TreeEntry::directory(name, sub_hash)?);
237 }
238 None => {
239 new_tree.remove(name);
240 }
241 }
242 }
243
244 if new_tree.is_empty() {
245 Ok(None)
246 } else {
247 Ok(Some(self.store.put_tree(&new_tree)?))
248 }
249 }
250
251 fn walk_context_tree(
252 &self,
253 tree: &Tree,
254 current_path: &Path,
255 prefix: Option<&Path>,
256 results: &mut BTreeMap<String, (ContextTarget, ContextBlob)>,
257 ) -> Result<()> {
258 for entry in tree.entries() {
259 let entry_path = current_path.join(&entry.name);
260 match entry.entry_type {
261 EntryType::Tree => {
262 if let Some(prefix) = prefix
263 && !prefix.starts_with(&entry_path)
264 && !entry_path.starts_with(prefix)
265 && !entry_path.starts_with("__files")
266 && !entry_path.starts_with("__states")
267 {
268 continue;
269 }
270 if let Some(subtree) = self.store.get_tree(&entry.hash)? {
271 self.walk_context_tree(&subtree, &entry_path, prefix, results)?;
272 }
273 }
274 EntryType::Blob => {
275 let Some(target) = ContextTarget::from_storage_path(&entry_path) else {
276 continue;
277 };
278 if let Some(prefix) = prefix
279 && let Some(path) = target.path()
280 && !Path::new(path).starts_with(prefix)
281 {
282 continue;
283 }
284 if let Some(blob) = self.store.get_blob(&entry.hash)?
285 && let Ok(context) = ContextBlob::decode(blob.content())
286 {
287 results.insert(context_entry_key(&target), (target, context));
288 }
289 }
290 EntryType::Symlink => {}
291 }
292 }
293 Ok(())
294 }
295
296 pub fn inherit_parent_context(parent: &State) -> Option<ContentHash> {
306 parent.context
307 }
308
309 pub fn union_parent_contexts(&self, parents: &[&State]) -> Result<Option<ContentHash>> {
323 let mut roots: Vec<ContentHash> = parents.iter().filter_map(|p| p.context).collect();
325 if roots.is_empty() {
326 return Ok(None);
327 }
328 if roots.len() == 1 {
329 return Ok(Some(roots.pop().expect("len == 1")));
330 }
331 if roots.iter().all(|r| *r == roots[0]) {
332 return Ok(Some(roots[0]));
334 }
335
336 let mut merged: BTreeMap<String, (ContextTarget, ContextBlob)> = BTreeMap::new();
340 for parent_root in &roots {
341 for entry in self.list_context_entries(parent_root, None)? {
342 let key = context_entry_key(&entry.target);
343 match merged.remove(&key) {
344 None => {
345 merged.insert(key, (entry.target, entry.blob));
346 }
347 Some((target, existing)) => {
348 let merged_blob = merge_context_blobs(existing, entry.blob);
349 merged.insert(key, (target, merged_blob));
350 }
351 }
352 }
353 }
354
355 if merged.is_empty() {
356 return Ok(None);
357 }
358
359 let mut root: Option<ContentHash> = None;
361 for (_, (target, blob)) in merged {
362 if blob.annotations.is_empty() {
363 continue;
364 }
365 let new_root = self.set_context_blob(root.as_ref(), &target, &blob)?;
366 root = Some(new_root);
367 }
368
369 Ok(root)
370 }
371}
372
373fn merge_context_blobs(left: ContextBlob, right: ContextBlob) -> ContextBlob {
379 let format_version = left.format_version;
380 let mut by_id: BTreeMap<String, Annotation> = BTreeMap::new();
381 for annotation in left.annotations.into_iter().chain(right.annotations) {
382 match by_id.remove(&annotation.annotation_id) {
383 None => {
384 by_id.insert(annotation.annotation_id.clone(), annotation);
385 }
386 Some(existing) => {
387 let winner = pick_newer_annotation(existing, annotation);
388 by_id.insert(winner.annotation_id.clone(), winner);
389 }
390 }
391 }
392 ContextBlob {
393 format_version,
394 annotations: by_id.into_values().collect(),
395 }
396}
397
398fn pick_newer_annotation(a: Annotation, b: Annotation) -> Annotation {
399 let ts_a = a
400 .current_revision()
401 .map(|r| r.created_at)
402 .unwrap_or(i64::MIN);
403 let ts_b = b
404 .current_revision()
405 .map(|r| r.created_at)
406 .unwrap_or(i64::MIN);
407 if ts_a > ts_b {
408 a
409 } else if ts_b > ts_a {
410 b
411 } else {
412 let rev_a = a
415 .current_revision()
416 .map(|r| r.revision_id.as_str())
417 .unwrap_or("");
418 let rev_b = b
419 .current_revision()
420 .map(|r| r.revision_id.as_str())
421 .unwrap_or("");
422 if rev_a >= rev_b { a } else { b }
423 }
424}
425
426fn context_entry_key(target: &ContextTarget) -> String {
427 match target {
428 ContextTarget::File { path } => format!("file:{path}"),
429 ContextTarget::State { change_id } => format!("state:{}", change_id.to_string_full()),
430 }
431}
432
433fn split_path(path: &Path) -> Option<(&str, &Path)> {
434 let mut components = path.components();
435 let first = components.next()?;
436 let std::path::Component::Normal(name) = first else {
437 return None;
438 };
439 Some((name.to_str()?, components.as_path()))
440}
441
442#[cfg(test)]
443mod tests {
444 use objects::object::{Annotation, AnnotationKind, ChangeId};
445 use tempfile::TempDir;
446
447 use super::{Repository, *};
448
449 fn setup() -> (TempDir, Repository) {
450 let dir = TempDir::new().unwrap();
451 let repo = Repository::init_default(dir.path()).unwrap();
452 (dir, repo)
453 }
454
455 fn make_annotation(scope: AnnotationScope, content: &str) -> Annotation {
456 Annotation::new(
457 scope,
458 AnnotationKind::Rationale,
459 content.to_string(),
460 vec![],
461 "test@example.com".to_string(),
462 1700000000,
463 None,
464 None,
465 )
466 }
467
468 #[test]
469 fn get_and_set_context_blob_for_file_target() {
470 let (_dir, repo) = setup();
471 let target = ContextTarget::file("src/main.rs").unwrap();
472 let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "Entry point")]);
473
474 let root = repo.set_context_blob(None, &target, &blob).unwrap();
475 let retrieved = repo.get_context_blob(&root, &target).unwrap().unwrap();
476
477 assert_eq!(retrieved, blob);
478 }
479
480 #[test]
481 fn supports_state_targets() {
482 let (_dir, repo) = setup();
483 let target = ContextTarget::state(ChangeId::generate());
484 let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "review note")]);
485
486 let root = repo.set_context_blob(None, &target, &blob).unwrap();
487 let retrieved = repo.get_context_blob(&root, &target).unwrap().unwrap();
488 assert_eq!(retrieved, blob);
489 }
490
491 #[test]
492 fn remove_context_blob_by_scope() {
493 let (_dir, repo) = setup();
494 let target = ContextTarget::file("src/lib.rs").unwrap();
495 let blob = ContextBlob::new(vec![
496 make_annotation(AnnotationScope::File, "file-level"),
497 make_annotation(AnnotationScope::Lines(1, 10), "range-level"),
498 ]);
499
500 let root = repo.set_context_blob(None, &target, &blob).unwrap();
501 let new_root = repo
502 .remove_context_at_target(&root, &target, Some(&AnnotationScope::Lines(1, 10)))
503 .unwrap()
504 .unwrap();
505 let remaining = repo.get_context_blob(&new_root, &target).unwrap().unwrap();
506
507 assert_eq!(remaining.annotations.len(), 1);
508 assert_eq!(
509 remaining
510 .annotations
511 .first()
512 .unwrap()
513 .current_revision()
514 .unwrap()
515 .content,
516 "file-level"
517 );
518 }
519
520 #[test]
521 fn list_context_entries_filters_by_prefix() {
522 let (_dir, repo) = setup();
523 let target1 = ContextTarget::file("src/main.rs").unwrap();
524 let target2 = ContextTarget::file("src/lib.rs").unwrap();
525 let target3 = ContextTarget::file("tests/test.rs").unwrap();
526 let blob1 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "first")]);
527 let blob2 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "second")]);
528 let blob3 = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "third")]);
529
530 let root1 = repo.set_context_blob(None, &target1, &blob1).unwrap();
531 let root2 = repo
532 .set_context_blob(Some(&root1), &target2, &blob2)
533 .unwrap();
534 let root3 = repo
535 .set_context_blob(Some(&root2), &target3, &blob3)
536 .unwrap();
537
538 let all = repo.list_context_entries(&root3, None).unwrap();
539 assert_eq!(all.len(), 3);
540
541 let src_only = repo
542 .list_context_entries(&root3, Some(Path::new("src")))
543 .unwrap();
544 assert_eq!(src_only.len(), 2);
545
546 let exact_root_file = repo
547 .list_context_entries(&root3, Some(Path::new("tests/test.rs")))
548 .unwrap();
549 assert_eq!(exact_root_file.len(), 1);
550 }
551
552 #[test]
553 fn find_annotation_returns_target_and_index() {
554 let (_dir, repo) = setup();
555 let target = ContextTarget::file("src/main.rs").unwrap();
556 let blob = ContextBlob::new(vec![make_annotation(AnnotationScope::File, "first")]);
557 let annotation_id = blob.annotations[0].annotation_id.clone();
558 let root = repo.set_context_blob(None, &target, &blob).unwrap();
559
560 let found = repo
561 .find_annotation(&root, &annotation_id)
562 .unwrap()
563 .unwrap();
564 assert_eq!(found.0, target);
565 assert_eq!(found.2, 0);
566 }
567
568 fn state_with_context(repo: &Repository, path: &str, anns: Vec<Annotation>) -> State {
573 let target = ContextTarget::file(path).unwrap();
574 let blob = ContextBlob::new(anns);
575 let root = repo.set_context_blob(None, &target, &blob).unwrap();
576 let mut state = State::new_snapshot(
577 ContentHash::compute(b""),
578 vec![],
579 objects::object::Attribution::human(objects::object::Principal::new(
580 "test",
581 "test@example.com",
582 )),
583 );
584 state = state.with_context(root);
585 state
586 }
587
588 fn ann_with_id(id: &str, content: &str, created_at: i64) -> Annotation {
589 let mut a = Annotation::new(
590 AnnotationScope::File,
591 AnnotationKind::Rationale,
592 content.to_string(),
593 vec![],
594 "test@example.com".to_string(),
595 created_at,
596 None,
597 None,
598 );
599 a.annotation_id = id.to_string();
600 a
601 }
602
603 #[test]
604 fn inherit_parent_context_passes_through_pointer() {
605 let (_dir, repo) = setup();
606 let parent = state_with_context(
607 &repo,
608 "src/lib.rs",
609 vec![make_annotation(AnnotationScope::File, "first")],
610 );
611 let inherited = Repository::inherit_parent_context(&parent);
612 assert_eq!(inherited, parent.context);
613 }
614
615 #[test]
616 fn inherit_parent_context_yields_none_when_parent_has_none() {
617 let parent = State::new_snapshot(
618 ContentHash::compute(b""),
619 vec![],
620 objects::object::Attribution::human(objects::object::Principal::new(
621 "test",
622 "test@example.com",
623 )),
624 );
625 assert_eq!(Repository::inherit_parent_context(&parent), None);
626 }
627
628 #[test]
629 fn union_parent_contexts_returns_none_for_empty_parents() {
630 let (_dir, repo) = setup();
631 let p = State::new_snapshot(
632 ContentHash::compute(b""),
633 vec![],
634 objects::object::Attribution::human(objects::object::Principal::new(
635 "test",
636 "test@example.com",
637 )),
638 );
639 let merged = repo.union_parent_contexts(&[&p, &p]).unwrap();
640 assert_eq!(merged, None);
641 }
642
643 #[test]
644 fn union_parent_contexts_pointer_copies_when_one_side_has_context() {
645 let (_dir, repo) = setup();
646 let parent_with = state_with_context(
647 &repo,
648 "src/lib.rs",
649 vec![make_annotation(AnnotationScope::File, "first")],
650 );
651 let parent_without = State::new_snapshot(
652 ContentHash::compute(b""),
653 vec![],
654 objects::object::Attribution::human(objects::object::Principal::new(
655 "test",
656 "test@example.com",
657 )),
658 );
659 let merged = repo
660 .union_parent_contexts(&[&parent_with, &parent_without])
661 .unwrap();
662 assert_eq!(merged, parent_with.context);
663 }
664
665 #[test]
666 fn union_parent_contexts_carries_disjoint_annotations() {
667 let (_dir, repo) = setup();
668 let left = state_with_context(
669 &repo,
670 "src/lib.rs",
671 vec![ann_with_id("ann-a", "left side", 1)],
672 );
673 let right = state_with_context(
674 &repo,
675 "src/main.rs",
676 vec![ann_with_id("ann-b", "right side", 1)],
677 );
678 let merged = repo
679 .union_parent_contexts(&[&left, &right])
680 .unwrap()
681 .expect("merged context root");
682 let entries = repo.list_context_entries(&merged, None).unwrap();
683 assert_eq!(entries.len(), 2);
684 let mut ids: Vec<String> = entries
685 .iter()
686 .flat_map(|e| e.blob.annotations.iter().map(|a| a.annotation_id.clone()))
687 .collect();
688 ids.sort();
689 assert_eq!(ids, vec!["ann-a".to_string(), "ann-b".to_string()]);
690 }
691
692 #[test]
693 fn union_parent_contexts_dedupes_same_id_with_newest_revision_wins() {
694 let (_dir, repo) = setup();
695 let older = ann_with_id("ann-shared", "older content", 1);
696 let newer = ann_with_id("ann-shared", "newer content", 9);
697 let left = state_with_context(&repo, "src/lib.rs", vec![older]);
698 let right = state_with_context(&repo, "src/lib.rs", vec![newer]);
699 let merged = repo
700 .union_parent_contexts(&[&left, &right])
701 .unwrap()
702 .expect("merged context root");
703 let entries = repo.list_context_entries(&merged, None).unwrap();
704 assert_eq!(entries.len(), 1);
705 let blob = &entries[0].blob;
706 assert_eq!(blob.annotations.len(), 1);
707 let revision = blob.annotations[0]
708 .current_revision()
709 .expect("annotation has a revision");
710 assert_eq!(revision.content, "newer content");
711 }
712}