1#![cfg(feature = "client")]
70
71use std::{
72 collections::{BTreeMap, HashSet},
73 fs,
74 path::{Path, PathBuf},
75};
76
77use anyhow::{Context, Result};
78use api::heddle::api::v1alpha1::{
79 AnnotationScope as ProtoScope, ContextAnnotation, ContextAnnotationKind,
80 ContextAnnotationStatus, LineRange, SymbolScope, annotation_scope::Scope,
81};
82use objects::{
83 fs_atomic::write_file_atomic,
84 object::{
85 Annotation, AnnotationKind, AnnotationRevision, AnnotationScope, AnnotationStatus,
86 ContentHash, ContextBlob, ContextTarget, State, StateId,
87 },
88 store::ObjectStore,
89};
90use repo::Repository;
91use serde::{Deserialize, Serialize};
92
93use crate::{
94 cli::commands::context::{context_root_for_state, put_context_attachment},
95 client::HostedClient,
96};
97
98#[derive(Debug, Default, Serialize, Deserialize)]
103struct HostedContextMirror {
104 #[serde(default)]
105 repos: BTreeMap<String, RepoContextMirror>,
106}
107
108#[derive(Debug, Default, Serialize, Deserialize)]
109struct RepoContextMirror {
110 #[serde(default)]
111 annotations: Vec<ContextMirrorEntry>,
112}
113
114#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115struct ContextMirrorEntry {
116 local_id: String,
118 #[serde(default)]
120 server_id: String,
121 #[serde(default)]
123 revision_links: Vec<RevisionLink>,
124 #[serde(default)]
127 pending_create_op: Option<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131struct RevisionLink {
132 local: String,
133 server: String,
134}
135
136fn mirror_path(heddle_dir: &Path) -> PathBuf {
137 heddle_dir
138 .join("collaboration")
139 .join("hosted-context-mirror.json")
140}
141
142fn load_mirror(heddle_dir: &Path) -> Result<HostedContextMirror> {
143 match fs::read(mirror_path(heddle_dir)) {
144 Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted context mirror map"),
145 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
146 Ok(HostedContextMirror::default())
147 }
148 Err(error) => Err(error).context("read hosted context mirror map"),
149 }
150}
151
152fn save_mirror(heddle_dir: &Path, mirror: &HostedContextMirror) -> Result<()> {
153 let path = mirror_path(heddle_dir);
154 if let Some(parent) = path.parent() {
155 fs::create_dir_all(parent).context("create collaboration dir")?;
156 }
157 let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted context mirror map")?;
158 write_file_atomic(&path, &bytes).context("write hosted context mirror map")?;
159 Ok(())
160}
161
162fn entry_index(mirror: &HostedContextMirror, repo_path: &str, local_id: &str) -> Option<usize> {
165 mirror
166 .repos
167 .get(repo_path)?
168 .annotations
169 .iter()
170 .position(|entry| entry.local_id == local_id)
171}
172
173fn get_or_create_entry<'a>(
174 mirror: &'a mut HostedContextMirror,
175 repo_path: &str,
176 local_id: &str,
177) -> &'a mut ContextMirrorEntry {
178 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
179 if let Some(index) = repo_mirror
180 .annotations
181 .iter()
182 .position(|entry| entry.local_id == local_id)
183 {
184 return &mut repo_mirror.annotations[index];
185 }
186 repo_mirror.annotations.push(ContextMirrorEntry {
187 local_id: local_id.to_string(),
188 ..Default::default()
189 });
190 repo_mirror.annotations.last_mut().expect("just pushed")
191}
192
193fn server_id_for_local(
194 mirror: &HostedContextMirror,
195 repo_path: &str,
196 local_id: &str,
197) -> Option<String> {
198 let entry = mirror
199 .repos
200 .get(repo_path)?
201 .annotations
202 .iter()
203 .find(|entry| entry.local_id == local_id)?;
204 (!entry.server_id.is_empty()).then(|| entry.server_id.clone())
205}
206
207fn local_id_for_server(
208 mirror: &HostedContextMirror,
209 repo_path: &str,
210 server_id: &str,
211) -> Option<String> {
212 mirror
213 .repos
214 .get(repo_path)?
215 .annotations
216 .iter()
217 .find(|entry| entry.server_id == server_id)
218 .map(|entry| entry.local_id.clone())
219}
220
221fn server_id_is_linked(mirror: &HostedContextMirror, repo_path: &str, server_id: &str) -> bool {
222 mirror.repos.get(repo_path).is_some_and(|repo_mirror| {
223 repo_mirror
224 .annotations
225 .iter()
226 .any(|entry| entry.server_id == server_id)
227 })
228}
229
230fn add_revision_link(
231 mirror: &mut HostedContextMirror,
232 repo_path: &str,
233 local_id: &str,
234 local_rev: String,
235 server_rev: String,
236) {
237 let entry = get_or_create_entry(mirror, repo_path, local_id);
238 if !entry
239 .revision_links
240 .iter()
241 .any(|link| link.local == local_rev && link.server == server_rev)
242 {
243 entry.revision_links.push(RevisionLink {
244 local: local_rev,
245 server: server_rev,
246 });
247 }
248}
249
250fn scope_to_proto(scope: &AnnotationScope) -> ProtoScope {
255 let inner = match scope {
256 AnnotationScope::File => Scope::File(true),
257 AnnotationScope::Symbol {
258 name,
259 resolved_lines,
260 } => Scope::Symbol(SymbolScope {
261 name: name.clone(),
262 resolved_start: resolved_lines.map(|(start, _)| start),
263 resolved_end: resolved_lines.map(|(_, end)| end),
264 }),
265 AnnotationScope::Lines(start, end) => Scope::Lines(LineRange {
266 start: *start,
267 end: *end,
268 }),
269 };
270 ProtoScope { scope: Some(inner) }
271}
272
273fn scope_from_proto(scope: Option<&ProtoScope>) -> AnnotationScope {
274 match scope.and_then(|s| s.scope.as_ref()) {
275 Some(Scope::File(_)) | None => AnnotationScope::File,
276 Some(Scope::Symbol(symbol)) => AnnotationScope::Symbol {
277 name: symbol.name.clone(),
278 resolved_lines: match (symbol.resolved_start, symbol.resolved_end) {
279 (Some(start), Some(end)) => Some((start, end)),
280 _ => None,
281 },
282 },
283 Some(Scope::Lines(range)) => AnnotationScope::Lines(range.start, range.end),
284 }
285}
286
287fn scope_ident(scope: &AnnotationScope) -> String {
290 match scope {
291 AnnotationScope::File => "file".to_string(),
292 AnnotationScope::Symbol { name, .. } => format!("symbol:{name}"),
293 AnnotationScope::Lines(start, end) => format!("lines:{start}-{end}"),
294 }
295}
296
297fn kind_to_proto(kind: AnnotationKind) -> ContextAnnotationKind {
298 match kind {
299 AnnotationKind::Constraint => ContextAnnotationKind::Constraint,
300 AnnotationKind::Invariant => ContextAnnotationKind::Invariant,
301 AnnotationKind::Rationale => ContextAnnotationKind::Rationale,
302 }
303}
304
305fn kind_from_proto(kind: i32) -> AnnotationKind {
306 match ContextAnnotationKind::try_from(kind).unwrap_or(ContextAnnotationKind::Rationale) {
307 ContextAnnotationKind::Constraint => AnnotationKind::Constraint,
308 ContextAnnotationKind::Invariant => AnnotationKind::Invariant,
309 ContextAnnotationKind::Rationale | ContextAnnotationKind::Unspecified => {
310 AnnotationKind::Rationale
311 }
312 }
313}
314
315fn status_from_proto(status: i32) -> AnnotationStatus {
316 match ContextAnnotationStatus::try_from(status).unwrap_or(ContextAnnotationStatus::Active) {
317 ContextAnnotationStatus::Superseded => AnnotationStatus::Superseded,
318 _ => AnnotationStatus::Active,
319 }
320}
321
322fn target_operands(target: &ContextTarget) -> (String, Option<String>) {
323 match target {
324 ContextTarget::File { path } => (path.clone(), None),
325 ContextTarget::State { state_id } => (String::new(), Some(state_id.to_string_full())),
326 }
327}
328
329fn content_hash_from_bytes(bytes: &Option<Vec<u8>>) -> Option<ContentHash> {
330 bytes
331 .as_ref()
332 .and_then(|raw| <[u8; 32]>::try_from(raw.as_slice()).ok())
333 .map(ContentHash::from_bytes)
334}
335
336fn state_id_from_proto(id: Option<&api::heddle::api::v1alpha1::StateId>) -> Option<StateId> {
337 id.and_then(|value| StateId::try_from_slice(&value.value).ok())
338}
339
340fn hosted_attribution(username: Option<&str>) -> Option<String> {
343 username.map(|name| format!("{name} <>"))
344}
345
346pub async fn push_context(
352 repo: &Repository,
353 client: &mut HostedClient,
354 repo_path: &str,
355) -> Result<usize> {
356 let Some(head_id) = repo.head().context("resolve repository head")? else {
357 return Ok(0);
358 };
359 let Some(head_state) = repo
360 .store()
361 .get_state(&head_id)
362 .context("load head state")?
363 else {
364 return Ok(0);
365 };
366 let Some(context_root) = context_root_for_state(repo, &head_state)? else {
367 return Ok(0);
368 };
369 let entries = repo
370 .list_context_entries(&context_root, None)
371 .context("enumerate local context annotations")?;
372 if entries.is_empty() {
373 return Ok(0);
374 }
375
376 let user_config = crate::config::UserConfig::load_default().unwrap_or_default();
377 let self_local_attr = crate::cli::commands::snapshot::resolve_attribution(repo, &user_config)
378 .ok()
379 .map(|attribution| attribution.to_string());
380 let username = client.authenticated_username();
381
382 let server_ann_ids: HashSet<String> = list_server_annotations(client, repo_path)
383 .await?
384 .into_iter()
385 .map(|annotation| annotation.id)
386 .collect();
387
388 let heddle_dir = repo.heddle_dir().to_path_buf();
389 let mut mirror = load_mirror(&heddle_dir)?;
390 let mut synced = 0usize;
391 for entry in &entries {
392 for annotation in &entry.blob.annotations {
393 let result = push_one(
394 client,
395 repo_path,
396 &heddle_dir,
397 &entry.target,
398 annotation,
399 self_local_attr.as_deref(),
400 username.as_deref(),
401 &server_ann_ids,
402 &mut mirror,
403 )
404 .await;
405 save_mirror(&heddle_dir, &mirror)?;
406 match result {
407 Ok(true) => synced += 1,
408 Ok(false) => {}
409 Err(error) => {
410 eprintln!(
411 "{} hosted context {}: {error:#}",
412 crate::cli::style::warn_marker(),
413 annotation.annotation_id
414 );
415 }
416 }
417 }
418 }
419 Ok(synced)
420}
421
422#[allow(clippy::too_many_arguments)]
423async fn push_one(
424 client: &mut HostedClient,
425 repo_path: &str,
426 heddle_dir: &Path,
427 target: &ContextTarget,
428 annotation: &Annotation,
429 self_local_attr: Option<&str>,
430 username: Option<&str>,
431 server_ann_ids: &HashSet<String>,
432 mirror: &mut HostedContextMirror,
433) -> Result<bool> {
434 let mut created = false;
436 let server_id = if let Some(sid) =
437 server_id_for_local(mirror, repo_path, &annotation.annotation_id)
438 {
439 sid
440 } else if server_ann_ids.contains(&annotation.annotation_id) {
441 let entry = get_or_create_entry(mirror, repo_path, &annotation.annotation_id);
443 entry.server_id = annotation.annotation_id.clone();
444 annotation.annotation_id.clone()
445 } else {
446 let first = annotation
448 .revisions
449 .first()
450 .context("annotation has no revisions")?;
451 let is_self = self_local_attr.is_some_and(|me| me == first.attribution);
452 if !is_self {
453 eprintln!(
454 "{} hosted context {}: not attributed to the local principal; left unpublished",
455 crate::cli::style::warn_marker(),
456 annotation.annotation_id
457 );
458 return Ok(false);
459 }
460 let superseded_server = annotation
462 .supersedes_annotation_id
463 .as_ref()
464 .and_then(|local| {
465 server_id_for_local(mirror, repo_path, local)
466 .or_else(|| server_ann_ids.contains(local).then(|| local.clone()))
467 });
468
469 let op_id = {
472 let entry = get_or_create_entry(mirror, repo_path, &annotation.annotation_id);
473 if entry.pending_create_op.is_none() {
474 entry.pending_create_op = Some(uuid::Uuid::new_v4().to_string());
475 }
476 entry.pending_create_op.clone().expect("just set")
477 };
478 save_mirror(heddle_dir, mirror)?;
479
480 let (sid, first_server_rev) = create_on_server(
481 client,
482 repo_path,
483 target,
484 annotation,
485 superseded_server.as_deref(),
486 &op_id,
487 username,
488 mirror,
489 )
490 .await?;
491
492 {
493 let entry = get_or_create_entry(mirror, repo_path, &annotation.annotation_id);
494 entry.server_id = sid.clone();
495 entry.pending_create_op = None;
496 }
497 add_revision_link(
498 mirror,
499 repo_path,
500 &annotation.annotation_id,
501 annotation.revisions[0].revision_id.clone(),
502 first_server_rev,
503 );
504 created = true;
505 sid
506 };
507
508 let pushed = sync_revisions_push(
510 client,
511 repo_path,
512 &server_id,
513 annotation,
514 self_local_attr,
515 username,
516 mirror,
517 )
518 .await?;
519
520 Ok(created || pushed > 0)
521}
522
523#[allow(clippy::too_many_arguments)]
526async fn create_on_server(
527 client: &mut HostedClient,
528 repo_path: &str,
529 target: &ContextTarget,
530 annotation: &Annotation,
531 superseded_server: Option<&str>,
532 op_id: &str,
533 username: Option<&str>,
534 mirror: &HostedContextMirror,
535) -> Result<(String, String)> {
536 let first = annotation
537 .revisions
538 .first()
539 .context("annotation has no revisions")?;
540 let (path, target_state_id) = target_operands(target);
541
542 let server_id = if let Some(superseded) = superseded_server {
543 let response = client
544 .supersede_context(
545 repo_path,
546 superseded,
547 if path.is_empty() {
548 None
549 } else {
550 Some(path.as_str())
551 },
552 target_state_id.as_deref(),
553 scope_to_proto(&annotation.scope),
554 first.tags.clone(),
555 &first.content,
556 None,
557 None,
558 kind_to_proto(first.kind),
559 op_id.to_string(),
560 )
561 .await
562 .with_context(|| format!("supersede hosted annotation {superseded}"))?;
563 response.new_annotation_id
564 } else {
565 client
566 .set_context(
567 repo_path,
568 &path,
569 target_state_id.as_deref(),
570 scope_to_proto(&annotation.scope),
571 kind_to_proto(first.kind),
572 first.tags.clone(),
573 &first.content,
574 None,
575 None,
576 op_id.to_string(),
577 )
578 .await
579 .with_context(|| format!("set hosted context for {}", annotation.annotation_id))?;
580
581 let hosted = hosted_attribution(username);
585 list_server_targets(client, repo_path)
586 .await?
587 .into_iter()
588 .rev()
589 .find(|(candidate_target, candidate)| {
590 candidate_target == target
591 && Some(candidate.attribution.as_str()) == hosted.as_deref()
592 && candidate.content == first.content
593 && scope_ident(&scope_from_proto(candidate.scope.as_ref()))
594 == scope_ident(&annotation.scope)
595 && !server_id_is_linked(mirror, repo_path, &candidate.id)
596 })
597 .map(|(_, candidate)| candidate.id)
598 .context("could not recover the minted annotation id (author + content)")?
599 };
600
601 let first_server_rev = first_server_revision_id(client, repo_path, &server_id).await?;
602 Ok((server_id, first_server_rev))
603}
604
605async fn sync_revisions_push(
608 client: &mut HostedClient,
609 repo_path: &str,
610 server_id: &str,
611 annotation: &Annotation,
612 self_local_attr: Option<&str>,
613 username: Option<&str>,
614 mirror: &mut HostedContextMirror,
615) -> Result<usize> {
616 let hosted = hosted_attribution(username);
617 let mut server_rev_ids: HashSet<String> = fetch_history(client, repo_path, server_id)
618 .await?
619 .into_iter()
620 .map(|rev| rev.revision_id)
621 .collect();
622 let linked_local: HashSet<String> = mirror
623 .repos
624 .get(repo_path)
625 .and_then(|m| {
626 m.annotations
627 .iter()
628 .find(|e| e.local_id == annotation.annotation_id)
629 })
630 .map(|entry| {
631 entry
632 .revision_links
633 .iter()
634 .map(|l| l.local.clone())
635 .collect()
636 })
637 .unwrap_or_default();
638
639 let mut pushed = 0usize;
640 for revision in &annotation.revisions {
641 if linked_local.contains(&revision.revision_id) {
642 continue;
643 }
644 if server_rev_ids.contains(&revision.revision_id) {
645 add_revision_link(
647 mirror,
648 repo_path,
649 &annotation.annotation_id,
650 revision.revision_id.clone(),
651 revision.revision_id.clone(),
652 );
653 continue;
654 }
655 if self_local_attr != Some(revision.attribution.as_str()) {
658 eprintln!(
659 "{} hosted context {}: unlinked revision not attributed to the local principal; left unpublished",
660 crate::cli::style::warn_marker(),
661 annotation.annotation_id
662 );
663 continue;
664 }
665 client
666 .revise_context(
667 repo_path,
668 server_id,
669 &revision.content,
670 revision.tags.clone(),
671 None,
672 None,
673 kind_to_proto(revision.kind),
674 revise_op_id(repo_path, server_id, &revision.revision_id),
675 )
676 .await
677 .with_context(|| format!("revise hosted annotation {server_id}"))?;
678
679 let refreshed = fetch_history(client, repo_path, server_id).await?;
682 let minted = refreshed.iter().rev().find(|candidate| {
683 !server_rev_ids.contains(&candidate.revision_id)
684 && Some(candidate.attribution.as_str()) == hosted.as_deref()
685 && candidate.content == revision.content
686 });
687 match minted {
688 Some(candidate) => {
689 add_revision_link(
690 mirror,
691 repo_path,
692 &annotation.annotation_id,
693 revision.revision_id.clone(),
694 candidate.revision_id.clone(),
695 );
696 server_rev_ids.insert(candidate.revision_id.clone());
697 pushed += 1;
698 }
699 None => {
700 eprintln!(
701 "{} hosted context {}: could not recover the minted revision id",
702 crate::cli::style::warn_marker(),
703 annotation.annotation_id
704 );
705 }
706 }
707 }
708 Ok(pushed)
709}
710
711async fn first_server_revision_id(
713 client: &mut HostedClient,
714 repo_path: &str,
715 server_id: &str,
716) -> Result<String> {
717 let revisions = fetch_history(client, repo_path, server_id).await?;
718 revisions
719 .into_iter()
720 .next()
721 .map(|revision| revision.revision_id)
722 .context("hosted annotation has no revisions")
723}
724
725pub async fn pull_context(
732 repo: &Repository,
733 client: &mut HostedClient,
734 repo_path: &str,
735) -> Result<usize> {
736 let Some(head_id) = repo.head().context("resolve repository head")? else {
737 return Ok(0);
738 };
739 let Some(head_state) = repo
740 .store()
741 .get_state(&head_id)
742 .context("load head state")?
743 else {
744 return Ok(0);
745 };
746
747 let server = list_server_targets(client, repo_path).await?;
748 if server.is_empty() {
749 return Ok(0);
750 }
751
752 let user_config = crate::config::UserConfig::load_default().unwrap_or_default();
753 let self_local_attr = crate::cli::commands::snapshot::resolve_attribution(repo, &user_config)
754 .ok()
755 .map(|attribution| attribution.to_string());
756 let username = client.authenticated_username();
757
758 let heddle_dir = repo.heddle_dir().to_path_buf();
759 let mut mirror = load_mirror(&heddle_dir)?;
760 let mut changed = 0usize;
761 for (target, annotation) in server {
762 let result = pull_one(
763 repo,
764 client,
765 repo_path,
766 &head_state,
767 &target,
768 &annotation,
769 self_local_attr.as_deref(),
770 username.as_deref(),
771 &mut mirror,
772 )
773 .await;
774 save_mirror(&heddle_dir, &mirror)?;
775 match result {
776 Ok(true) => changed += 1,
777 Ok(false) => {}
778 Err(error) => {
779 eprintln!(
780 "{} hosted context {}: {error:#}",
781 crate::cli::style::warn_marker(),
782 annotation.id
783 );
784 }
785 }
786 }
787 Ok(changed)
788}
789
790#[allow(clippy::too_many_arguments)]
791async fn pull_one(
792 repo: &Repository,
793 client: &mut HostedClient,
794 repo_path: &str,
795 head_state: &State,
796 target: &ContextTarget,
797 server: &ContextAnnotation,
798 self_local_attr: Option<&str>,
799 username: Option<&str>,
800 mirror: &mut HostedContextMirror,
801) -> Result<bool> {
802 let local_id =
803 local_id_for_server(mirror, repo_path, &server.id).unwrap_or_else(|| server.id.clone());
804 let server_revs = fetch_history(client, repo_path, &server.id).await?;
805
806 let context_root = context_root_for_state(repo, head_state)?;
807 let mut blob = match &context_root {
808 Some(root) => repo
809 .get_context_blob(root, target)?
810 .unwrap_or_else(|| ContextBlob::new(vec![])),
811 None => ContextBlob::new(vec![]),
812 };
813
814 let existing_index = blob
815 .annotations
816 .iter()
817 .position(|annotation| annotation.annotation_id == local_id);
818
819 let existing_revisions: Vec<AnnotationRevision> = existing_index
820 .map(|index| blob.annotations[index].revisions.clone())
821 .unwrap_or_default();
822 let existing_links: Vec<RevisionLink> = entry_index(mirror, repo_path, &local_id)
823 .map(|index| {
824 mirror.repos[repo_path].annotations[index]
825 .revision_links
826 .clone()
827 })
828 .unwrap_or_default();
829
830 let (new_revisions, new_links) = reconcile_revisions_pull(
831 &existing_revisions,
832 &server_revs,
833 &existing_links,
834 self_local_attr,
835 username,
836 );
837
838 {
839 let entry = get_or_create_entry(mirror, repo_path, &local_id);
840 entry.server_id = server.id.clone();
841 entry.revision_links = new_links;
842 }
843
844 let new_status = status_from_proto(server.status);
845 let changed = match existing_index {
846 Some(index) => {
847 let annotation = &mut blob.annotations[index];
848 let differs = annotation.revisions != new_revisions
849 || annotation.status != new_status
850 || annotation.supersedes_annotation_id != server.supersedes_annotation_id
851 || annotation.supersedes_rewrite_pct != server.supersedes_rewrite_pct;
852 annotation.revisions = new_revisions;
853 annotation.status = new_status;
854 annotation.supersedes_annotation_id = server.supersedes_annotation_id.clone();
855 annotation.supersedes_rewrite_pct = server.supersedes_rewrite_pct;
856 differs
857 }
858 None => {
859 blob.annotations.push(Annotation {
860 annotation_id: local_id.clone(),
861 scope: scope_from_proto(server.scope.as_ref()),
862 status: new_status,
863 revisions: new_revisions,
864 supersedes_annotation_id: server.supersedes_annotation_id.clone(),
865 supersedes_rewrite_pct: server.supersedes_rewrite_pct,
866 visibility: objects::object::VisibilityTier::default(),
867 resolved_from_discussion: None,
868 });
869 true
870 }
871 };
872
873 if !changed {
874 return Ok(false);
875 }
876 let new_root = repo.set_context_blob(context_root.as_ref(), target, &blob)?;
877 if context_root != Some(new_root) {
878 put_context_attachment(repo, head_state, Some(new_root))?;
879 }
880 Ok(true)
881}
882
883fn reconcile_revisions_pull(
888 existing: &[AnnotationRevision],
889 server_revs: &[AnnotationRevision],
890 existing_links: &[RevisionLink],
891 self_local_attr: Option<&str>,
892 username: Option<&str>,
893) -> (Vec<AnnotationRevision>, Vec<RevisionLink>) {
894 let hosted = hosted_attribution(username);
895 let linked_local: HashSet<&str> = existing_links.iter().map(|l| l.local.as_str()).collect();
896 let mut link_by_server: std::collections::HashMap<&str, &str> =
897 std::collections::HashMap::new();
898 for link in existing_links {
899 link_by_server.insert(link.server.as_str(), link.local.as_str());
900 }
901
902 let mut consumed: HashSet<String> = HashSet::new();
903 let mut new_revisions: Vec<AnnotationRevision> = Vec::new();
904 let mut new_links: Vec<RevisionLink> = Vec::new();
905
906 for server_rev in server_revs {
907 if let Some(local_rev_id) = link_by_server.get(server_rev.revision_id.as_str())
909 && let Some(local) = existing.iter().find(|rev| {
910 rev.revision_id == *local_rev_id && !consumed.contains(&rev.revision_id)
911 })
912 {
913 consumed.insert(local.revision_id.clone());
914 new_revisions.push(local.clone());
915 new_links.push(RevisionLink {
916 local: local.revision_id.clone(),
917 server: server_rev.revision_id.clone(),
918 });
919 continue;
920 }
921 if let Some(local) = existing.iter().find(|rev| {
923 rev.revision_id == server_rev.revision_id && !consumed.contains(&rev.revision_id)
924 }) {
925 consumed.insert(local.revision_id.clone());
926 new_revisions.push(local.clone());
927 new_links.push(RevisionLink {
928 local: local.revision_id.clone(),
929 server: server_rev.revision_id.clone(),
930 });
931 continue;
932 }
933 let candidate = existing.iter().find(|rev| {
935 !linked_local.contains(rev.revision_id.as_str())
936 && !consumed.contains(&rev.revision_id)
937 && rev.content == server_rev.content
938 && reconcile_ok(rev, server_rev, hosted.as_deref(), self_local_attr)
939 });
940 if let Some(local) = candidate {
941 consumed.insert(local.revision_id.clone());
942 new_revisions.push(local.clone());
943 new_links.push(RevisionLink {
944 local: local.revision_id.clone(),
945 server: server_rev.revision_id.clone(),
946 });
947 continue;
948 }
949 consumed.insert(server_rev.revision_id.clone());
951 new_revisions.push(server_rev.clone());
952 new_links.push(RevisionLink {
953 local: server_rev.revision_id.clone(),
954 server: server_rev.revision_id.clone(),
955 });
956 }
957
958 for revision in existing {
961 if !consumed.contains(&revision.revision_id)
962 && !new_revisions
963 .iter()
964 .any(|rev| rev.revision_id == revision.revision_id)
965 {
966 new_revisions.push(revision.clone());
967 }
968 }
969
970 (new_revisions, new_links)
971}
972
973fn reconcile_ok(
977 local: &AnnotationRevision,
978 server: &AnnotationRevision,
979 hosted: Option<&str>,
980 self_local_attr: Option<&str>,
981) -> bool {
982 let pushed_by_us = self_local_attr == Some(local.attribution.as_str())
985 && hosted == Some(server.attribution.as_str());
986 let pulled_before =
989 local.attribution == server.attribution && local.created_at == server.created_at;
990 pushed_by_us || pulled_before
991}
992
993async fn list_server_annotations(
998 client: &mut HostedClient,
999 repo_path: &str,
1000) -> Result<Vec<ContextAnnotation>> {
1001 Ok(list_server_targets(client, repo_path)
1002 .await?
1003 .into_iter()
1004 .map(|(_, annotation)| annotation)
1005 .collect())
1006}
1007
1008async fn list_server_targets(
1009 client: &mut HostedClient,
1010 repo_path: &str,
1011) -> Result<Vec<(ContextTarget, ContextAnnotation)>> {
1012 let response = client
1013 .list_context(repo_path, None, None, None)
1014 .await
1015 .context("list hosted context")?;
1016 let mut out = Vec::new();
1017 for file in response.files {
1018 let Ok(target) = ContextTarget::file(&file.path) else {
1019 continue;
1020 };
1021 for annotation in file.annotations {
1022 out.push((target.clone(), annotation));
1023 }
1024 }
1025 for state in response.states {
1026 let Some(state_id) = state_id_from_proto(state.state_id.as_ref()) else {
1027 continue;
1028 };
1029 let target = ContextTarget::state(state_id);
1030 for annotation in state.annotations {
1031 out.push((target.clone(), annotation));
1032 }
1033 }
1034 Ok(out)
1035}
1036
1037async fn fetch_history(
1040 client: &mut HostedClient,
1041 repo_path: &str,
1042 annotation_id: &str,
1043) -> Result<Vec<AnnotationRevision>> {
1044 let history = client
1045 .get_context_history(repo_path, None, annotation_id)
1046 .await
1047 .with_context(|| format!("fetch hosted annotation history {annotation_id}"))?;
1048 let mut revisions: Vec<AnnotationRevision> = history
1049 .revisions
1050 .into_iter()
1051 .map(|revision| AnnotationRevision {
1052 revision_id: revision.revision_id,
1053 kind: kind_from_proto(revision.kind),
1054 content: revision.content,
1055 tags: revision.tags,
1056 attribution: revision.attribution,
1057 created_at: revision.created_at.map(|ts| ts.seconds).unwrap_or(0),
1058 source_hash: content_hash_from_bytes(&revision.source_hash),
1059 created_at_state: state_id_from_proto(revision.created_at_state.as_ref()),
1060 })
1061 .collect();
1062 revisions.reverse();
1063 Ok(revisions)
1064}
1065
1066const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_6374_785f_7379_6e63_0001);
1069
1070fn revise_op_id(repo_path: &str, server_id: &str, revision_id: &str) -> String {
1071 uuid::Uuid::new_v5(
1072 &OP_NAMESPACE,
1073 format!("revise:{repo_path}:{server_id}:{revision_id}").as_bytes(),
1074 )
1075 .to_string()
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 fn rev(id: &str, attr: &str, content: &str, created_at: i64) -> AnnotationRevision {
1083 AnnotationRevision {
1084 revision_id: id.to_string(),
1085 kind: AnnotationKind::Rationale,
1086 content: content.to_string(),
1087 tags: vec![],
1088 attribution: attr.to_string(),
1089 created_at,
1090 source_hash: None,
1091 created_at_state: None,
1092 }
1093 }
1094
1095 #[test]
1096 fn scope_round_trips_through_proto() {
1097 for scope in [
1098 AnnotationScope::File,
1099 AnnotationScope::Symbol {
1100 name: "run".to_string(),
1101 resolved_lines: Some((3, 9)),
1102 },
1103 AnnotationScope::Lines(10, 20),
1104 ] {
1105 assert_eq!(scope_from_proto(Some(&scope_to_proto(&scope))), scope);
1106 }
1107 }
1108
1109 #[test]
1110 fn kind_round_trips_through_proto() {
1111 for kind in [
1112 AnnotationKind::Constraint,
1113 AnnotationKind::Invariant,
1114 AnnotationKind::Rationale,
1115 ] {
1116 assert_eq!(kind_from_proto(kind_to_proto(kind) as i32), kind);
1117 }
1118 }
1119
1120 #[test]
1121 fn scope_ident_ignores_resolved_lines() {
1122 let a = AnnotationScope::Symbol {
1123 name: "greet".to_string(),
1124 resolved_lines: Some((1, 2)),
1125 };
1126 let b = AnnotationScope::Symbol {
1127 name: "greet".to_string(),
1128 resolved_lines: None,
1129 };
1130 assert_eq!(scope_ident(&a), scope_ident(&b));
1131 }
1132
1133 #[test]
1138 fn pull_two_author_revisions_no_loss_no_dup_server_order() {
1139 let existing = vec![
1140 rev("sA1", "alice <>", "v1", 1), rev("rA3-local", "Alice <a@x>", "v3", 30), ];
1143 let existing_links = vec![
1144 RevisionLink {
1145 local: "sA1".into(),
1146 server: "sA1".into(),
1147 },
1148 RevisionLink {
1149 local: "rA3-local".into(),
1150 server: "sA3".into(),
1151 },
1152 ];
1153 let server_revs = vec![
1154 rev("sA1", "alice <>", "v1", 1),
1155 rev("sB2", "bob <>", "v2-bob", 20), rev("sA3", "alice <>", "v3", 30), ];
1158 let (new_revisions, new_links) = reconcile_revisions_pull(
1159 &existing,
1160 &server_revs,
1161 &existing_links,
1162 Some("Alice <a@x>"),
1163 Some("alice"),
1164 );
1165 let ids: Vec<&str> = new_revisions
1166 .iter()
1167 .map(|r| r.revision_id.as_str())
1168 .collect();
1169 assert_eq!(ids, vec!["sA1", "sB2", "rA3-local"]);
1171 let contents: Vec<&str> = new_revisions.iter().map(|r| r.content.as_str()).collect();
1172 assert_eq!(contents, vec!["v1", "v2-bob", "v3"]);
1173 let unique: HashSet<&str> = ids.iter().copied().collect();
1175 assert_eq!(unique.len(), 3);
1176 assert_eq!(new_links.len(), 3);
1178 }
1179
1180 #[test]
1185 fn pull_rejects_cross_author_identical_body() {
1186 let existing = vec![rev("rLocal", "Alice <a@x>", "lgtm", 5)];
1187 let server_revs = vec![rev("sBob", "bob <>", "lgtm", 9)];
1188 let (new_revisions, _links) = reconcile_revisions_pull(
1189 &existing,
1190 &server_revs,
1191 &[],
1192 Some("Alice <a@x>"),
1193 Some("alice"),
1194 );
1195 let ids: HashSet<&str> = new_revisions
1196 .iter()
1197 .map(|r| r.revision_id.as_str())
1198 .collect();
1199 assert_eq!(new_revisions.len(), 2, "both must survive, none collapsed");
1200 assert!(
1201 ids.contains("sBob"),
1202 "Bob's revision materialized distinctly"
1203 );
1204 assert!(ids.contains("rLocal"), "Alice's local revision preserved");
1205 }
1206
1207 #[test]
1210 fn pull_relinks_our_pushed_revision_after_lost_mirror() {
1211 let existing = vec![rev("rMine", "Alice <a@x>", "ship it", 40)];
1212 let server_revs = vec![rev("sMine", "alice <>", "ship it", 40)];
1213 let (new_revisions, links) = reconcile_revisions_pull(
1214 &existing,
1215 &server_revs,
1216 &[], Some("Alice <a@x>"),
1218 Some("alice"),
1219 );
1220 assert_eq!(new_revisions.len(), 1, "no duplicate of our own revision");
1221 assert_eq!(new_revisions[0].revision_id, "rMine", "local id preserved");
1222 assert_eq!(links[0].local, "rMine");
1223 assert_eq!(links[0].server, "sMine");
1224 }
1225
1226 #[test]
1227 fn reconcile_ok_rules() {
1228 let hosted = Some("alice <>");
1231 let me = Some("Alice <a@x>");
1232 assert!(reconcile_ok(
1234 &rev("l", "Alice <a@x>", "x", 1),
1235 &rev("s", "alice <>", "x", 999),
1236 hosted,
1237 me,
1238 ));
1239 assert!(!reconcile_ok(
1241 &rev("l", "Alice <a@x>", "x", 1),
1242 &rev("s", "bob <>", "x", 1),
1243 hosted,
1244 me,
1245 ));
1246 assert!(reconcile_ok(
1248 &rev("l", "bob <>", "x", 7),
1249 &rev("s", "bob <>", "x", 7),
1250 hosted,
1251 me,
1252 ));
1253 assert!(!reconcile_ok(
1255 &rev("l", "bob <>", "x", 7),
1256 &rev("s", "bob <>", "x", 8),
1257 hosted,
1258 me,
1259 ));
1260 }
1261
1262 #[test]
1266 fn supersede_resolves_local_to_server_through_mirror() {
1267 let mut mirror = HostedContextMirror::default();
1268 get_or_create_entry(&mut mirror, "ns/repo", "local-old").server_id = "srv-old".into();
1270 get_or_create_entry(&mut mirror, "ns/repo", "srv-pulled").server_id = "srv-pulled".into();
1272
1273 assert_eq!(
1274 server_id_for_local(&mirror, "ns/repo", "local-old"),
1275 Some("srv-old".to_string()),
1276 );
1277 assert_eq!(
1278 server_id_for_local(&mirror, "ns/repo", "srv-pulled"),
1279 Some("srv-pulled".to_string()),
1280 );
1281 get_or_create_entry(&mut mirror, "ns/repo", "in-flight").pending_create_op =
1283 Some("op".into());
1284 assert_eq!(server_id_for_local(&mirror, "ns/repo", "in-flight"), None);
1285 }
1286
1287 #[test]
1290 fn server_id_is_linked_detects_prior_links() {
1291 let mut mirror = HostedContextMirror::default();
1292 get_or_create_entry(&mut mirror, "ns/repo", "local-a").server_id = "srv-1".into();
1293 assert!(server_id_is_linked(&mirror, "ns/repo", "srv-1"));
1294 assert!(!server_id_is_linked(&mirror, "ns/repo", "srv-2"));
1295 }
1296}