1#![cfg(feature = "client")]
64
65use std::{
66 collections::{BTreeMap, HashMap, HashSet},
67 fs,
68 path::{Path, PathBuf},
69 time::{SystemTime, UNIX_EPOCH},
70};
71
72use anyhow::{Context, Result, anyhow};
73use objects::{
74 fs_atomic::write_file_atomic,
75 object::{
76 Attribution, CollabOpId, CollaborationAnchor, CollaborationIdempotencyKey,
77 CollaborationOperationBodyV1, CollaborationOperationEnvelope, DiscussionRecordId,
78 DiscussionTurnV1, MaterializedDiscussion, Principal, StateId, VisibilityTier,
79 },
80 store::ObjectStore,
81};
82use repo::{CollaborationStore, Repository, mark_legacy_discussions_migrated};
83use serde::{Deserialize, Serialize};
84
85use crate::{
86 client::HostedClient,
87 hosted_runtime::hosted::{HostedDiscussion, HostedDiscussionTurn},
88};
89
90const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_6469_7363_7573_7379_6e63);
93
94#[derive(Debug, Default, Serialize, Deserialize)]
95struct HostedMirror {
96 #[serde(default)]
98 repos: BTreeMap<String, RepoMirror>,
99}
100
101#[derive(Debug, Default, Serialize, Deserialize)]
102struct RepoMirror {
103 #[serde(default)]
104 discussions: Vec<MirrorEntry>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108struct MirrorEntry {
109 local_id: String,
111 server_id: String,
113 #[serde(default)]
115 links: Vec<TurnLink>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119struct TurnLink {
120 local_turn_id: String,
123 server_ordinal: usize,
125}
126
127struct LocalTurn {
129 turn_id: String,
130 body: String,
131 author_name: String,
132 author_email: String,
133 occurred_at_ms: i64,
134 is_self: bool,
135}
136
137fn turn_identity(op_id: &CollabOpId, index_within_op: usize) -> String {
138 format!("{}#{index_within_op}", op_id.to_string_full())
139}
140
141fn mirror_path(heddle_dir: &Path) -> PathBuf {
142 heddle_dir.join("collaboration").join("hosted-mirror.json")
143}
144
145fn load_mirror(heddle_dir: &Path) -> Result<HostedMirror> {
146 match fs::read(mirror_path(heddle_dir)) {
147 Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted discussion mirror map"),
148 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(HostedMirror::default()),
149 Err(error) => Err(error).context("read hosted discussion mirror map"),
150 }
151}
152
153fn save_mirror(heddle_dir: &Path, mirror: &HostedMirror) -> Result<()> {
154 let path = mirror_path(heddle_dir);
155 if let Some(parent) = path.parent() {
156 fs::create_dir_all(parent).context("create collaboration dir")?;
157 }
158 let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted discussion mirror map")?;
159 write_file_atomic(&path, &bytes).context("write hosted discussion mirror map")?;
160 Ok(())
161}
162
163fn open_op_id(repo_path: &str, local_id: &str) -> String {
164 uuid::Uuid::new_v5(
165 &OP_NAMESPACE,
166 format!("open:{repo_path}:{local_id}").as_bytes(),
167 )
168 .to_string()
169}
170
171fn append_op_id(repo_path: &str, server_id: &str, turn_id: &str) -> String {
172 uuid::Uuid::new_v5(
173 &OP_NAMESPACE,
174 format!("append:{repo_path}:{server_id}:{turn_id}").as_bytes(),
175 )
176 .to_string()
177}
178
179fn now_ms() -> i64 {
180 SystemTime::now()
181 .duration_since(UNIX_EPOCH)
182 .map(|d| d.as_millis() as i64)
183 .unwrap_or(0)
184}
185
186fn collect_local_turns(
190 store: &CollaborationStore,
191 discussion: &MaterializedDiscussion,
192 self_attr: Option<&Attribution>,
193) -> Result<Vec<LocalTurn>> {
194 let mut per_op: HashMap<CollabOpId, usize> = HashMap::new();
195 let mut op_author: HashMap<CollabOpId, (Principal, i64)> = HashMap::new();
196 let mut turns = Vec::with_capacity(discussion.turns.len());
197 for (op_id, turn) in &discussion.turns {
198 let index_within_op = {
199 let slot = per_op.entry(*op_id).or_insert(0);
200 let value = *slot;
201 *slot += 1;
202 value
203 };
204 let (principal, occurred_at_ms) = match op_author.get(op_id) {
205 Some(cached) => cached.clone(),
206 None => {
207 let decoded = store
208 .read_operation(op_id)
209 .context("read collaboration operation")?
210 .ok_or_else(|| anyhow!("collaboration operation {op_id} missing"))?;
211 let entry = (
212 decoded.operation.author.principal.clone(),
213 decoded.operation.occurred_at_ms,
214 );
215 op_author.insert(*op_id, entry.clone());
216 entry
217 }
218 };
219 let is_self = self_attr.is_some_and(|attr| principals_match(&principal, &attr.principal));
222 turns.push(LocalTurn {
223 turn_id: turn_identity(op_id, index_within_op),
224 body: turn.body.clone(),
225 author_name: principal.name.clone(),
226 author_email: principal.email.clone(),
227 occurred_at_ms,
228 is_self,
229 });
230 }
231 Ok(turns)
232}
233
234pub async fn push_discussions(
238 repo: &Repository,
239 client: &mut HostedClient,
240 repo_path: &str,
241) -> Result<usize> {
242 let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
243 let materialized = store
244 .materialize()
245 .context("materialize local discussions")?;
246 if materialized.discussions.is_empty() {
247 return Ok(0);
248 }
249 let self_attr = repo.get_attribution().ok();
250
251 let mut mirror = load_mirror(repo.heddle_dir())?;
252 let mut synced = 0usize;
253
254 for (discussion_id, discussion) in &materialized.discussions {
255 let result = push_one(
256 client,
257 &store,
258 repo,
259 repo_path,
260 &mut mirror,
261 self_attr.as_ref(),
262 &discussion_id.to_string(),
263 discussion,
264 )
265 .await;
266 save_mirror(repo.heddle_dir(), &mirror)?;
269 match result {
270 Ok(true) => synced += 1,
271 Ok(false) => {}
272 Err(error) => {
273 eprintln!(
274 "{} hosted discussion {}: {error:#}",
275 crate::cli::style::warn_marker(),
276 discussion_id
277 );
278 }
279 }
280 }
281
282 Ok(synced)
283}
284
285#[allow(clippy::too_many_arguments)]
286async fn push_one(
287 client: &mut HostedClient,
288 store: &CollaborationStore,
289 repo: &Repository,
290 repo_path: &str,
291 mirror: &mut HostedMirror,
292 self_attr: Option<&Attribution>,
293 local_id: &str,
294 discussion: &MaterializedDiscussion,
295) -> Result<bool> {
296 let CollaborationAnchor::Symbol {
297 state_id,
298 path,
299 symbol,
300 } = &discussion.anchor
301 else {
302 return Ok(false);
304 };
305 let Some(state) = repo
306 .store()
307 .get_state(state_id)
308 .context("load discussion anchor state")?
309 else {
310 return Ok(false);
311 };
312 let change_id = state.change_id;
313 let visibility = discussion.visibility.as_str().to_string();
314
315 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
316 let entry_index = repo_mirror
317 .discussions
318 .iter()
319 .position(|entry| entry.local_id == local_id);
320 let linked: HashSet<String> = match entry_index {
321 Some(i) => repo_mirror.discussions[i]
322 .links
323 .iter()
324 .map(|link| link.local_turn_id.clone())
325 .collect(),
326 None => HashSet::new(),
327 };
328
329 let local_turns = collect_local_turns(store, discussion, self_attr)?;
331 let mut candidates: Vec<(String, String)> = Vec::new(); let mut skipped_foreign = 0usize;
333 for turn in &local_turns {
334 if linked.contains(&turn.turn_id) {
335 continue;
336 }
337 if !turn.is_self {
338 skipped_foreign += 1;
340 continue;
341 }
342 candidates.push((turn.turn_id.clone(), turn.body.clone()));
343 }
344 if skipped_foreign > 0 {
345 eprintln!(
348 "{} hosted discussion {local_id}: {skipped_foreign} unlinked turn(s) not attributed to the local principal were left unpublished",
349 crate::cli::style::warn_marker(),
350 );
351 }
352 if candidates.is_empty() {
353 return Ok(false);
354 }
355
356 match entry_index {
357 None => {
358 let (open_turn_id, open_body) = candidates[0].clone();
359 let hosted = client
360 .open_discussion(
361 repo_path,
362 change_id,
363 path,
364 symbol,
365 &open_body,
366 &visibility,
367 open_op_id(repo_path, local_id),
368 )
369 .await
370 .with_context(|| format!("open hosted discussion for {local_id}"))?;
371 let server_id = hosted.id.clone();
372 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
373 repo_mirror.discussions.push(MirrorEntry {
374 local_id: local_id.to_string(),
375 server_id: server_id.clone(),
376 links: vec![TurnLink {
377 local_turn_id: open_turn_id,
378 server_ordinal: 0,
379 }],
380 });
381 let index = repo_mirror.discussions.len() - 1;
382 for (turn_id, body) in &candidates[1..] {
383 let hosted = client
384 .append_turn(
385 repo_path,
386 &server_id,
387 body,
388 append_op_id(repo_path, &server_id, turn_id),
389 )
390 .await
391 .with_context(|| format!("append hosted turn for {local_id}"))?;
392 push_link(
393 mirror,
394 repo_path,
395 index,
396 turn_id.clone(),
397 hosted.turns.len().saturating_sub(1),
398 );
399 }
400 Ok(true)
401 }
402 Some(index) => {
403 let server_id = mirror.repos[repo_path].discussions[index].server_id.clone();
404 for (turn_id, body) in &candidates {
405 let hosted = client
406 .append_turn(
407 repo_path,
408 &server_id,
409 body,
410 append_op_id(repo_path, &server_id, turn_id),
411 )
412 .await
413 .with_context(|| format!("append hosted turn for {local_id}"))?;
414 push_link(
415 mirror,
416 repo_path,
417 index,
418 turn_id.clone(),
419 hosted.turns.len().saturating_sub(1),
420 );
421 }
422 Ok(true)
423 }
424 }
425}
426
427pub async fn pull_discussions(
431 repo: &Repository,
432 client: &mut HostedClient,
433 repo_path: &str,
434) -> Result<usize> {
435 mark_legacy_discussions_migrated(repo).context("claim legacy discussion migration marker")?;
443
444 let Some(head_state) = repo.head().context("resolve repository head")? else {
445 return Ok(0);
447 };
448 let Some(state) = repo
449 .store()
450 .get_state(&head_state)
451 .context("load head state")?
452 else {
453 return Ok(0);
454 };
455 let change_id = state.change_id;
456
457 let hosted = client
458 .list_discussions_by_state(repo_path, change_id, "all")
459 .await
460 .context("list hosted discussions")?;
461 if hosted.is_empty() {
462 return Ok(0);
463 }
464 let hosted_username = client.authenticated_username();
467
468 let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
469 let self_attr = repo.get_attribution().ok();
470 let mut mirror = load_mirror(repo.heddle_dir())?;
471 let mut changed = 0usize;
472
473 for discussion in hosted {
474 let result = pull_one(
475 &store,
476 repo_path,
477 &mut mirror,
478 head_state,
479 hosted_username.as_deref(),
480 self_attr.as_ref(),
481 &discussion,
482 );
483 save_mirror(repo.heddle_dir(), &mirror)?;
484 match result {
485 Ok(true) => changed += 1,
486 Ok(false) => {}
487 Err(error) => {
488 eprintln!(
489 "{} hosted discussion {}: {error:#}",
490 crate::cli::style::warn_marker(),
491 discussion.id
492 );
493 }
494 }
495 }
496
497 Ok(changed)
498}
499
500#[allow(clippy::too_many_arguments)]
501fn pull_one(
502 store: &CollaborationStore,
503 repo_path: &str,
504 mirror: &mut HostedMirror,
505 head_state: StateId,
506 hosted_username: Option<&str>,
507 self_attr: Option<&Attribution>,
508 discussion: &HostedDiscussion,
509) -> Result<bool> {
510 if discussion.turns.is_empty() {
511 return Ok(false);
512 }
513 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
514 let entry_index = repo_mirror
515 .discussions
516 .iter()
517 .position(|entry| entry.server_id == discussion.id);
518
519 match entry_index {
520 None => {
521 let local_id = DiscussionRecordId::generate();
522 let anchor = CollaborationAnchor::Symbol {
523 state_id: discussion.opened_against_state.unwrap_or(head_state),
524 path: discussion.file.clone(),
525 symbol: discussion.symbol.clone(),
526 };
527 let title = derive_title(&discussion.turns[0].body, &discussion.symbol);
528 let visibility = parse_visibility_token(&discussion.visibility);
529
530 let first = &discussion.turns[0];
531 let open_op = write_local_operation(
532 store,
533 local_id,
534 Vec::new(),
535 turn_attribution(first),
536 turn_ms(first),
537 CollaborationOperationBodyV1::Open {
538 title,
539 anchor,
540 visibility,
541 turn: turn_body(first)?,
542 },
543 )?;
544 let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
547 repo_mirror.discussions.push(MirrorEntry {
548 local_id: local_id.to_string(),
549 server_id: discussion.id.clone(),
550 links: vec![TurnLink {
551 local_turn_id: turn_identity(&open_op, 0),
552 server_ordinal: 0,
553 }],
554 });
555 let index = repo_mirror.discussions.len() - 1;
556
557 let mut heads = vec![open_op];
558 for (ordinal, turn) in discussion.turns.iter().enumerate().skip(1) {
559 let op_id = write_local_operation(
560 store,
561 local_id,
562 heads.clone(),
563 turn_attribution(turn),
564 turn_ms(turn),
565 CollaborationOperationBodyV1::AppendTurn {
566 turn: turn_body(turn)?,
567 },
568 )?;
569 heads = vec![op_id];
570 push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
571 }
572 Ok(true)
573 }
574 Some(index) => {
575 let local_id: DiscussionRecordId = repo_mirror.discussions[index]
576 .local_id
577 .parse()
578 .map_err(|e| anyhow!("mirror map has an invalid local discussion id: {e}"))?;
579 let linked_ordinals: HashSet<usize> = repo_mirror.discussions[index]
580 .links
581 .iter()
582 .map(|link| link.server_ordinal)
583 .collect();
584 let linked_turn_ids: HashSet<String> = repo_mirror.discussions[index]
585 .links
586 .iter()
587 .map(|link| link.local_turn_id.clone())
588 .collect();
589
590 let existing = store
591 .materialize_discussion(&local_id)
592 .context("materialize mirrored discussion")?
593 .ok_or_else(|| anyhow!("mirrored discussion {local_id} missing locally"))?;
594 let mut heads: Vec<CollabOpId> = existing.heads.iter().copied().collect();
595 let mut available: Vec<LocalTurn> = collect_local_turns(store, &existing, self_attr)?
598 .into_iter()
599 .filter(|turn| !linked_turn_ids.contains(&turn.turn_id))
600 .collect();
601
602 let mut changed = false;
603 for (ordinal, server_turn) in discussion.turns.iter().enumerate() {
604 if linked_ordinals.contains(&ordinal) {
605 continue;
606 }
607 if let Some(pos) = reconcile(&available, server_turn, hosted_username) {
608 let local = available.swap_remove(pos);
609 push_link(mirror, repo_path, index, local.turn_id, ordinal);
610 changed = true;
611 continue;
612 }
613 let op_id = write_local_operation(
614 store,
615 local_id,
616 heads.clone(),
617 turn_attribution(server_turn),
618 turn_ms(server_turn),
619 CollaborationOperationBodyV1::AppendTurn {
620 turn: turn_body(server_turn)?,
621 },
622 )?;
623 heads = vec![op_id];
624 push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
625 changed = true;
626 }
627 Ok(changed)
628 }
629 }
630}
631
632fn reconcile(
636 available: &[LocalTurn],
637 server_turn: &HostedDiscussionTurn,
638 hosted_username: Option<&str>,
639) -> Option<usize> {
640 let server_ms = server_turn.posted_at_secs.saturating_mul(1000);
641 available.iter().position(|local| {
642 if local.body != server_turn.body {
643 return false;
644 }
645 let pushed_by_us = local.is_self
648 && hosted_username.is_some_and(|username| username == server_turn.author_name);
649 let pulled_before = local.author_name == server_turn.author_name
652 && local.author_email == server_turn.author_email
653 && local.occurred_at_ms == server_ms;
654 pushed_by_us || pulled_before
655 })
656}
657
658fn push_link(
659 mirror: &mut HostedMirror,
660 repo_path: &str,
661 index: usize,
662 local_turn_id: String,
663 server_ordinal: usize,
664) {
665 if let Some(entry) = mirror
666 .repos
667 .get_mut(repo_path)
668 .and_then(|repo_mirror| repo_mirror.discussions.get_mut(index))
669 {
670 entry.links.push(TurnLink {
671 local_turn_id,
672 server_ordinal,
673 });
674 }
675}
676
677fn principals_match(a: &Principal, b: &Principal) -> bool {
678 a.name == b.name && a.email == b.email
679}
680
681fn write_local_operation(
682 store: &CollaborationStore,
683 discussion_id: DiscussionRecordId,
684 parents: Vec<CollabOpId>,
685 author: Attribution,
686 occurred_at_ms: i64,
687 body: CollaborationOperationBodyV1,
688) -> Result<CollabOpId> {
689 let key = CollaborationIdempotencyKey::new(uuid::Uuid::new_v4().to_string())
690 .map_err(|e| anyhow!("invalid idempotency key: {e}"))?;
691 let operation = CollaborationOperationEnvelope::new(
692 discussion_id,
693 parents,
694 key,
695 author,
696 occurred_at_ms,
697 body,
698 )
699 .map_err(|e| anyhow!("build collaboration operation: {e}"))?;
700 Ok(store
701 .write_operation(&operation)
702 .context("write collaboration operation")?
703 .operation_id)
704}
705
706fn turn_body(turn: &HostedDiscussionTurn) -> Result<DiscussionTurnV1> {
707 DiscussionTurnV1::new(turn.body.clone()).map_err(|e| anyhow!("invalid discussion turn: {e}"))
708}
709
710fn turn_attribution(turn: &HostedDiscussionTurn) -> Attribution {
711 Attribution::human(Principal::new(
712 turn.author_name.clone(),
713 turn.author_email.clone(),
714 ))
715}
716
717fn turn_ms(turn: &HostedDiscussionTurn) -> i64 {
718 if turn.posted_at_secs > 0 {
719 turn.posted_at_secs.saturating_mul(1000)
720 } else {
721 now_ms()
722 }
723}
724
725fn derive_title(body: &str, symbol: &str) -> String {
726 body.lines()
727 .map(str::trim)
728 .find(|line| !line.is_empty())
729 .unwrap_or(symbol)
730 .to_string()
731}
732
733fn parse_visibility_token(token: &str) -> VisibilityTier {
734 match token {
735 "public" => VisibilityTier::Public,
736 "internal" => VisibilityTier::Internal,
737 "team_scoped" => VisibilityTier::TeamScoped {
738 team_id: String::new(),
739 },
740 "restricted" => VisibilityTier::Restricted {
741 scope_label: String::new(),
742 },
743 "private" => VisibilityTier::Private {
744 scope_label: String::new(),
745 },
746 _ => VisibilityTier::Internal,
747 }
748}
749
750#[cfg(test)]
751mod tests {
752 use objects::object::{
753 Attribution, CollaborationAnchor, CollaborationIdempotencyKey,
754 CollaborationOperationBodyV1, CollaborationOperationEnvelope, ContentHash,
755 DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionId, LegacyDiscussionResolutionV1,
756 LegacySourceLocator, Principal, StateAttachmentId, StateId, VisibilityTier,
757 };
758
759 use super::*;
760
761 fn local(
762 body: &str,
763 author_name: &str,
764 author_email: &str,
765 is_self: bool,
766 ms: i64,
767 ) -> LocalTurn {
768 LocalTurn {
769 turn_id: format!("co-{author_name}#0"),
770 body: body.to_string(),
771 author_name: author_name.to_string(),
772 author_email: author_email.to_string(),
773 occurred_at_ms: ms,
774 is_self,
775 }
776 }
777
778 fn server(
779 body: &str,
780 author_name: &str,
781 author_email: &str,
782 posted_at_secs: i64,
783 ) -> HostedDiscussionTurn {
784 HostedDiscussionTurn {
785 author_name: author_name.to_string(),
786 author_email: author_email.to_string(),
787 body: body.to_string(),
788 posted_at_secs,
789 }
790 }
791
792 #[test]
796 fn reconcile_rejects_identical_body_across_authors() {
797 let available = vec![local("lgtm", "alice", "alice@x", true, 111)];
799 let st = server("lgtm", "bob", "", 5);
801 assert_eq!(
802 reconcile(&available, &st, Some("alice")),
803 None,
804 "a self turn must not link to a DIFFERENT author's identical body (rule i needs our username to be the server author)"
805 );
806 }
807
808 #[test]
811 fn reconcile_links_turn_we_pushed() {
812 let available = vec![local("ship it", "alice-local", "alice@x", true, 111)];
813 let st = server("ship it", "alice", "", 9); assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
815 }
816
817 #[test]
820 fn reconcile_links_turn_we_pulled() {
821 let available = vec![local("+1", "bob", "bob@x", false, 7000)]; let st = server("+1", "bob", "bob@x", 7);
823 assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
824 let st_other = server("+1", "carol", "carol@x", 7);
826 assert_eq!(reconcile(&available, &st_other, Some("alice")), None);
827 }
828
829 #[test]
833 fn legacy_imported_multi_turn_op_has_distinct_identities_and_keys() {
834 let temp = tempfile::TempDir::new().unwrap();
835 let store = CollaborationStore::open(temp.path()).unwrap();
836 let discussion_id: DiscussionRecordId =
837 "disc-018f47ea-4a54-7c89-b012-3456789abcde".parse().unwrap();
838 let author = Attribution::human(Principal::new("Importer", "importer@x"));
839 let anchor = CollaborationAnchor::Symbol {
840 state_id: StateId::from_bytes([1; 32]),
841 path: "src/lib.rs".to_string(),
842 symbol: "run".to_string(),
843 };
844 let op = CollaborationOperationEnvelope::new(
845 discussion_id,
846 Vec::new(),
847 CollaborationIdempotencyKey::new("legacy-1").unwrap(),
848 author.clone(),
849 1_000,
850 CollaborationOperationBodyV1::LegacyImported {
851 source: LegacySourceLocator::new(
852 StateId::from_bytes([1; 32]),
853 StateAttachmentId::from_hash(ContentHash::from_bytes([4; 32])),
854 ContentHash::from_bytes([5; 32]),
855 ),
856 legacy_discussion_id: LegacyDiscussionId::new("legacy-1".to_string()).unwrap(),
857 aliases: Vec::new(),
858 title: "run".to_string(),
859 anchor,
860 visibility: VisibilityTier::Internal,
861 turns: vec![
862 DiscussionTurnV1::new("turn one").unwrap(),
863 DiscussionTurnV1::new("turn two").unwrap(),
864 DiscussionTurnV1::new("turn three").unwrap(),
865 ],
866 resolution: LegacyDiscussionResolutionV1::Open,
867 },
868 )
869 .unwrap();
870 store.write_operation(&op).unwrap();
871
872 let materialized = store
873 .materialize_discussion(&discussion_id)
874 .unwrap()
875 .unwrap();
876 assert_eq!(materialized.turns.len(), 3);
877 let self_attr = Attribution::human(Principal::new("Importer", "importer@x"));
878 let turns = collect_local_turns(&store, &materialized, Some(&self_attr)).unwrap();
879
880 let ids: HashSet<&String> = turns.iter().map(|t| &t.turn_id).collect();
882 assert_eq!(ids.len(), 3, "multi-turn op must yield distinct turn ids");
883 let keys: HashSet<String> = turns
885 .iter()
886 .map(|t| append_op_id("ns/repo", "server-1", &t.turn_id))
887 .collect();
888 assert_eq!(
889 keys.len(),
890 3,
891 "each turn must get a distinct idempotency key"
892 );
893 assert!(turns.iter().all(|t| t.is_self));
895 }
896
897 #[test]
899 fn collect_local_turns_fails_closed_without_self_principal() {
900 let temp = tempfile::TempDir::new().unwrap();
901 let store = CollaborationStore::open(temp.path()).unwrap();
902 let discussion_id = DiscussionRecordId::generate();
903 let op = CollaborationOperationEnvelope::new(
904 discussion_id,
905 Vec::new(),
906 CollaborationIdempotencyKey::new("k").unwrap(),
907 Attribution::human(Principal::new("Ada", "ada@x")),
908 1,
909 CollaborationOperationBodyV1::Open {
910 title: "t".to_string(),
911 anchor: CollaborationAnchor::Symbol {
912 state_id: StateId::from_bytes([2; 32]),
913 path: "a.rs".to_string(),
914 symbol: "a".to_string(),
915 },
916 visibility: VisibilityTier::Internal,
917 turn: DiscussionTurnV1::new("hi").unwrap(),
918 },
919 )
920 .unwrap();
921 store.write_operation(&op).unwrap();
922 let materialized = store
923 .materialize_discussion(&discussion_id)
924 .unwrap()
925 .unwrap();
926 let turns = collect_local_turns(&store, &materialized, None).unwrap();
927 assert!(
928 turns.iter().all(|t| !t.is_self),
929 "with no local principal, no turn may be classified as ours"
930 );
931 }
932}