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