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