Skip to main content

cli/client/
discussion_sync.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Hosted discussion sync bridge.
3//!
4//! Local discussions live in the append-only [`CollaborationStore`] op-log
5//! (`.heddle/collaboration/ops`). The hosted weft `CollaborationService` speaks
6//! a different, per-state `DiscussionsBlob` model (id-keyed discussions with a
7//! linear turn list). This module bridges the two:
8//!
9//! * **Push (write path):** after a successful `heddle push`, replay local
10//!   symbol-anchored discussion turns *we authored* to the server via the
11//!   caller-authenticated `OpenDiscussion` / `AppendTurn` RPCs (enforce-mode
12//!   signed). #549 rejects attachments in the pack, so they cannot ride it.
13//! * **Pull/clone (read path):** after a successful clone/pull, `ListByState`
14//!   the head state's discussions and materialize any turns we do not already
15//!   hold into the local op-log so `discuss list` / `discuss show` see them.
16//!
17//! ## Turn identity
18//!
19//! Local turn order is op-log materialization order; server turn order is
20//! push/append order. They diverge the moment both sides append, so a single
21//! "N turns synced" prefix count is a lie. Instead the per-repo mirror map
22//! (`.heddle/collaboration/hosted-mirror.json`) records, per discussion, an
23//! explicit set of **turn links**: a local turn id ↔ a server turn ordinal.
24//!
25//! A local turn id is `(CollabOpId, index-within-op)` — NOT the `CollabOpId`
26//! alone, because a `LegacyImported` op (a migrated blob→op-log discussion)
27//! materializes *all* its turns under one shared `CollabOpId`. Keying on the op
28//! alone would give turns 2..N the same idempotency key with different bodies
29//! (a weft `with_idempotency` conflict) and collapse them into one link, so the
30//! rest would be silently dropped on exactly the migrated repos.
31//!
32//! Push sends only turns that are self-authored AND unlinked; pull materializes
33//! only server ordinals not yet linked. Client operation ids are derived from
34//! the stable turn id, so a retry replays instead of conflicting.
35//!
36//! ## Reconciliation is author-aware, never body-alone
37//!
38//! When the mirror map is lost/rebuilt, an unlinked server turn is reconciled
39//! against an unlinked local turn only under an explicit **author** rule — never
40//! body equality alone, which would cross-link two different authors' identical
41//! bodies (`"lgtm"`, `"+1"`) and silently drop one:
42//! * (i) a turn WE pushed — the local turn is self-authored AND the server
43//!   turn's author is our own hosted username (weft stamps
44//!   `Principal::new(username, "")`); or
45//! * (ii) a turn we previously PULLED — the local op's author (written as
46//!   `Principal::new(author_name, author_email)`) and `occurred_at_ms` exactly
47//!   equal the server turn's author and `posted_at`.
48//!
49//! Anything matching neither rule materializes as a new, distinct turn.
50//! Distinguishing "a turn I pushed" from "a turn another clone of the SAME user
51//! pushed" is impossible client-side without server-minted turn ids (weft#640);
52//! rule (i) is precise across distinct hosted principals, which is the real
53//! multi-party case.
54//!
55//! The mirror is saved after **each** discussion and on the error path, with
56//! collect-and-continue per discussion — one wedged discussion (e.g. weft#638's
57//! no-HEAD `AppendTurn`) cannot abort the rest, and a mid-run failure never
58//! leaves durable writes without their mapping.
59//!
60//! Scope: discussions only; `context`/`review` share the same seam (not built).
61//! `resolve`/`reopen` are not yet mirrored (turns only).
62
63#![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::fs_atomic::write_file_atomic;
74use objects::object::{
75    Attribution, CollabOpId, CollaborationAnchor, CollaborationIdempotencyKey,
76    CollaborationOperationBodyV1, CollaborationOperationEnvelope, DiscussionRecordId,
77    DiscussionTurnV1, MaterializedDiscussion, Principal, StateId, VisibilityTier,
78};
79use objects::store::ObjectStore;
80use repo::{CollaborationStore, Repository, mark_legacy_discussions_migrated};
81use serde::{Deserialize, Serialize};
82
83use crate::client::HostedGrpcClient;
84use heddle_client::grpc_hosted::{HostedDiscussion, HostedDiscussionTurn};
85
86/// Deterministic namespace for the derived client-operation-ids so a retried
87/// push replays (server-side idempotent) rather than duplicating a turn.
88const OP_NAMESPACE: uuid::Uuid = uuid::Uuid::from_u128(0x6865_6464_6c65_6469_7363_7573_7379_6e63);
89
90#[derive(Debug, Default, Serialize, Deserialize)]
91struct HostedMirror {
92    /// Server repo path → mirror state for that hosted repo.
93    #[serde(default)]
94    repos: BTreeMap<String, RepoMirror>,
95}
96
97#[derive(Debug, Default, Serialize, Deserialize)]
98struct RepoMirror {
99    #[serde(default)]
100    discussions: Vec<MirrorEntry>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104struct MirrorEntry {
105    /// Local `DiscussionRecordId` (string form).
106    local_id: String,
107    /// Server-assigned discussion id.
108    server_id: String,
109    /// Turns known to exist on BOTH sides, each carrying its identity on both.
110    #[serde(default)]
111    links: Vec<TurnLink>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115struct TurnLink {
116    /// Local turn id: `{CollabOpId}#{index-within-op}` — the stable turn
117    /// identity (unique even when a `LegacyImported` op carries many turns).
118    local_turn_id: String,
119    /// Position of the turn in the server's linear turn list.
120    server_ordinal: usize,
121}
122
123/// One local turn with the identity + attribution the sync bridge reasons over.
124struct LocalTurn {
125    turn_id: String,
126    body: String,
127    author_name: String,
128    author_email: String,
129    occurred_at_ms: i64,
130    is_self: bool,
131}
132
133fn turn_identity(op_id: &CollabOpId, index_within_op: usize) -> String {
134    format!("{}#{index_within_op}", op_id.to_string_full())
135}
136
137fn mirror_path(heddle_dir: &Path) -> PathBuf {
138    heddle_dir.join("collaboration").join("hosted-mirror.json")
139}
140
141fn load_mirror(heddle_dir: &Path) -> Result<HostedMirror> {
142    match fs::read(mirror_path(heddle_dir)) {
143        Ok(bytes) => serde_json::from_slice(&bytes).context("decode hosted discussion mirror map"),
144        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(HostedMirror::default()),
145        Err(error) => Err(error).context("read hosted discussion mirror map"),
146    }
147}
148
149fn save_mirror(heddle_dir: &Path, mirror: &HostedMirror) -> Result<()> {
150    let path = mirror_path(heddle_dir);
151    if let Some(parent) = path.parent() {
152        fs::create_dir_all(parent).context("create collaboration dir")?;
153    }
154    let bytes = serde_json::to_vec_pretty(mirror).context("encode hosted discussion mirror map")?;
155    write_file_atomic(&path, &bytes).context("write hosted discussion mirror map")?;
156    Ok(())
157}
158
159fn open_op_id(repo_path: &str, local_id: &str) -> String {
160    uuid::Uuid::new_v5(&OP_NAMESPACE, format!("open:{repo_path}:{local_id}").as_bytes()).to_string()
161}
162
163fn append_op_id(repo_path: &str, server_id: &str, turn_id: &str) -> String {
164    uuid::Uuid::new_v5(
165        &OP_NAMESPACE,
166        format!("append:{repo_path}:{server_id}:{turn_id}").as_bytes(),
167    )
168    .to_string()
169}
170
171fn now_ms() -> i64 {
172    SystemTime::now()
173        .duration_since(UNIX_EPOCH)
174        .map(|d| d.as_millis() as i64)
175        .unwrap_or(0)
176}
177
178/// Enumerate a materialized discussion's turns with their per-op index (turn
179/// identity), author, and whether the local principal authored them. Reads each
180/// distinct op once for its author/timestamp.
181fn collect_local_turns(
182    store: &CollaborationStore,
183    discussion: &MaterializedDiscussion,
184    self_attr: Option<&Attribution>,
185) -> Result<Vec<LocalTurn>> {
186    let mut per_op: HashMap<CollabOpId, usize> = HashMap::new();
187    let mut op_author: HashMap<CollabOpId, (Principal, i64)> = HashMap::new();
188    let mut turns = Vec::with_capacity(discussion.turns.len());
189    for (op_id, turn) in &discussion.turns {
190        let index_within_op = {
191            let slot = per_op.entry(*op_id).or_insert(0);
192            let value = *slot;
193            *slot += 1;
194            value
195        };
196        let (principal, occurred_at_ms) = match op_author.get(op_id) {
197            Some(cached) => cached.clone(),
198            None => {
199                let decoded = store
200                    .read_operation(op_id)
201                    .context("read collaboration operation")?
202                    .ok_or_else(|| anyhow!("collaboration operation {op_id} missing"))?;
203                let entry = (
204                    decoded.operation.author.principal.clone(),
205                    decoded.operation.occurred_at_ms,
206                );
207                op_author.insert(*op_id, entry.clone());
208                entry
209            }
210        };
211        // F3: fail closed — an op we cannot attribute to the local principal is
212        // NOT treated as ours (no `self_attr` ⇒ never self).
213        let is_self = self_attr.is_some_and(|attr| principals_match(&principal, &attr.principal));
214        turns.push(LocalTurn {
215            turn_id: turn_identity(op_id, index_within_op),
216            body: turn.body.clone(),
217            author_name: principal.name.clone(),
218            author_email: principal.email.clone(),
219            occurred_at_ms,
220            is_self,
221        });
222    }
223    Ok(turns)
224}
225
226/// Publish local symbol-anchored discussion turns we authored to the hosted
227/// `CollaborationService`. Saves the mirror after each discussion and continues
228/// past a per-discussion failure (warn-and-skip).
229pub async fn push_discussions(
230    repo: &Repository,
231    client: &mut HostedGrpcClient,
232    repo_path: &str,
233) -> Result<usize> {
234    let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
235    let materialized = store.materialize().context("materialize local discussions")?;
236    if materialized.discussions.is_empty() {
237        return Ok(0);
238    }
239    let self_attr = repo.get_attribution().ok();
240
241    let mut mirror = load_mirror(repo.heddle_dir())?;
242    let mut synced = 0usize;
243
244    for (discussion_id, discussion) in &materialized.discussions {
245        let result = push_one(
246            client,
247            &store,
248            repo,
249            repo_path,
250            &mut mirror,
251            self_attr.as_ref(),
252            &discussion_id.to_string(),
253            discussion,
254        )
255        .await;
256        // Persist links after every discussion — including the error path, where
257        // some turns may already be on the server — so a retry resumes cleanly.
258        save_mirror(repo.heddle_dir(), &mirror)?;
259        match result {
260            Ok(true) => synced += 1,
261            Ok(false) => {}
262            Err(error) => {
263                eprintln!(
264                    "{} hosted discussion {}: {error:#}",
265                    crate::cli::style::warn_marker(),
266                    discussion_id
267                );
268            }
269        }
270    }
271
272    Ok(synced)
273}
274
275#[allow(clippy::too_many_arguments)]
276async fn push_one(
277    client: &mut HostedGrpcClient,
278    store: &CollaborationStore,
279    repo: &Repository,
280    repo_path: &str,
281    mirror: &mut HostedMirror,
282    self_attr: Option<&Attribution>,
283    local_id: &str,
284    discussion: &MaterializedDiscussion,
285) -> Result<bool> {
286    let CollaborationAnchor::Symbol {
287        state_id,
288        path,
289        symbol,
290    } = &discussion.anchor
291    else {
292        // Only symbol-anchored discussions map to the hosted PathSymbolRef.
293        return Ok(false);
294    };
295    let Some(state) = repo
296        .store()
297        .get_state(state_id)
298        .context("load discussion anchor state")?
299    else {
300        return Ok(false);
301    };
302    let change_id = state.change_id;
303    let visibility = discussion.visibility.as_str().to_string();
304
305    let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
306    let entry_index = repo_mirror
307        .discussions
308        .iter()
309        .position(|entry| entry.local_id == local_id);
310    let linked: HashSet<String> = match entry_index {
311        Some(i) => repo_mirror.discussions[i]
312            .links
313            .iter()
314            .map(|link| link.local_turn_id.clone())
315            .collect(),
316        None => HashSet::new(),
317    };
318
319    // Candidates: turns we authored that the server does not already hold.
320    let local_turns = collect_local_turns(store, discussion, self_attr)?;
321    let mut candidates: Vec<(String, String)> = Vec::new(); // (turn_id, body)
322    let mut skipped_foreign = 0usize;
323    for turn in &local_turns {
324        if linked.contains(&turn.turn_id) {
325            continue;
326        }
327        if !turn.is_self {
328            // Never re-publish another author's turn under our identity.
329            skipped_foreign += 1;
330            continue;
331        }
332        candidates.push((turn.turn_id.clone(), turn.body.clone()));
333    }
334    if skipped_foreign > 0 {
335        // F3: surface principal drift / foreign-authored unpushed turns instead
336        // of silently producing an empty candidate set.
337        eprintln!(
338            "{} hosted discussion {local_id}: {skipped_foreign} unlinked turn(s) not attributed to the local principal were left unpublished",
339            crate::cli::style::warn_marker(),
340        );
341    }
342    if candidates.is_empty() {
343        return Ok(false);
344    }
345
346    match entry_index {
347        None => {
348            let (open_turn_id, open_body) = candidates[0].clone();
349            let hosted = client
350                .open_discussion(
351                    repo_path,
352                    change_id,
353                    path,
354                    symbol,
355                    &open_body,
356                    &visibility,
357                    open_op_id(repo_path, local_id),
358                )
359                .await
360                .with_context(|| format!("open hosted discussion for {local_id}"))?;
361            let server_id = hosted.id.clone();
362            let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
363            repo_mirror.discussions.push(MirrorEntry {
364                local_id: local_id.to_string(),
365                server_id: server_id.clone(),
366                links: vec![TurnLink {
367                    local_turn_id: open_turn_id,
368                    server_ordinal: 0,
369                }],
370            });
371            let index = repo_mirror.discussions.len() - 1;
372            for (turn_id, body) in &candidates[1..] {
373                let hosted = client
374                    .append_turn(
375                        repo_path,
376                        &server_id,
377                        body,
378                        append_op_id(repo_path, &server_id, turn_id),
379                    )
380                    .await
381                    .with_context(|| format!("append hosted turn for {local_id}"))?;
382                push_link(
383                    mirror,
384                    repo_path,
385                    index,
386                    turn_id.clone(),
387                    hosted.turns.len().saturating_sub(1),
388                );
389            }
390            Ok(true)
391        }
392        Some(index) => {
393            let server_id = mirror.repos[repo_path].discussions[index].server_id.clone();
394            for (turn_id, body) in &candidates {
395                let hosted = client
396                    .append_turn(
397                        repo_path,
398                        &server_id,
399                        body,
400                        append_op_id(repo_path, &server_id, turn_id),
401                    )
402                    .await
403                    .with_context(|| format!("append hosted turn for {local_id}"))?;
404                push_link(
405                    mirror,
406                    repo_path,
407                    index,
408                    turn_id.clone(),
409                    hosted.turns.len().saturating_sub(1),
410                );
411            }
412            Ok(true)
413        }
414    }
415}
416
417/// Fetch hosted discussions for the repository head and materialize any turns we
418/// do not already hold. Saves the mirror after each discussion and continues
419/// past a per-discussion failure.
420pub async fn pull_discussions(
421    repo: &Repository,
422    client: &mut HostedGrpcClient,
423    repo_path: &str,
424) -> Result<usize> {
425    // Hosted discussions arrive as server-minted `Discussions` state-attachments
426    // on the pulled objects. Those are the transport form of what we
427    // authoritatively re-materialize below via the CollaborationService RPCs —
428    // so claim the one-shot legacy blob->op-log migration marker to keep it from
429    // also converting them (which would duplicate every discussion and diverge
430    // on multi-turn supersede history). Fresh clones have no genuine local
431    // legacy discussions, and existing repos already hold the marker.
432    mark_legacy_discussions_migrated(repo).context("claim legacy discussion migration marker")?;
433
434    let Some(head_state) = repo.head().context("resolve repository head")? else {
435        // weft#638: a repo with no HEAD cannot resolve a state to list against.
436        return Ok(0);
437    };
438    let Some(state) = repo
439        .store()
440        .get_state(&head_state)
441        .context("load head state")?
442    else {
443        return Ok(0);
444    };
445    let change_id = state.change_id;
446
447    let hosted = client
448        .list_discussions_by_state(repo_path, change_id, "all")
449        .await
450        .context("list hosted discussions")?;
451    if hosted.is_empty() {
452        return Ok(0);
453    }
454    // Our own hosted principal name, so reconciliation can recognize the turns
455    // we pushed (weft stamps `Principal::new(username, "")`).
456    let hosted_username = client.authenticated_username();
457
458    let store = CollaborationStore::open(repo.heddle_dir()).context("open collaboration store")?;
459    let self_attr = repo.get_attribution().ok();
460    let mut mirror = load_mirror(repo.heddle_dir())?;
461    let mut changed = 0usize;
462
463    for discussion in hosted {
464        let result = pull_one(
465            &store,
466            repo_path,
467            &mut mirror,
468            head_state,
469            hosted_username.as_deref(),
470            self_attr.as_ref(),
471            &discussion,
472        );
473        save_mirror(repo.heddle_dir(), &mirror)?;
474        match result {
475            Ok(true) => changed += 1,
476            Ok(false) => {}
477            Err(error) => {
478                eprintln!(
479                    "{} hosted discussion {}: {error:#}",
480                    crate::cli::style::warn_marker(),
481                    discussion.id
482                );
483            }
484        }
485    }
486
487    Ok(changed)
488}
489
490#[allow(clippy::too_many_arguments)]
491fn pull_one(
492    store: &CollaborationStore,
493    repo_path: &str,
494    mirror: &mut HostedMirror,
495    head_state: StateId,
496    hosted_username: Option<&str>,
497    self_attr: Option<&Attribution>,
498    discussion: &HostedDiscussion,
499) -> Result<bool> {
500    if discussion.turns.is_empty() {
501        return Ok(false);
502    }
503    let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
504    let entry_index = repo_mirror
505        .discussions
506        .iter()
507        .position(|entry| entry.server_id == discussion.id);
508
509    match entry_index {
510        None => {
511            let local_id = DiscussionRecordId::generate();
512            let anchor = CollaborationAnchor::Symbol {
513                state_id: discussion.opened_against_state.unwrap_or(head_state),
514                path: discussion.file.clone(),
515                symbol: discussion.symbol.clone(),
516            };
517            let title = derive_title(&discussion.turns[0].body, &discussion.symbol);
518            let visibility = parse_visibility_token(&discussion.visibility);
519
520            let first = &discussion.turns[0];
521            let open_op = write_local_operation(
522                store,
523                local_id,
524                Vec::new(),
525                turn_attribution(first),
526                turn_ms(first),
527                CollaborationOperationBodyV1::Open {
528                    title,
529                    anchor,
530                    visibility,
531                    turn: turn_body(first)?,
532                },
533            )?;
534            // Record the mapping immediately so a mid-materialization failure
535            // resumes into the `Some` arm instead of orphaning the written ops.
536            let repo_mirror = mirror.repos.entry(repo_path.to_string()).or_default();
537            repo_mirror.discussions.push(MirrorEntry {
538                local_id: local_id.to_string(),
539                server_id: discussion.id.clone(),
540                links: vec![TurnLink {
541                    local_turn_id: turn_identity(&open_op, 0),
542                    server_ordinal: 0,
543                }],
544            });
545            let index = repo_mirror.discussions.len() - 1;
546
547            let mut heads = vec![open_op];
548            for (ordinal, turn) in discussion.turns.iter().enumerate().skip(1) {
549                let op_id = write_local_operation(
550                    store,
551                    local_id,
552                    heads.clone(),
553                    turn_attribution(turn),
554                    turn_ms(turn),
555                    CollaborationOperationBodyV1::AppendTurn { turn: turn_body(turn)? },
556                )?;
557                heads = vec![op_id];
558                push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
559            }
560            Ok(true)
561        }
562        Some(index) => {
563            let local_id: DiscussionRecordId = repo_mirror.discussions[index]
564                .local_id
565                .parse()
566                .map_err(|e| anyhow!("mirror map has an invalid local discussion id: {e}"))?;
567            let linked_ordinals: HashSet<usize> = repo_mirror.discussions[index]
568                .links
569                .iter()
570                .map(|link| link.server_ordinal)
571                .collect();
572            let linked_turn_ids: HashSet<String> = repo_mirror.discussions[index]
573                .links
574                .iter()
575                .map(|link| link.local_turn_id.clone())
576                .collect();
577
578            let existing = store
579                .materialize_discussion(&local_id)
580                .context("materialize mirrored discussion")?
581                .ok_or_else(|| anyhow!("mirrored discussion {local_id} missing locally"))?;
582            let mut heads: Vec<CollabOpId> = existing.heads.iter().copied().collect();
583            // Unlinked local turns available to reconcile against server turns —
584            // author-aware only (see the module note on why body alone is wrong).
585            let mut available: Vec<LocalTurn> = collect_local_turns(store, &existing, self_attr)?
586                .into_iter()
587                .filter(|turn| !linked_turn_ids.contains(&turn.turn_id))
588                .collect();
589
590            let mut changed = false;
591            for (ordinal, server_turn) in discussion.turns.iter().enumerate() {
592                if linked_ordinals.contains(&ordinal) {
593                    continue;
594                }
595                if let Some(pos) = reconcile(&available, server_turn, hosted_username) {
596                    let local = available.swap_remove(pos);
597                    push_link(mirror, repo_path, index, local.turn_id, ordinal);
598                    changed = true;
599                    continue;
600                }
601                let op_id = write_local_operation(
602                    store,
603                    local_id,
604                    heads.clone(),
605                    turn_attribution(server_turn),
606                    turn_ms(server_turn),
607                    CollaborationOperationBodyV1::AppendTurn { turn: turn_body(server_turn)? },
608                )?;
609                heads = vec![op_id];
610                push_link(mirror, repo_path, index, turn_identity(&op_id, 0), ordinal);
611                changed = true;
612            }
613            Ok(changed)
614        }
615    }
616}
617
618/// Match an unlinked server turn against an unlinked local turn by AUTHOR, never
619/// body alone. Returns the index into `available` when one of the two identity
620/// rules holds.
621fn reconcile(
622    available: &[LocalTurn],
623    server_turn: &HostedDiscussionTurn,
624    hosted_username: Option<&str>,
625) -> Option<usize> {
626    let server_ms = server_turn.posted_at_secs.saturating_mul(1000);
627    available.iter().position(|local| {
628        if local.body != server_turn.body {
629            return false;
630        }
631        // (i) A turn we pushed: locally self-authored AND the server stamped it
632        // with our own hosted username.
633        let pushed_by_us = local.is_self
634            && hosted_username.is_some_and(|username| username == server_turn.author_name);
635        // (ii) A turn we previously pulled: the local op copied the server
636        // author + timestamp verbatim.
637        let pulled_before = local.author_name == server_turn.author_name
638            && local.author_email == server_turn.author_email
639            && local.occurred_at_ms == server_ms;
640        pushed_by_us || pulled_before
641    })
642}
643
644fn push_link(
645    mirror: &mut HostedMirror,
646    repo_path: &str,
647    index: usize,
648    local_turn_id: String,
649    server_ordinal: usize,
650) {
651    if let Some(entry) = mirror
652        .repos
653        .get_mut(repo_path)
654        .and_then(|repo_mirror| repo_mirror.discussions.get_mut(index))
655    {
656        entry.links.push(TurnLink {
657            local_turn_id,
658            server_ordinal,
659        });
660    }
661}
662
663fn principals_match(a: &Principal, b: &Principal) -> bool {
664    a.name == b.name && a.email == b.email
665}
666
667fn write_local_operation(
668    store: &CollaborationStore,
669    discussion_id: DiscussionRecordId,
670    parents: Vec<CollabOpId>,
671    author: Attribution,
672    occurred_at_ms: i64,
673    body: CollaborationOperationBodyV1,
674) -> Result<CollabOpId> {
675    let key = CollaborationIdempotencyKey::new(uuid::Uuid::new_v4().to_string())
676        .map_err(|e| anyhow!("invalid idempotency key: {e}"))?;
677    let operation =
678        CollaborationOperationEnvelope::new(discussion_id, parents, key, author, occurred_at_ms, body)
679            .map_err(|e| anyhow!("build collaboration operation: {e}"))?;
680    Ok(store
681        .write_operation(&operation)
682        .context("write collaboration operation")?
683        .operation_id)
684}
685
686fn turn_body(turn: &HostedDiscussionTurn) -> Result<DiscussionTurnV1> {
687    DiscussionTurnV1::new(turn.body.clone()).map_err(|e| anyhow!("invalid discussion turn: {e}"))
688}
689
690fn turn_attribution(turn: &HostedDiscussionTurn) -> Attribution {
691    Attribution::human(Principal::new(
692        turn.author_name.clone(),
693        turn.author_email.clone(),
694    ))
695}
696
697fn turn_ms(turn: &HostedDiscussionTurn) -> i64 {
698    if turn.posted_at_secs > 0 {
699        turn.posted_at_secs.saturating_mul(1000)
700    } else {
701        now_ms()
702    }
703}
704
705fn derive_title(body: &str, symbol: &str) -> String {
706    body.lines()
707        .map(str::trim)
708        .find(|line| !line.is_empty())
709        .unwrap_or(symbol)
710        .to_string()
711}
712
713fn parse_visibility_token(token: &str) -> VisibilityTier {
714    match token {
715        "public" => VisibilityTier::Public,
716        "internal" => VisibilityTier::Internal,
717        "team_scoped" => VisibilityTier::TeamScoped {
718            team_id: String::new(),
719        },
720        "restricted" => VisibilityTier::Restricted {
721            scope_label: String::new(),
722        },
723        "private" => VisibilityTier::Private {
724            scope_label: String::new(),
725        },
726        _ => VisibilityTier::Internal,
727    }
728}
729
730#[cfg(test)]
731mod tests {
732    use objects::object::{
733        CollaborationAnchor, CollaborationIdempotencyKey, CollaborationOperationBodyV1,
734        CollaborationOperationEnvelope, DiscussionRecordId, DiscussionTurnV1, LegacyDiscussionId,
735        LegacyDiscussionResolutionV1, LegacySourceLocator, Principal, StateAttachmentId, StateId,
736        VisibilityTier,
737    };
738    use objects::object::{Attribution, ContentHash};
739
740    use super::*;
741
742    fn local(body: &str, author_name: &str, author_email: &str, is_self: bool, ms: i64) -> LocalTurn {
743        LocalTurn {
744            turn_id: format!("co-{author_name}#0"),
745            body: body.to_string(),
746            author_name: author_name.to_string(),
747            author_email: author_email.to_string(),
748            occurred_at_ms: ms,
749            is_self,
750        }
751    }
752
753    fn server(body: &str, author_name: &str, author_email: &str, posted_at_secs: i64) -> HostedDiscussionTurn {
754        HostedDiscussionTurn {
755            author_name: author_name.to_string(),
756            author_email: author_email.to_string(),
757            body: body.to_string(),
758            posted_at_secs,
759        }
760    }
761
762    // F1: identical bodies from DIFFERENT authors must NOT reconcile — the
763    // server turn materializes as its own distinct turn; the local turn is left
764    // unlinked (so push will still publish it). Body equality alone never links.
765    #[test]
766    fn reconcile_rejects_identical_body_across_authors() {
767        // A's own unpushed "lgtm" (local principal "alice", not yet on server).
768        let available = vec![local("lgtm", "alice", "alice@x", true, 111)];
769        // B pushed "lgtm" (server stamped it "bob"); our hosted username is "alice".
770        let st = server("lgtm", "bob", "", 5);
771        assert_eq!(
772            reconcile(&available, &st, Some("alice")),
773            None,
774            "a self turn must not link to a DIFFERENT author's identical body (rule i needs our username to be the server author)"
775        );
776    }
777
778    // F1 rule (i): a turn WE pushed (self-authored locally, stamped with our
779    // hosted username on the server) reconciles.
780    #[test]
781    fn reconcile_links_turn_we_pushed() {
782        let available = vec![local("ship it", "alice-local", "alice@x", true, 111)];
783        let st = server("ship it", "alice", "", 9); // server stamped our hosted username
784        assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
785    }
786
787    // F1 rule (ii): a turn we previously PULLED (local op copied the server
788    // author + posted_at verbatim) reconciles.
789    #[test]
790    fn reconcile_links_turn_we_pulled() {
791        let available = vec![local("+1", "bob", "bob@x", false, 7000)]; // occurred = 7 * 1000
792        let st = server("+1", "bob", "bob@x", 7);
793        assert_eq!(reconcile(&available, &st, Some("alice")), Some(0));
794        // Same body, wrong author → no match.
795        let st_other = server("+1", "carol", "carol@x", 7);
796        assert_eq!(reconcile(&available, &st_other, Some("alice")), None);
797    }
798
799    // F2: a LegacyImported op carries N turns under ONE CollabOpId. They must
800    // yield N DISTINCT turn ids and thus N DISTINCT append idempotency keys —
801    // otherwise weft dedup conflicts on turn 3 and turns 3..N are dropped.
802    #[test]
803    fn legacy_imported_multi_turn_op_has_distinct_identities_and_keys() {
804        let temp = tempfile::TempDir::new().unwrap();
805        let store = CollaborationStore::open(temp.path()).unwrap();
806        let discussion_id: DiscussionRecordId =
807            "disc-018f47ea-4a54-7c89-b012-3456789abcde".parse().unwrap();
808        let author = Attribution::human(Principal::new("Importer", "importer@x"));
809        let anchor = CollaborationAnchor::Symbol {
810            state_id: StateId::from_bytes([1; 32]),
811            path: "src/lib.rs".to_string(),
812            symbol: "run".to_string(),
813        };
814        let op = CollaborationOperationEnvelope::new(
815            discussion_id,
816            Vec::new(),
817            CollaborationIdempotencyKey::new("legacy-1").unwrap(),
818            author.clone(),
819            1_000,
820            CollaborationOperationBodyV1::LegacyImported {
821                source: LegacySourceLocator::new(
822                    StateId::from_bytes([1; 32]),
823                    StateAttachmentId::from_hash(ContentHash::from_bytes([4; 32])),
824                    ContentHash::from_bytes([5; 32]),
825                ),
826                legacy_discussion_id: LegacyDiscussionId::new("legacy-1".to_string()).unwrap(),
827                aliases: Vec::new(),
828                title: "run".to_string(),
829                anchor,
830                visibility: VisibilityTier::Internal,
831                turns: vec![
832                    DiscussionTurnV1::new("turn one").unwrap(),
833                    DiscussionTurnV1::new("turn two").unwrap(),
834                    DiscussionTurnV1::new("turn three").unwrap(),
835                ],
836                resolution: LegacyDiscussionResolutionV1::Open,
837            },
838        )
839        .unwrap();
840        store.write_operation(&op).unwrap();
841
842        let materialized = store.materialize_discussion(&discussion_id).unwrap().unwrap();
843        assert_eq!(materialized.turns.len(), 3);
844        let self_attr = Attribution::human(Principal::new("Importer", "importer@x"));
845        let turns = collect_local_turns(&store, &materialized, Some(&self_attr)).unwrap();
846
847        // All three turns share ONE CollabOpId but MUST have distinct ids…
848        let ids: HashSet<&String> = turns.iter().map(|t| &t.turn_id).collect();
849        assert_eq!(ids.len(), 3, "multi-turn op must yield distinct turn ids");
850        // …and distinct append idempotency keys.
851        let keys: HashSet<String> = turns
852            .iter()
853            .map(|t| append_op_id("ns/repo", "server-1", &t.turn_id))
854            .collect();
855        assert_eq!(keys.len(), 3, "each turn must get a distinct idempotency key");
856        // All authored by the importer (self) → all are push candidates.
857        assert!(turns.iter().all(|t| t.is_self));
858    }
859
860    // F3: no local principal ⇒ turns are NOT treated as ours (fail closed).
861    #[test]
862    fn collect_local_turns_fails_closed_without_self_principal() {
863        let temp = tempfile::TempDir::new().unwrap();
864        let store = CollaborationStore::open(temp.path()).unwrap();
865        let discussion_id = DiscussionRecordId::generate();
866        let op = CollaborationOperationEnvelope::new(
867            discussion_id,
868            Vec::new(),
869            CollaborationIdempotencyKey::new("k").unwrap(),
870            Attribution::human(Principal::new("Ada", "ada@x")),
871            1,
872            CollaborationOperationBodyV1::Open {
873                title: "t".to_string(),
874                anchor: CollaborationAnchor::Symbol {
875                    state_id: StateId::from_bytes([2; 32]),
876                    path: "a.rs".to_string(),
877                    symbol: "a".to_string(),
878                },
879                visibility: VisibilityTier::Internal,
880                turn: DiscussionTurnV1::new("hi").unwrap(),
881            },
882        )
883        .unwrap();
884        store.write_operation(&op).unwrap();
885        let materialized = store.materialize_discussion(&discussion_id).unwrap().unwrap();
886        let turns = collect_local_turns(&store, &materialized, None).unwrap();
887        assert!(
888            turns.iter().all(|t| !t.is_self),
889            "with no local principal, no turn may be classified as ours"
890        );
891    }
892}