Skip to main content

lattice_diff/
subsystem.rs

1//! D.2.a / D.2.b / D.2.c — `DiffSubsystem`.
2//!
3//! Registry + compute + routing layer for the diff subsystem.
4//! Sessions are `Arc`-shared so consumers (the future inline
5//! overlay D.3, side-by-side D.4, hunk-transfer ops D.5,
6//! `:describe-diff` D.2.d) can hold a stable handle while the
7//! registry continues to mutate around them.
8//!
9//! ## Slice landing order
10//!
11//! - **D.2.a (2026-05-28)** — registry skeleton. [`DiffSubsystem`]
12//!   keying [`Arc<DiffSession>`] by [`BufferId`] behind a
13//!   `std::sync::Mutex`. `register` / `lookup` / `drop_session` /
14//!   `iter_sessions`. Per-session `ArcSwap<HunkIndex>` for
15//!   RCU-published reads.
16//! - **D.2.b (2026-05-28)** — compute path. [`DiffParticipantSource`]
17//!   trait (initial impl [`StaticSource`]). Monotonic revision
18//!   allocator on the session; gated publish via
19//!   [`DiffSession::try_publish_if_newer`]. Sync recompute body
20//!   [`DiffSession::recompute_blocking`]; tokio orchestration
21//!   [`DiffSubsystem::schedule_recompute`] spawns it on
22//!   `spawn_blocking` and returns a join handle.
23//! - **D.2.c (2026-05-29)** — routing + debounce + bus
24//!   subscription. [`BufferTextProvider`] (one host seam),
25//!   [`DiffParticipantSource`] (mirror of `DiffParticipantSource`),
26//!   [`BufferSource`] / [`BufferSource`] live-rope impls,
27//!   [`DiffDescriptor`] (sources + explicit `watch: Vec<BufferId>`).
28//!   Centralized inverse `watchers` index + per-session lazy
29//!   [`Debouncer`]. [`DiffSubsystem::bind`] takes a [`DocumentBufferResolver`]
30//!   and an `Arc<EventBus>` and returns a [`DiffSubscriptionGuard`]
31//!   that aborts the drainer task and unsubscribes the bus on
32//!   `Drop`. See
33//!   [`../../../docs/dev/architecture/diff-system.md`](../../../docs/dev/architecture/diff-system.md)
34//!   §3.4 for the full data + routing model and the
35//!   per-session-actor / direct-call alternatives that were
36//!   considered and rejected.
37//!
38//! ## Concurrency model
39//!
40//! - Registry / descriptor / watchers / debouncer mutation goes
41//!   through `std::sync::Mutex`. Mutation is buffer-open /
42//!   buffer-close / lazy-debouncer-spawn frequency — never
43//!   per-frame.
44//! - Per-session published `hunks: ArcSwap<HunkIndex>` is read
45//!   lock-free from any thread (the renderer, `:describe-diff`,
46//!   etc.).
47//! - The session's `Arc` itself is cloned out of the registry
48//!   under the registry lock, then released. Holders may keep
49//!   the `Arc` past a `drop_session` call — the registry forgets
50//!   the entry, but in-flight readers see a coherent snapshot
51//!   until they release their clone (RCU). Matches the standard
52//!   `BufferRegistry` / `cells_matrix_cell` pattern in this
53//!   crate.
54//! - Bus subscription is **centralized** (one subscription on
55//!   the subsystem; one drainer task). On each `DocumentChanged`
56//!   the drainer resolves DocumentId → BufferId, looks up
57//!   dependents in the `watchers` inverse index, and pokes each
58//!   session's `Debouncer`. The per-session debouncer is
59//!   **lazy** — no task at rest; a tokio task spawns on first
60//!   poke, sleeps the debounce window, and self-terminates after
61//!   the burst quiesces. Rationale + scaling discussion in §3.4.
62
63use std::collections::HashMap;
64use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
65use std::sync::{Arc, Mutex};
66use std::time::Duration;
67
68use arc_swap::ArcSwap;
69use ropey::Rope;
70use tokio::sync::{mpsc, oneshot};
71use tokio::task::JoinHandle;
72use tracing::debug;
73
74use crate::{DiffAlgorithm, HunkIndex, HunkKind, LineRange, compute_diff};
75use lattice_core::BufferId;
76use lattice_protocol::event::{Event, EventKind};
77use lattice_protocol::ids::DocumentId;
78use lattice_runtime::{EventBus, EventFilter, SubscriptionId, SubscriptionTarget};
79
80/// One participant in a diff session — produces the rope to
81/// diff for one slot in `Hunk::ranges`.
82///
83/// D.8.b (2026-05-31): collapses the previous
84/// `BaselineSource` + `CurrentSource` two-trait split into a
85/// single trait. The original split was structural sugar
86/// (made `descriptor.baseline.snapshot()` vs
87/// `descriptor.current.snapshot()` visually distinct) and
88/// stopped scaling once participants became an arity-agnostic
89/// `Vec<Arc<dyn DiffParticipantSource>>`. The slot index now
90/// carries the role.
91///
92/// Concrete impls in this module:
93/// - [`StaticSource`] — owned in-memory `Rope`.
94/// - [`OnDiskSource`] — re-reads a file at snapshot time.
95/// - [`BufferSource`] — live rope from a [`BufferTextProvider`]-
96///   backed buffer.
97///
98/// D.7's `GitSource` (post-implementation) will land alongside
99/// these.
100///
101/// `snapshot` is called from inside the `spawn_blocking` body
102/// of [`DiffSubsystem::schedule_recompute`], so impls may do
103/// cheap blocking I/O (a `git cat-file` for `GitSource`) but
104/// must not hold the host's UI thread. `Send + Sync + 'static`
105/// is required so the trait object can cross the
106/// `spawn_blocking` boundary.
107pub trait DiffParticipantSource: Send + Sync + 'static + std::fmt::Debug {
108    /// Produce this participant's rope. Called once per
109    /// recompute; the implementor decides whether to clone a
110    /// cached rope or rematerialise from a backing store.
111    fn snapshot(&self) -> Rope;
112
113    /// D.8.d (2026-05-31): the buffer id this source is
114    /// backed by, if any. Returns `None` for non-buffer
115    /// sources (`StaticSource`, `OnDiskSource`, future
116    /// `GitSource`); `BufferSource` overrides to return
117    /// `Some(buffer_id)`.
118    ///
119    /// Load-bearing for the slot ↔ buffer mapping the
120    /// subsystem's membership API + the D.6.d
121    /// `pane_index_of` helper rely on. Without it,
122    /// `compute_get_edit` / `compute_put_plan` /
123    /// `remove_participant_buffer` would need a sidecar map
124    /// to find "the slot in `Hunk::ranges` for buffer X".
125    /// Default `None` means non-overriding impls continue
126    /// to work — they just can't be addressed by buffer id.
127    fn buffer_id(&self) -> Option<BufferId> {
128        None
129    }
130}
131
132/// In-memory participant source — an owned `Rope` cloned on
133/// every [`Self::snapshot`].
134///
135/// Cheap: `Rope::clone` is an `Arc`-share of the underlying
136/// chunks, not a deep copy. Used as the default smoke-test
137/// baseline and as the substrate consumers (e.g. an LSP server
138/// returning `WorkspaceEdit` previews, the AI multi-file
139/// `openDiff` flow) wrap when they already hold the source
140/// text in memory.
141#[derive(Debug, Clone)]
142pub struct StaticSource {
143    rope: Rope,
144}
145
146impl StaticSource {
147    pub fn new(rope: Rope) -> Self {
148        Self { rope }
149    }
150}
151
152impl DiffParticipantSource for StaticSource {
153    fn snapshot(&self) -> Rope {
154        self.rope.clone()
155    }
156}
157
158/// D.3.a (2026-05-29): on-disk file participant source.
159///
160/// `snapshot` re-reads the file at `path` and parses it into a
161/// fresh `Rope`. Used by `:diff` (no args) — "diff against
162/// the on-disk version of this file." Cheap enough to do
163/// inside `spawn_blocking` per the [`DiffParticipantSource`]
164/// contract; D.3's first consumer is single-file inline
165/// overlay so per-recompute file re-reads are acceptable.
166/// Future D.7 (`:Gdiff`) introduces a separate `GitSource`
167/// that reads through `gix` against a fixed ref.
168///
169/// On I/O error (missing path, permissions, mid-read crash)
170/// snapshot returns an empty rope. The session then recomputes
171/// the diff against empty baseline (all-Add hunks), which is
172/// the "everything is new" presentation — a noisy but
173/// defensible degradation that the user can resolve via
174/// `:diffoff` and a corrected path. We log the error at
175/// `tracing::debug` so the failure surfaces under
176/// `RUST_LOG=lattice_host::diff::subsystem=debug` without
177/// blocking the recompute path.
178#[derive(Clone, Debug)]
179pub struct OnDiskSource {
180    path: std::path::PathBuf,
181}
182
183impl OnDiskSource {
184    pub fn new(path: std::path::PathBuf) -> Self {
185        Self { path }
186    }
187
188    pub fn path(&self) -> &std::path::Path {
189        &self.path
190    }
191}
192
193impl DiffParticipantSource for OnDiskSource {
194    fn snapshot(&self) -> Rope {
195        match std::fs::read_to_string(&self.path) {
196            Ok(s) => Rope::from(s),
197            Err(err) => {
198                debug!(
199                    target: "lattice_host::diff::subsystem",
200                    path = ?self.path,
201                    ?err,
202                    "OnDiskSource::snapshot failed; returning empty rope"
203                );
204                Rope::new()
205            }
206        }
207    }
208}
209
210// ──────────────────────────────────────────────────────────────
211// D.2.c: BufferTextProvider trait + the production
212// BufferRegistry-backed impl + BufferSource concrete participant
213// (D.8.b unified `BufferBaseline` + `BufferCurrentSource`
214// into one `BufferSource`)
215// ──────────────────────────────────────────────────────────────
216
217/// One-trait seam between the diff subsystem and the host's
218/// buffer storage. Required for [`BufferSource`] to resolve a
219/// [`BufferId`] to its live rope at snapshot time.
220///
221/// The host supplies a single impl backed by `BufferRegistry`.
222/// Future ephemeral-buffer providers (e.g. plugin-owned virtual
223/// buffers, AI-proposed-edits views) plug into the same trait.
224///
225/// `buffer_rope(id)` returns `None` when the buffer has been
226/// dropped. [`BufferSource`] treats `None` as an empty rope so
227/// a recompute against a closed buffer still produces a
228/// well-defined `HunkIndex` (all-Add or all-Remove depending
229/// on which slot was the dropped buffer) rather than
230/// panicking. The session's `drop_session` lifecycle will
231/// remove the entry shortly after.
232pub trait BufferTextProvider: Send + Sync + 'static + std::fmt::Debug {
233    fn buffer_rope(&self, id: BufferId) -> Option<Rope>;
234}
235
236// DX.6 (2026-06-24): the production `BufferTextProvider` /
237// `DocumentBufferResolver` impls (`BufferRegistryTextProvider` /
238// `BufferRegistryDocumentResolver`) reference the host's
239// `BufferRegistry`, so they CANNOT live in this crate — they stay
240// in `lattice-host` (`crate::diff::resolver`, re-exported under
241// `crate::diff::subsystem`). The TRAITS above are the seam: this
242// crate depends only on the abstraction; the host supplies the
243// `BufferRegistry`-backed impls. See `diff-extraction.md` (C6).
244
245/// D.8.b (2026-05-31): live-rope participant backed by a
246/// buffer. Replaces the prior `BufferSource` +
247/// `BufferSource` two-struct split (both had identical
248/// shape — provider + buffer_id — and only differed in which
249/// trait they implemented). The trait collapse to
250/// [`DiffParticipantSource`] makes the split structurally
251/// redundant.
252///
253/// The unsaved-buffer case: when neither side of a diff has a
254/// filesystem path, both sides resolve through
255/// [`BufferTextProvider`] at snapshot time. The session's
256/// descriptor must include this buffer in its `watch` list so
257/// edits to it wake the session.
258#[derive(Clone, Debug)]
259pub struct BufferSource {
260    provider: Arc<dyn BufferTextProvider>,
261    buffer_id: BufferId,
262}
263
264impl BufferSource {
265    pub fn new(provider: Arc<dyn BufferTextProvider>, buffer_id: BufferId) -> Self {
266        Self {
267            provider,
268            buffer_id,
269        }
270    }
271
272    pub fn buffer_id(&self) -> BufferId {
273        self.buffer_id
274    }
275}
276
277impl DiffParticipantSource for BufferSource {
278    fn snapshot(&self) -> Rope {
279        self.provider
280            .buffer_rope(self.buffer_id)
281            .unwrap_or_default()
282    }
283
284    fn buffer_id(&self) -> Option<BufferId> {
285        Some(self.buffer_id)
286    }
287}
288
289/// The "what to diff against what" pair for a session.
290///
291/// `sources` is an arity-agnostic vector of N participant
292/// sources, one per slot in `Hunk::ranges`. The engine
293/// (`crate::compute_diff`) dispatches by
294/// `sources.len()` — N=2 is two-way (slot 0 = baseline /
295/// from, slot 1 = current / to), N=3 is three-way merge
296/// (slot 0 = base, slot 1 = local, slot 2 = remote), N≥4
297/// returns `DiffEngineError::Unsupported` in v1.
298///
299/// `watch` is the **explicit dependency declaration**:
300/// every [`BufferId`] whose edits should wake this session.
301/// The descriptor's author (a future `:diffsplit` /
302/// `:Gdiff` / AI-host call site) knows which sources are
303/// buffer-backed and contributes those `BufferId`s into
304/// `watch`. Static or git-blob sources contribute nothing.
305///
306/// `Clone` because the runtime sometimes wants a stable
307/// snapshot of the descriptor to feed a debounced
308/// recompute — the inner `Vec<Arc<dyn ...>>` and
309/// `Vec<BufferId>` clones are cheap (one Arc bump per
310/// source + a small heap allocation).
311///
312/// **D.8.c shape (2026-05-31).** Replaces the prior
313/// `baseline + current + Option<remote>` named-field
314/// triple with a single `sources: Vec<...>`; see
315/// [`docs/dev/architecture/n-way-diff-membership.md`]
316/// (`docs/dev/architecture/n-way-diff-membership.md`)
317/// for the rationale.
318#[derive(Clone, Debug)]
319pub struct DiffDescriptor {
320    /// N participant sources in slot order. `sources[i]`
321    /// produces the rope at `Hunk::ranges[i]` after a
322    /// recompute. v1 supports N ∈ {1, 2, 3}; the engine
323    /// returns `DiffEngineError::Unsupported` for N≥4.
324    pub sources: Vec<Arc<dyn DiffParticipantSource>>,
325    pub watch: Vec<BufferId>,
326    /// D.5.a (2026-05-30): user-visible diff sides that should
327    /// receive `diff-mode` activation while this session is
328    /// registered. Distinct from [`Self::watch`]:
329    /// - `watch` declares edit-event subscriptions.
330    /// - `participants` declares which buffers are user-visible
331    ///   diff sides that should get the mode toggle.
332    ///
333    /// For buffer-backed sources they coincide; they diverge
334    /// when a baseline source contributes no live buffer
335    /// (file-on-disk inline → `[primary]`; D.7 git baseline
336    /// → `[primary]`). Two-pane is `[baseline, primary]`; D.6
337    /// three-way is `[base, local, remote]` (length 3).
338    pub participants: Vec<BufferId>,
339}
340
341impl DiffDescriptor {
342    /// D.8.c (2026-05-31): the session's arity — the source
343    /// of truth for how many participants this session has.
344    /// Equivalent to `sources.len()`.
345    pub fn arity(&self) -> usize {
346        self.sources.len()
347    }
348}
349
350// ──────────────────────────────────────────────────────────────
351// D.2.c: DocumentBufferResolver
352// ──────────────────────────────────────────────────────────────
353
354/// Translates the protocol-layer [`DocumentId`] carried in
355/// `Event::DocumentChanged` / `Event::DocumentClosed` back to a
356/// host-layer [`BufferId`]. The host supplies an impl backed by
357/// `BufferRegistry`. Kept as a trait so the subsystem stays
358/// independent of buffer-registry layout (and so tests can
359/// inject a stub mapping).
360pub trait DocumentBufferResolver: Send + Sync + 'static + std::fmt::Debug {
361    fn buffer_id_for(&self, document_id: DocumentId) -> Option<BufferId>;
362}
363
364// ──────────────────────────────────────────────────────────────
365// D.2.c: Lazy per-session Debouncer
366// ──────────────────────────────────────────────────────────────
367
368/// Default debounce window. Matches Helix's diff debounce
369/// (50ms); short enough that the visual lag is imperceptible
370/// during sustained typing, long enough that a burst of 5–10
371/// keystrokes collapses to a single recompute. Overridable via
372/// [`DiffSubsystem::with_debounce_window`] for tests and host
373/// configuration.
374pub const DEFAULT_DEBOUNCE_WINDOW: Duration = Duration::from_millis(50);
375
376/// Per-session debounce controller.
377///
378/// State is two atomics — an epoch counter bumped on every
379/// [`Self::poke`], and a `pending` flag that gates spawning the
380/// debounce task. The task itself is **lazy**: it spawns on the
381/// first `poke` after an idle period, sleeps the debounce
382/// window, re-reads the epoch, and either re-sleeps (more pokes
383/// arrived during the window) or invokes the supplied
384/// `runner` and exits. No task runs while the session is
385/// quiescent.
386///
387/// `runner` is `Arc<dyn Fn>` — shareable across the loop. The
388/// caller (`DiffSubsystem::poke_session`) captures the
389/// subsystem `Arc` + session key into the closure.
390#[derive(Debug)]
391pub struct Debouncer {
392    inner: Arc<DebouncerInner>,
393}
394
395#[derive(Debug)]
396struct DebouncerInner {
397    epoch: AtomicU64,
398    pending: AtomicBool,
399    window: Duration,
400}
401
402impl Debouncer {
403    pub fn new(window: Duration) -> Self {
404        Self {
405            inner: Arc::new(DebouncerInner {
406                epoch: AtomicU64::new(0),
407                pending: AtomicBool::new(false),
408                window,
409            }),
410        }
411    }
412
413    pub fn window(&self) -> Duration {
414        self.inner.window
415    }
416
417    /// Bump the epoch. If no debounce task is in flight, spawn
418    /// one that sleeps the window, re-reads the epoch, and
419    /// either re-sleeps (more pokes) or invokes `runner` and
420    /// exits.
421    ///
422    /// `runner` runs on the tokio runtime (the debounce task is
423    /// itself a tokio task). Inside the runner, the production
424    /// call site schedules the actual recompute on
425    /// `spawn_blocking`; the runner closure stays light.
426    pub fn poke<F>(&self, runner: F)
427    where
428        F: Fn() + Send + Sync + 'static,
429    {
430        let inner = Arc::clone(&self.inner);
431        // Bump the epoch first so a concurrent task observes the
432        // new value even if we don't end up spawning.
433        inner.epoch.fetch_add(1, Ordering::Relaxed);
434        // Try to claim the spawn slot. If we lose, another
435        // debounce task is already in flight and our epoch bump
436        // will be observed when it re-reads.
437        if inner
438            .pending
439            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed)
440            .is_ok()
441        {
442            let runner = Arc::new(runner);
443            let inner_task = Arc::clone(&inner);
444            tokio::spawn(async move {
445                loop {
446                    let observed = inner_task.epoch.load(Ordering::Acquire);
447                    tokio::time::sleep(inner_task.window).await;
448                    let after = inner_task.epoch.load(Ordering::Acquire);
449                    if after == observed {
450                        // Quiet. Clear pending, fire, exit.
451                        // Race: a poke that lands between this
452                        // store and the next read of `pending`
453                        // will spawn a new task — at worst one
454                        // extra recompute, dropped by the
455                        // revision gate (see D.2.b).
456                        inner_task.pending.store(false, Ordering::Release);
457                        runner();
458                        return;
459                    }
460                    // More pokes arrived during sleep — loop and
461                    // sleep again.
462                }
463            });
464        }
465    }
466}
467
468// ──────────────────────────────────────────────────────────────
469// D.2.c: Bus-subscription guard
470// ──────────────────────────────────────────────────────────────
471
472/// RAII guard returned by [`DiffSubsystem::bind`]. Holds the
473/// bus `SubscriptionId` + the drainer task `JoinHandle`. On
474/// `Drop`, unsubscribes the bus subscription and aborts the
475/// drainer task.
476///
477/// Hosts hold one of these for the editor's lifetime. Tests
478/// drop it to verify cleanup.
479#[derive(Debug)]
480pub struct DiffSubscriptionGuard {
481    bus: Arc<EventBus>,
482    subscription: SubscriptionId,
483    drainer: JoinHandle<()>,
484}
485
486impl Drop for DiffSubscriptionGuard {
487    fn drop(&mut self) {
488        self.bus.unsubscribe(self.subscription);
489        self.drainer.abort();
490    }
491}
492
493// ──────────────────────────────────────────────────────────────
494// D.2.d: introspection types
495// ──────────────────────────────────────────────────────────────
496
497/// One row of `:describe-diff` output. Produced by
498/// [`DiffSubsystem::describe_sessions`] and rendered into the
499/// help-buffer body by
500/// [`DiffSubsystem::build_describe_diff_content`].
501#[derive(Clone, Debug, PartialEq, Eq)]
502pub struct DiffSessionDescription {
503    pub buffer_id: BufferId,
504    pub algorithm: DiffAlgorithm,
505    pub revision: u64,
506    pub hunk_count: usize,
507    /// Buffers this session watches for edit-triggered
508    /// recomputes (from `descriptor.watch`). Empty if the
509    /// session was registered without sources via
510    /// [`DiffSubsystem::register`] (the test path).
511    pub watch: Vec<BufferId>,
512}
513
514/// D.6.e (2026-05-31): resolution signal fired through a
515/// [`DiffSession`]'s completion channel when the user
516/// invokes `:diff-accept` / `:diff-reject`. Consumed by
517/// the future `openDiff` plugin flow (Claude Code, AI
518/// multi-file edits) and by any in-tree consumer (magit
519/// plugin, AI proposals) that wants to know how the user
520/// resolved a session.
521///
522/// `#[non_exhaustive]` so future variants — notably the
523/// `Partial(Vec<HunkId>)` case from the design doc, which
524/// would require per-hunk acceptance tracking — can be
525/// added without breaking exhaustiveness in pattern-match
526/// consumers.
527#[non_exhaustive]
528#[derive(Clone, Debug, PartialEq, Eq)]
529pub enum DiffOutcome {
530    /// User confirmed they're done reviewing; the
531    /// buffer's *current* content (whatever they applied
532    /// via `do`/`dp` or left alone) is the accepted
533    /// resolution. Plugins typically commit the active
534    /// buffer's rope on receiving this.
535    Accept,
536    /// User dismissed the session without committing.
537    /// Plugins should revert to pre-session state if they
538    /// modified the buffer for the diff display.
539    Reject,
540}
541
542/// D.8.d (2026-05-31): errors the subsystem's membership
543/// API returns when mutating a session's participant set.
544/// Distinct from [`DiffEngineError`] (which is the engine
545/// crate's surface) so callers don't have to import
546/// `lattice_diff` just to pattern-match on
547/// `add_participant` failures. The engine's cap on arity
548/// (N ≥ 4 rejected in v1) surfaces here as
549/// `EngineRejected(DiffEngineError::Unsupported{...})`.
550#[derive(Debug, thiserror::Error)]
551pub enum MembershipError {
552    #[error("no diff session registered for buffer {0:?}")]
553    NoSession(BufferId),
554    #[error("slot {slot} out of range; session arity = {arity}")]
555    SlotOutOfRange { slot: usize, arity: usize },
556    #[error("buffer {0:?} is not a participant of this session")]
557    NotParticipant(BufferId),
558    #[error("engine rejected new arity: {0}")]
559    EngineRejected(#[from] crate::DiffEngineError),
560}
561
562/// D.5.b (2026-05-30): describes the edit the diff-mode `do`
563/// (diff-get) operator would apply when invoked at a given
564/// cursor row on the active side of a session. Produced by
565/// [`DiffSubsystem::compute_get_edit`]; consumed by dispatch
566/// which translates it into an `apply_edit_blocking` call
567/// and re-positions the cursor.
568#[derive(Clone, Debug, PartialEq, Eq)]
569pub struct DiffGetPlan {
570    /// The mutation to apply to the active buffer (the side
571    /// the cursor is on).
572    pub edit: lattice_protocol::edit::Edit,
573    /// Post-edit cursor line — the start of the resolved
574    /// hunk on the active side. Cursor stays "on the hunk"
575    /// so successive `]c` / `[c` jumps walk through
576    /// neighbouring hunks naturally.
577    pub post_cursor_row: u32,
578}
579
580/// D.6.d (2026-05-31): outcome of resolving the diff-mode
581/// `do` chord or `:diffget [<bufnr>]` ex-command. Mirrors
582/// [`DiffPutOutcome`]'s tri-state but for the get
583/// direction:
584///
585/// - [`DiffGetOutcome::Edit`]: edit ready to apply to the
586///   active buffer. The carried `target_buffer_id` names
587///   the side the edit's content was pulled FROM.
588/// - [`DiffGetOutcome::TargetRequired`]: three-way session
589///   and the caller didn't disambiguate. The
590///   `available_targets` field lists the other
591///   participant buffers so dispatch can surface a clear
592///   error.
593/// - [`DiffGetOutcome::Nothing`]: no session, no
594///   descriptor, no covering hunk under the cursor on the
595///   active side, or — for the `do` chord with no
596///   explicit target — a two-way Conflict hunk (which
597///   shouldn't exist anyway since compute_two_way doesn't
598///   emit it; defensive).
599#[derive(Clone, Debug, PartialEq, Eq)]
600pub enum DiffGetOutcome {
601    Edit {
602        target_buffer_id: BufferId,
603        edit: lattice_protocol::edit::Edit,
604        post_cursor_row: u32,
605    },
606    TargetRequired {
607        available_targets: Vec<BufferId>,
608    },
609    Nothing,
610}
611
612impl DiffGetOutcome {
613    /// Test-friendly: project the Edit variant down to a
614    /// [`DiffGetPlan`] (the D.5.b shape) or `None` for
615    /// non-Edit outcomes. Keeps existing tests' `Option`
616    /// ergonomics post-D.6.d.
617    pub fn into_plan(self) -> Option<DiffGetPlan> {
618        match self {
619            DiffGetOutcome::Edit {
620                edit,
621                post_cursor_row,
622                ..
623            } => Some(DiffGetPlan {
624                edit,
625                post_cursor_row,
626            }),
627            _ => None,
628        }
629    }
630
631    /// `true` iff this outcome is `Nothing` (silent no-op).
632    pub fn is_nothing(&self) -> bool {
633        matches!(self, DiffGetOutcome::Nothing)
634    }
635}
636
637/// D.5.c (2026-05-30) / D.6.d (2026-05-31): outcome of
638/// resolving the diff-mode `dp` chord or `:diffput
639/// [<bufnr>]` ex-command. Distinguishes four cases the
640/// dispatch handler must surface differently:
641///
642/// - [`DiffPutOutcome::Edit`]: an edit is ready to apply
643///   to the buffer identified by `target_buffer_id`. The
644///   target is the destination side; the active buffer is
645///   the source. Apply via the registry's
646///   `RopeDocumentHandle::apply_edit` and park the cursor at
647///   `post_cursor_row` on the active side.
648/// - [`DiffPutOutcome::NoPeerBuffer`]: inline session
649///   whose baseline is not a live buffer (file-on-disk
650///   for `:diff`, git blob for D.7's future `:Gdiff`).
651///   `dp` cannot push to a non-buffer; the handler emits
652///   a clear error message ("dp: baseline is not a
653///   buffer; use :write") rather than silently no-op'ing.
654/// - [`DiffPutOutcome::TargetRequired`] (D.6.d): three-way
655///   session and the caller didn't disambiguate. The
656///   `available_targets` field lists the other
657///   participant buffers so dispatch surfaces a clear
658///   error.
659/// - [`DiffPutOutcome::Nothing`]: no session, no
660///   descriptor, no hunk under the cursor, or — for the
661///   `dp` chord with no explicit target — a two-way
662///   Conflict hunk (defensive; compute_two_way doesn't
663///   emit Conflict).
664#[derive(Clone, Debug, PartialEq, Eq)]
665pub enum DiffPutOutcome {
666    Edit {
667        target_buffer_id: BufferId,
668        edit: lattice_protocol::edit::Edit,
669        post_cursor_row: u32,
670    },
671    NoPeerBuffer,
672    TargetRequired {
673        available_targets: Vec<BufferId>,
674    },
675    Nothing,
676}
677
678/// CR.1: the "three-way merge needs an explicit bufnr" error `Echo`
679/// shared by [`DiffSubsystem::diff_get_effect`] /
680/// [`DiffSubsystem::diff_put_effect`]. Preserves the host's former
681/// `do_diff_get`/`do_diff_put` wording verbatim so the migration is
682/// behaviour-preserving (`cmd` is `"diffget"` / `"diffput"`).
683fn target_required_echo(cmd: &str, available_targets: &[BufferId]) -> lattice_grammar::Effect {
684    let avail = available_targets
685        .iter()
686        .map(|b| b.0.to_string())
687        .collect::<Vec<_>>()
688        .join(", ");
689    lattice_grammar::Effect::Echo {
690        level: lattice_grammar::EchoLevel::Error,
691        text: format!(
692            "{cmd}: target required for three-way merge; use :{cmd} <bufnr> (one of: {avail})"
693        ),
694    }
695}
696
697/// CR.6: the current-side (slot 1) start rows of every hunk, in order —
698/// the navigation order `]c`/`[c` walk.
699fn hunk_starts(index: &HunkIndex) -> Vec<u32> {
700    index
701        .hunks
702        .iter()
703        .filter_map(|h| h.ranges.get(1).map(|r| r.start))
704        .collect()
705}
706
707/// CR.6: an info `Echo` for the `:hunk-next`/`:hunk-prev` no-session /
708/// no-hunks cases — preserves the former host `do_next_hunk` messages.
709fn hunk_nav_echo(text: &str) -> lattice_grammar::Effect {
710    lattice_grammar::Effect::Echo {
711        level: lattice_grammar::EchoLevel::Info,
712        text: text.to_string(),
713    }
714}
715
716/// Returns `Effect::CursorMove` at `(row, 0)` — the cursor-jump the
717/// host applies for hunk navigation.
718fn hunk_selection(row: u32) -> lattice_grammar::Effect {
719    lattice_grammar::Effect::CursorMove(lattice_protocol::position::Position::new(row, 0))
720}
721
722/// D.5.b helper: slice the rope at the given line range and
723/// return the contents as a `String`. Half-open `[start, end)`.
724/// Empty range returns the empty string. Tolerant of an
725/// `end` that exceeds the rope's line count (clamped) and a
726/// `start` past EOF (returns empty).
727fn slice_line_range(rope: &Rope, range: LineRange) -> String {
728    if range.is_empty() {
729        return String::new();
730    }
731    let total_lines = rope.len_lines() as u32;
732    if range.start >= total_lines {
733        return String::new();
734    }
735    let end = range.end.min(total_lines);
736    let start_char = rope.line_to_char(range.start as usize);
737    let end_char = rope.line_to_char(end as usize);
738    rope.slice(start_char..end_char).to_string()
739}
740
741fn format_algorithm(alg: DiffAlgorithm) -> &'static str {
742    match alg {
743        DiffAlgorithm::Histogram => "Histogram",
744        DiffAlgorithm::Myers => "Myers",
745        DiffAlgorithm::MyersMinimal => "MyersMinimal",
746    }
747}
748
749// ──────────────────────────────────────────────────────────────
750// D.6.d (2026-05-31): pane-index helpers for compute_get_edit /
751// compute_put_plan with target-aware dispatch.
752// ──────────────────────────────────────────────────────────────
753
754/// Result of resolving a target buffer to a slot index in
755/// `Hunk::ranges` (0/1 in two-way; 0/1/2 in three-way).
756enum TargetResolution {
757    /// Caller specified a target (or two-way default
758    /// resolved to the unique peer). Use this pane slot.
759    Pane(usize),
760    /// Three-way session and caller didn't supply a target.
761    /// Dispatch must surface a "diffput/diffget: target
762    /// required" error.
763    Required,
764    /// Caller specified a target buffer that isn't a
765    /// participant of this session, or is the active
766    /// buffer itself.
767    Unknown,
768}
769
770/// Find the slot index in `hunk.ranges` (= position in
771/// `descriptor.sources`) corresponding to `buffer_id`.
772///
773/// D.8.d (2026-05-31): rewritten to walk `sources`
774/// directly via the [`DiffParticipantSource::buffer_id`]
775/// method. The post-D.8.c arity-agnostic sources vector
776/// puts each source at the same slot index it occupies in
777/// `Hunk::ranges`, so finding the buffer's slot reduces
778/// to a position-by-trait-method query. Pre-D.8.d this
779/// function carried an "inline → slot 1" special-case
780/// derived from `descriptor.participants` because the
781/// D.6 fixed-arity descriptor didn't expose source
782/// identities. With D.8.b's `BufferSource::buffer_id()`
783/// trait method the special-case is no longer needed —
784/// inline 1-source sessions report slot 0 directly, and
785/// inline 2-source sessions (StaticSource at slot 0 +
786/// BufferSource at slot 1) report slot 1 from the
787/// position-walk.
788fn pane_index_of(descriptor: &DiffDescriptor, buffer_id: BufferId) -> Option<usize> {
789    descriptor
790        .sources
791        .iter()
792        .position(|s| s.buffer_id() == Some(buffer_id))
793}
794
795/// Resolve `target` to a pane slot index:
796/// - `Some(b)` ⇒ slot of `b` (via [`pane_index_of`]), or
797///   `Unknown` if not present / equals active.
798/// - `None` ⇒ two-way auto-targets the unique peer slot;
799///   three-way returns `Required`.
800fn resolve_target_pane(
801    descriptor: &DiffDescriptor,
802    active_pane: usize,
803    target: Option<BufferId>,
804) -> TargetResolution {
805    if let Some(t) = target {
806        let Some(pane) = pane_index_of(descriptor, t) else {
807            return TargetResolution::Unknown;
808        };
809        if pane == active_pane {
810            return TargetResolution::Unknown;
811        }
812        return TargetResolution::Pane(pane);
813    }
814    // No explicit target. Slot count comes from the
815    // descriptor's `sources.len()` — N=1 inline still has
816    // the implicit "other slot" semantic against the (now
817    // absent) peer; N=2 has a unique peer; N≥3 needs the
818    // caller to disambiguate.
819    if descriptor.arity() >= 3 {
820        TargetResolution::Required
821    } else {
822        // N≤2 (inline or two-pane): peer is the other slot.
823        TargetResolution::Pane(if active_pane == 0 { 1 } else { 0 })
824    }
825}
826
827/// Other participants (buffer ids) besides the active
828/// pane's. Returned in slot order so dispatch can render a
829/// stable error message ("expected one of: 7, 9").
830fn other_participants(descriptor: &DiffDescriptor, active_pane: usize) -> Vec<BufferId> {
831    descriptor
832        .participants
833        .iter()
834        .enumerate()
835        .filter_map(|(i, b)| if i == active_pane { None } else { Some(*b) })
836        .collect()
837}
838
839/// Snapshot the rope for the source at slot `pane`.
840/// Returns `None` if the slot is out of range for the
841/// descriptor's arity (e.g. slot 2 on a two-way descriptor
842/// with `sources.len() == 2`). D.8.c (2026-05-31): rewritten
843/// to consult `descriptor.sources` directly rather than
844/// branching by slot index against the prior
845/// baseline / current / remote named fields.
846fn snapshot_for_pane(descriptor: &DiffDescriptor, pane: usize) -> Option<Rope> {
847    descriptor.sources.get(pane).map(|s| s.snapshot())
848}
849
850/// Find the first hunk whose `active_pane`-side range
851/// covers `cursor_row`. Vim-parity rules: empty ranges
852/// match only at the exact start row (the deletion-marker
853/// row); non-empty ranges match by half-open inclusion.
854///
855/// When `allow_conflict` is `true`, Conflict hunks
856/// participate in the search (D.6.d target-aware path
857/// resolves them); when `false`, they're skipped
858/// defensively (the `do` / `dp` chord path without an
859/// explicit target — preserves D.5.b/c semantics).
860fn find_covering_hunk(
861    index: &HunkIndex,
862    active_pane: usize,
863    cursor_row: u32,
864    allow_conflict: bool,
865) -> Option<&crate::Hunk> {
866    index.hunks.iter().find(|h| {
867        if !allow_conflict && matches!(h.kind, HunkKind::Conflict) {
868            return false;
869        }
870        let Some(range) = h.ranges.get(active_pane).copied() else {
871            return false;
872        };
873        if range.is_empty() {
874            range.start == cursor_row
875        } else {
876            cursor_row >= range.start && cursor_row < range.end
877        }
878    })
879}
880
881/// Per-document diff state. Wraps an `ArcSwap<HunkIndex>` so
882/// consumers read the latest published hunks without holding the
883/// registry lock.
884///
885/// Construction goes through [`DiffSubsystem::register`]; direct
886/// instantiation is fine for unit tests but bypasses the registry.
887#[derive(Debug)]
888pub struct DiffSession {
889    /// The buffer this session diffs. Stable across the session's
890    /// lifetime — the registry guarantees one session per id.
891    buffer_id: BufferId,
892    /// Algorithm selected for this session's recomputes. Fixed at
893    /// `register` time; changing algorithm is a drop + re-register.
894    algorithm: DiffAlgorithm,
895    /// Published hunks. Initialised to `HunkIndex::empty(algorithm)`
896    /// (revision = 0) at construction; replaced by recompute via
897    /// the revision-gated [`Self::try_publish_if_newer`] path.
898    hunks: ArcSwap<HunkIndex>,
899    /// D.2.b: monotonically-increasing revision allocator. Each
900    /// recompute consumes one value via [`Self::allocate_revision`];
901    /// the value stamps the resulting `HunkIndex` and is also
902    /// what [`Self::try_publish_if_newer`] gates against. Starts
903    /// at 1 (the initial empty index uses revision 0).
904    next_revision: AtomicU64,
905    /// D.3.a.1 (2026-05-29): wake signal fired on every
906    /// successful publish (gated by `try_publish_if_newer` or
907    /// the unconditional `publish`). Consumers — notably the
908    /// virtual-rows-wake forwarder set up by `:diff` — await
909    /// `notified()` to react immediately to hunk republishes
910    /// without waiting for the next `publish_render_state`
911    /// tick. Permit-style coalescing: a burst of publishes
912    /// collapses to one consumer wake, which matches the
913    /// expected debounce behavior on the worker side.
914    publish_notify: Arc<tokio::sync::Notify>,
915    /// D.3.d.0 (2026-05-29): published per-line sign
916    /// classification derived from the current `HunkIndex`.
917    /// Renderers read via `sign_map()` (lock-free `ArcSwap`
918    /// load) per-frame; the
919    /// [`crate::overlay::DiffOverlayRefreshTask`] writes
920    /// this cell on every hunk publish, keeping it in lockstep
921    /// with `hunks`. Initialised to an empty map at session
922    /// construction; first refresh populates it once the
923    /// initial recompute completes.
924    sign_map: ArcSwap<crate::overlay::DiffSignMap>,
925    /// D-fix.3b (2026-06-26): the BASELINE-side sign map (hunks'
926    /// `ranges[0]`: `Remove`→removed, `Change`→changed). `sign_map`
927    /// above covers the current/proposed (right) pane; this covers the
928    /// baseline (left) pane so a side-by-side diff tints BOTH sides.
929    /// Published in lockstep with `sign_map` by `recompute_blocking`.
930    /// Empty for inline `:diff` (no separate baseline pane).
931    baseline_sign_map: ArcSwap<crate::overlay::DiffSignMap>,
932    /// D-fix.5 (2026-06-26): per-slot current line count, slot `i` =
933    /// `sources[i].len_lines()` of the rope that produced the latest
934    /// `HunkIndex`. Published in lockstep with `hunks` at the
935    /// [`Self::recompute_blocking`] choke point. The
936    /// `UnchangedFoldSource` reads this to bound its complement-of-hunks
937    /// computation (the trailing unchanged gap runs to a side's EOF, so
938    /// the source needs the line count it can't derive from the hunks
939    /// alone). Empty (`vec![]`) until the first recompute publishes —
940    /// the fold source then emits nothing (graceful: no line count → no
941    /// complement).
942    slot_line_counts: ArcSwap<Vec<u32>>,
943    /// D.4.d.3.a (2026-05-30): linkage from a two-pane diff
944    /// session to its `PaneGroup` (the scroll-binding
945    /// mechanism with `HunkRowMapper`). `None` for inline
946    /// `:diff` sessions, which have no pane-group scroll
947    /// binding; `Some(id)` for `:diffthis` / `:diffsplit`
948    /// sessions, set by `bind_pane_group` at registration
949    /// and read by `do_diff_off` at teardown to drop the
950    /// group cleanly.
951    pane_group_id: Mutex<Option<lattice_core::ui::pane::PaneGroupId>>,
952    /// D.6.e (2026-05-31): one-shot resolution channel.
953    /// Set by callers that want to know when the user
954    /// runs `:diff-accept` / `:diff-reject` (or by
955    /// programmatic flows via [`Self::bind_completion`]).
956    /// The Editor's `do_diff_accept` / `do_diff_reject`
957    /// path takes the sender out
958    /// ([`Self::take_completion`]) and sends the matching
959    /// [`DiffOutcome`] before tearing the session down.
960    /// `None` for sessions that don't need outcome
961    /// notification (the default — most interactive flows
962    /// don't bind one).
963    completion: Mutex<Option<oneshot::Sender<DiffOutcome>>>,
964}
965
966impl DiffSession {
967    /// Public constructor used by both [`DiffSubsystem::register`]
968    /// and tests. Starts with an empty `HunkIndex` tagged with the
969    /// session's algorithm.
970    pub fn new(buffer_id: BufferId, algorithm: DiffAlgorithm) -> Self {
971        Self {
972            buffer_id,
973            algorithm,
974            hunks: ArcSwap::from_pointee(HunkIndex::empty(algorithm)),
975            next_revision: AtomicU64::new(1),
976            publish_notify: Arc::new(tokio::sync::Notify::new()),
977            sign_map: ArcSwap::from_pointee(crate::overlay::DiffSignMap::default()),
978            baseline_sign_map: ArcSwap::from_pointee(crate::overlay::DiffSignMap::default()),
979            slot_line_counts: ArcSwap::from_pointee(Vec::new()),
980            pane_group_id: Mutex::new(None),
981            completion: Mutex::new(None),
982        }
983    }
984
985    /// D.4.d.3.a (2026-05-30): bind a `PaneGroup` to this
986    /// session — call once at two-pane session creation,
987    /// before any wake or read. Subsequent calls overwrite
988    /// (a re-binding scenario isn't expected in v1 but is
989    /// safe).
990    pub fn bind_pane_group(&self, id: lattice_core::ui::pane::PaneGroupId) {
991        *self
992            .pane_group_id
993            .lock()
994            .expect("DiffSession pane_group_id mutex poisoned") = Some(id);
995    }
996
997    /// D.4.d.3.a: read the linked `PaneGroupId`, if any.
998    /// `None` for inline `:diff` sessions; `Some(id)` for
999    /// two-pane sessions registered via `:diffthis` /
1000    /// `:diffsplit`. The teardown path (`do_diff_off`)
1001    /// reads this to drop the linked group atomically with
1002    /// the session.
1003    /// D.6.e (2026-05-31): bind a [`DiffOutcome`] one-shot
1004    /// sender to this session. Called once after
1005    /// registration by callers (`openDiff` plugin flow,
1006    /// magit-style consumers) that want to be notified
1007    /// when the user resolves the session via
1008    /// `:diff-accept` / `:diff-reject`. Subsequent binds
1009    /// overwrite (a re-bind scenario isn't expected in v1
1010    /// but is safe — the previous sender is dropped, so
1011    /// any awaiting receiver observes a `Closed` error,
1012    /// matching the typical "session superseded" UX).
1013    pub fn bind_completion(&self, tx: oneshot::Sender<DiffOutcome>) {
1014        *self
1015            .completion
1016            .lock()
1017            .expect("DiffSession completion mutex poisoned") = Some(tx);
1018    }
1019
1020    /// D.6.e (2026-05-31): take the bound `DiffOutcome`
1021    /// sender, if any. Single-shot: returns `Some` on the
1022    /// first call after a `bind_completion`; subsequent
1023    /// calls return `None`. Used by the
1024    /// `do_diff_accept` / `do_diff_reject` teardown path
1025    /// to fire the signal before dropping the session.
1026    pub fn take_completion(&self) -> Option<oneshot::Sender<DiffOutcome>> {
1027        self.completion
1028            .lock()
1029            .expect("DiffSession completion mutex poisoned")
1030            .take()
1031    }
1032
1033    /// True while a completion sender is still bound — i.e. this session is a
1034    /// programmatic/agent review awaiting a `:diff-accept` / `:diff-reject`
1035    /// verdict (set by [`Self::bind_completion`], cleared by
1036    /// [`Self::take_completion`] at teardown). `:diff-accept` / `:diff-reject`
1037    /// use this (via [`DiffSubsystem::sessions_awaiting_outcome`]) to resolve a
1038    /// pending review even when focus isn't on the diff pane, so a verdict
1039    /// typed from the `:claude` terminal still reaches the agent.
1040    pub fn awaits_outcome(&self) -> bool {
1041        self.completion
1042            .lock()
1043            .expect("DiffSession completion mutex poisoned")
1044            .is_some()
1045    }
1046
1047    pub fn pane_group_id(&self) -> Option<lattice_core::ui::pane::PaneGroupId> {
1048        *self
1049            .pane_group_id
1050            .lock()
1051            .expect("DiffSession pane_group_id mutex poisoned")
1052    }
1053
1054    /// D.3.d.0 (2026-05-29): snapshot the latest published
1055    /// `DiffSignMap`. Lock-free `ArcSwap::load_full`; renderer
1056    /// hot path. The map is refreshed in lockstep with
1057    /// `hunks` by [`crate::overlay::DiffOverlayRefreshTask`].
1058    pub fn sign_map(&self) -> Arc<crate::overlay::DiffSignMap> {
1059        self.sign_map.load_full()
1060    }
1061
1062    /// D.3.d.0: publish a freshly-computed sign map.
1063    /// Unconditional store (no revision gate) — the
1064    /// `DiffOverlayRefreshTask` already serialises map
1065    /// updates with `hunks` publishes, so out-of-order
1066    /// landing isn't possible from the refresh-task side.
1067    /// Direct callers (tests, future consumers) bear the
1068    /// ordering responsibility.
1069    pub fn publish_sign_map(&self, map: Arc<crate::overlay::DiffSignMap>) {
1070        self.sign_map.store(map);
1071    }
1072
1073    /// D-fix.3b: snapshot the latest published BASELINE-side sign map
1074    /// (the left/baseline pane of a side-by-side diff). Lock-free
1075    /// `ArcSwap::load_full`; renderer hot path. Empty for inline `:diff`.
1076    pub fn baseline_sign_map(&self) -> Arc<crate::overlay::DiffSignMap> {
1077        self.baseline_sign_map.load_full()
1078    }
1079
1080    /// D-fix.5 (2026-06-26): the line count of the rope at `slot` that
1081    /// produced the latest published `HunkIndex`. `None` if no recompute
1082    /// has published yet (the slot vector is empty) or `slot` is out of
1083    /// range. The `UnchangedFoldSource` for a buffer at this slot reads
1084    /// it to bound the complement-of-hunks computation to `[0,
1085    /// line_count)`.
1086    pub fn slot_line_count(&self, slot: usize) -> Option<u32> {
1087        self.slot_line_counts.load().get(slot).copied()
1088    }
1089
1090    /// D-fix.5: publish the per-slot line counts in lockstep with the
1091    /// hunks. Called by [`Self::recompute_blocking`] from the rope
1092    /// slice it just diffed (slot `i` ⇒ `sources[i].len_lines()`).
1093    pub fn publish_slot_line_counts(&self, counts: Vec<u32>) {
1094        self.slot_line_counts.store(Arc::new(counts));
1095    }
1096
1097    /// D-fix.3b: publish a freshly-computed baseline-side sign map.
1098    /// Stored in lockstep with `publish_sign_map` by `recompute_blocking`.
1099    pub fn publish_baseline_sign_map(&self, map: Arc<crate::overlay::DiffSignMap>) {
1100        self.baseline_sign_map.store(map);
1101    }
1102
1103    /// D.3.a.1: shared `Notify` fired on every successful
1104    /// publish. The `:diff` handler clones this and awaits
1105    /// `notified()` in a forwarder task to wake the
1106    /// `virtual_rows_worker` immediately on hunk republish.
1107    pub fn publish_notify(&self) -> Arc<tokio::sync::Notify> {
1108        self.publish_notify.clone()
1109    }
1110
1111    pub fn buffer_id(&self) -> BufferId {
1112        self.buffer_id
1113    }
1114
1115    pub fn algorithm(&self) -> DiffAlgorithm {
1116        self.algorithm
1117    }
1118
1119    /// Snapshot the latest published hunks. Lock-free; returns a
1120    /// fresh `Arc` whose contents are stable for the holder's
1121    /// lifetime (RCU semantics).
1122    pub fn current_hunks(&self) -> Arc<HunkIndex> {
1123        self.hunks.load_full()
1124    }
1125
1126    /// Publish a freshly-computed `HunkIndex` unconditionally.
1127    /// D.2.a kept this as the simple "replace published" path
1128    /// for tests; D.2.b's recompute path prefers
1129    /// [`Self::try_publish_if_newer`] for monotonic ordering
1130    /// under concurrent `spawn_blocking` completion.
1131    ///
1132    /// D.3.a.1: fires `publish_notify` so the diff-overlay
1133    /// wake forwarder observes the publish.
1134    pub fn publish(&self, hunks: Arc<HunkIndex>) {
1135        self.hunks.store(hunks);
1136        self.publish_notify.notify_one();
1137    }
1138
1139    /// D.2.b: allocate a fresh revision tag. Monotonically
1140    /// increasing; one tag per recompute.
1141    pub fn allocate_revision(&self) -> u64 {
1142        self.next_revision.fetch_add(1, Ordering::Relaxed)
1143    }
1144
1145    /// D.2.b: peek the next revision the session would allocate.
1146    /// Test-friendly; does not consume the slot.
1147    pub fn peek_next_revision(&self) -> u64 {
1148        self.next_revision.load(Ordering::Relaxed)
1149    }
1150
1151    /// D.2.b: publish `idx` only if its revision strictly exceeds
1152    /// the currently-published revision. Returns `true` on take,
1153    /// `false` if the publish was dropped as stale.
1154    ///
1155    /// Preserves monotonic ordering when multiple recomputes run
1156    /// concurrently on `spawn_blocking` and may finish out of
1157    /// order. The `rcu` loop retries internally on contention; the
1158    /// `took` flag captures whichever decision the *winning*
1159    /// closure invocation made.
1160    pub fn try_publish_if_newer(&self, idx: Arc<HunkIndex>) -> bool {
1161        let new_rev = idx.revision;
1162        let mut took = false;
1163        self.hunks.rcu(|current| {
1164            if new_rev > current.revision {
1165                took = true;
1166                Arc::clone(&idx)
1167            } else {
1168                took = false;
1169                Arc::clone(current)
1170            }
1171        });
1172        // D.3.a.1: fire publish_notify on a successful take so
1173        // the diff-overlay wake forwarder reacts immediately
1174        // to the hunk republish.
1175        if took {
1176            self.publish_notify.notify_one();
1177        }
1178        took
1179    }
1180
1181    /// D.2.b / D.6.a / D.8.c: synchronous recompute.
1182    /// Allocates a revision, runs the diff engine via
1183    /// [`compute_diff`] over the participant ropes, builds a
1184    /// `HunkIndex` stamped with the allocated revision + the
1185    /// session's algorithm, and publishes via the
1186    /// revision-gated path.
1187    ///
1188    /// `sources` is an N-slot rope slice in slot order
1189    /// (slot i feeds `Hunk::ranges[i]`). The engine picks the
1190    /// algorithm by `sources.len()`: N=2 → two-way, N=3 →
1191    /// three-way with Conflict semantics, N≥4 →
1192    /// `DiffEngineError::Unsupported` (recompute drops with a
1193    /// debug log; mirrors the stale-publish drop semantic).
1194    ///
1195    /// D.8.c (2026-05-31): signature rewritten from
1196    /// `(baseline: &Rope, current: &Rope, remote:
1197    /// Option<&Rope>)` to `(sources: &[Rope])`. Callers
1198    /// iterate the descriptor's `sources` and snapshot each
1199    /// rope into a Vec passed here.
1200    ///
1201    /// Returns `Some(idx)` on successful publish, `None` if a
1202    /// newer revision was already published (stale result
1203    /// dropped) or the engine rejected the participant set.
1204    /// This is the body the
1205    /// [`DiffSubsystem::schedule_recompute`]
1206    /// `spawn_blocking` closure executes; tests call it
1207    /// directly to exercise the compute path without tokio.
1208    pub fn recompute_blocking(&self, sources: &[Rope]) -> Option<Arc<HunkIndex>> {
1209        let revision = self.allocate_revision();
1210        let raw = match compute_diff(sources, self.algorithm) {
1211            Ok(idx) => idx,
1212            Err(err) => {
1213                tracing::debug!(
1214                    target: "lattice_host::diff::subsystem",
1215                    ?err,
1216                    "compute_diff rejected the participant set; recompute dropped"
1217                );
1218                return None;
1219            }
1220        };
1221        let idx = Arc::new(HunkIndex {
1222            hunks: raw.hunks,
1223            algorithm: self.algorithm,
1224            revision,
1225        });
1226        if self.try_publish_if_newer(Arc::clone(&idx)) {
1227            // D-fix.3a (2026-06-26): publish the current-side sign map in
1228            // lockstep with the hunks, here at the single recompute choke
1229            // point, so EVERY session gets line tints + gutter signs — inline
1230            // `:diff` AND pane-group (`:diffsplit` / Claude Code `openDiff`).
1231            // Previously only `DiffOverlayRefreshTask` (spawned solely on the
1232            // inline `:diff` path) published the sign map, so pane-group diffs
1233            // computed hunks but left `sign_map()` empty → no in-buffer diff
1234            // highlighting. (The refresh task still owns deletion-block
1235            // overlay rendering for inline diffs; this publish is idempotent
1236            // with its own, computed from the same hunks.)
1237            self.publish_sign_map(Arc::new(crate::overlay::compute_diff_sign_map(&idx)));
1238            // D-fix.3b: the baseline-side map (left pane) in lockstep.
1239            self.publish_baseline_sign_map(Arc::new(
1240                crate::overlay::compute_baseline_diff_sign_map(&idx),
1241            ));
1242            // D-fix.5: per-slot line counts in lockstep, from the same
1243            // ropes that produced the hunks. The `UnchangedFoldSource`
1244            // reads slot `i`'s count to bound its complement-of-hunks
1245            // fold to the side's EOF. `len_lines()` counts ropey's
1246            // trailing phantom line; the fold source's `>= 2`-line floor
1247            // + per-hunk context window absorb that off-by-one at EOF.
1248            self.publish_slot_line_counts(sources.iter().map(|r| r.len_lines() as u32).collect());
1249            Some(idx)
1250        } else {
1251            None
1252        }
1253    }
1254}
1255
1256/// Process-wide registry + routing layer for diff sessions.
1257///
1258/// Lifecycle (D.2.a):
1259/// - `register(buffer_id, algorithm)` — idempotent; pure-compute
1260///   path with no sources, no debouncer, no routing entries.
1261///   For tests and the future `:describe-diff` standalone path.
1262/// - `register_with_sources(buffer_id, algorithm, descriptor)`
1263///   (D.2.c) — production registration. Installs the
1264///   descriptor + watch entries + a per-session [`Debouncer`].
1265///   Edits to any buffer in `descriptor.watch` will route to
1266///   this session.
1267/// - `lookup(buffer_id)` — returns `Some(Arc<DiffSession>)` if
1268///   registered.
1269/// - `lookup_descriptor(buffer_id)` (D.2.c) — returns
1270///   `Some(DiffDescriptor)` if registered with sources.
1271/// - `drop_session(buffer_id)` — removes session, descriptor,
1272///   debouncer, and the session's entries from every
1273///   `watchers` bucket. In-flight `Arc` holders are unaffected.
1274/// - `iter_sessions()` — snapshot of all currently-registered
1275///   sessions. Powers `:describe-diff` (D.2.d).
1276///
1277/// Routing (D.2.c):
1278/// - `bind(bus, resolver)` — installs the bus subscription +
1279///   drainer task. Returns a [`DiffSubscriptionGuard`] whose
1280///   `Drop` unsubscribes and aborts.
1281/// - `note_buffer_edited(buffer_id)` — pokes the debouncer for
1282///   every session in `watchers[buffer_id]`. Public so tests
1283///   and (future) non-bus drivers can fire it directly.
1284/// - `note_buffer_closed(buffer_id)` — calls `drop_session`.
1285///
1286/// The registry is `Default`-able and zero-cost to construct; the
1287/// host owns one instance, threaded through `Editor`. Debounce
1288/// window defaults to [`DEFAULT_DEBOUNCE_WINDOW`] and can be
1289/// overridden via [`Self::with_debounce_window`].
1290#[derive(Debug)]
1291pub struct DiffSubsystem {
1292    sessions: Mutex<HashMap<BufferId, Arc<DiffSession>>>,
1293    descriptors: Mutex<HashMap<BufferId, DiffDescriptor>>,
1294    /// Inverse index: edits to `watched_buffer` should wake the
1295    /// listed session keys. Rebuilt from descriptors on every
1296    /// register / drop.
1297    watchers: Mutex<HashMap<BufferId, Vec<BufferId>>>,
1298    /// D.4.d.3.a (2026-05-30): secondary-buffer → primary-buffer
1299    /// indirection. Populated from `descriptor.watch` minus the
1300    /// primary key at registration time; consulted by
1301    /// [`Self::lookup_session_for`] so a buffer participating
1302    /// in a two-pane diff (but not the session's primary key)
1303    /// still resolves to its session. Inline `:diff` sessions
1304    /// have a single-entry `watch` list that equals the primary,
1305    /// so they contribute no entries here.
1306    secondary_index: Mutex<HashMap<BufferId, BufferId>>,
1307    debouncers: Mutex<HashMap<BufferId, Arc<Debouncer>>>,
1308    debounce_window: Duration,
1309    /// D.5.a (2026-05-30): host-side `diff-mode` lifecycle
1310    /// bridge. Created on `Default` so the subsystem is the
1311    /// single owner of the bridge identity; the editor accesses
1312    /// it via [`Self::mode_bridge`] for the dispatch-tail drain.
1313    mode_bridge: Arc<crate::mode::DiffModeBridge>,
1314}
1315
1316impl Default for DiffSubsystem {
1317    fn default() -> Self {
1318        Self {
1319            sessions: Mutex::new(HashMap::new()),
1320            descriptors: Mutex::new(HashMap::new()),
1321            watchers: Mutex::new(HashMap::new()),
1322            secondary_index: Mutex::new(HashMap::new()),
1323            debouncers: Mutex::new(HashMap::new()),
1324            debounce_window: DEFAULT_DEBOUNCE_WINDOW,
1325            mode_bridge: Arc::new(crate::mode::DiffModeBridge::new()),
1326        }
1327    }
1328}
1329
1330/// Cheap-clone service handle for the diff subsystem. DX.3/C7 (BC.6):
1331/// registered in the `ServiceRegistry` at boot so `diff-mode`'s
1332/// `on_activate` can reach the session for a buffer
1333/// (`ctx.service::<DiffSubsystemHandle>()`) and register a
1334/// `HunkFoldSource` via the `FoldOverlayService` — mirroring how
1335/// `MultibufferMode` reaches its `MultibufferRegistryHandle`. Follows the
1336/// Arc/TypeId convention: register `Arc<DiffSubsystem>`, look up
1337/// `Arc<DiffSubsystem>`.
1338pub type DiffSubsystemHandle = Arc<DiffSubsystem>;
1339
1340impl DiffSubsystem {
1341    pub fn new() -> Self {
1342        Self::default()
1343    }
1344
1345    /// Construct a subsystem with a non-default debounce window.
1346    /// Primarily a test hook (so unit tests can run with
1347    /// `Duration::from_millis(1)` and avoid wall-clock waits) but
1348    /// hosts can also tune the window via the typed options
1349    /// registry once that wiring lands (D.2.e).
1350    pub fn with_debounce_window(window: Duration) -> Self {
1351        Self {
1352            debounce_window: window,
1353            ..Self::default()
1354        }
1355    }
1356
1357    pub fn debounce_window(&self) -> Duration {
1358        self.debounce_window
1359    }
1360
1361    /// D.5.a (2026-05-30): access the diff-mode lifecycle
1362    /// bridge so the editor's dispatch tail can drain queued
1363    /// activations. The subsystem owns the bridge identity; the
1364    /// returned `Arc` is a cheap reference clone, not a take.
1365    pub fn mode_bridge(&self) -> Arc<crate::mode::DiffModeBridge> {
1366        Arc::clone(&self.mode_bridge)
1367    }
1368
1369    /// Register a session for `buffer_id` with no sources. The
1370    /// session has no debouncer, no descriptor, no watchers
1371    /// entries — used by tests and by the pure-compute API path.
1372    /// Production callers want [`Self::register_with_sources`].
1373    ///
1374    /// Idempotent: returns the existing `Arc<DiffSession>` if
1375    /// one is already registered (the `algorithm` argument is
1376    /// ignored in that case).
1377    pub fn register(&self, buffer_id: BufferId, algorithm: DiffAlgorithm) -> Arc<DiffSession> {
1378        let mut sessions = self.sessions.lock().expect("DiffSubsystem mutex poisoned");
1379        sessions
1380            .entry(buffer_id)
1381            .or_insert_with(|| Arc::new(DiffSession::new(buffer_id, algorithm)))
1382            .clone()
1383    }
1384
1385    /// D.2.c: register a session with a full [`DiffDescriptor`].
1386    /// Inserts (or reuses) the session, stores the descriptor,
1387    /// rebuilds the inverse `watchers` entries to include this
1388    /// session for every buffer in `descriptor.watch`, and
1389    /// installs a per-session [`Debouncer`].
1390    ///
1391    /// Idempotent on session identity (same `Arc<DiffSession>`
1392    /// returned on re-registration) but **descriptor is
1393    /// replaced** on re-registration — the caller may be
1394    /// updating sources (e.g. switching baseline from
1395    /// `StaticSource` to `GitBaseline`). The old watch
1396    /// entries are scrubbed before the new ones are installed
1397    /// so a re-register with a shrunken watch list doesn't
1398    /// leave stale routes.
1399    pub fn register_with_sources(
1400        &self,
1401        buffer_id: BufferId,
1402        algorithm: DiffAlgorithm,
1403        descriptor: DiffDescriptor,
1404    ) -> Arc<DiffSession> {
1405        let session = {
1406            let mut sessions = self.sessions.lock().expect("DiffSubsystem mutex poisoned");
1407            sessions
1408                .entry(buffer_id)
1409                .or_insert_with(|| Arc::new(DiffSession::new(buffer_id, algorithm)))
1410                .clone()
1411        };
1412        // Replace descriptor; capture old to scrub stale
1413        // watcher entries.
1414        let old_descriptor = {
1415            let mut descriptors = self
1416                .descriptors
1417                .lock()
1418                .expect("DiffSubsystem mutex poisoned");
1419            descriptors.insert(buffer_id, descriptor.clone())
1420        };
1421        if let Some(old) = old_descriptor {
1422            self.scrub_watcher_entries(buffer_id, &old.watch);
1423            self.scrub_secondary_entries(buffer_id, &old.watch);
1424        }
1425        self.install_watcher_entries(buffer_id, &descriptor.watch);
1426        // D.4.d.3.a: maintain the BufferId → primary
1427        // indirection so two-pane sessions can be looked up
1428        // from *either* side. Inline sessions (`watch =
1429        // [primary]`) contribute no entries because
1430        // `scrub_secondary_entries` skips the primary.
1431        self.install_secondary_entries(buffer_id, &descriptor.watch);
1432        // Install or replace the debouncer (idempotent — a
1433        // re-register on an already-debouncing session keeps
1434        // the existing controller).
1435        {
1436            let mut debouncers = self
1437                .debouncers
1438                .lock()
1439                .expect("DiffSubsystem mutex poisoned");
1440            debouncers
1441                .entry(buffer_id)
1442                .or_insert_with(|| Arc::new(Debouncer::new(self.debounce_window)));
1443        }
1444        // D.5.a (2026-05-30): notify the diff-mode bridge after
1445        // the session is durable in the registry. The bridge
1446        // queues activation changes; the dispatch tail drains
1447        // and applies them via `mode_registry.activate_minor`.
1448        // Re-register paths (e.g. switching baseline source)
1449        // flow through here too — the bridge's idempotent
1450        // scrub-then-re-add on the same `session_key` keeps
1451        // refcounts correct.
1452        self.mode_bridge
1453            .note_session_opened(buffer_id, &descriptor.participants);
1454        session
1455    }
1456
1457    /// D.4.d.3.a (2026-05-30): resolve a session from *any*
1458    /// buffer that participates in it (primary or secondary).
1459    /// Returns the same `Arc<DiffSession>` whether the caller
1460    /// passes the session's primary key or one of its
1461    /// descriptor's watched buffers. The teardown path
1462    /// (`do_diff_off`) uses this so `:diffoff` from either
1463    /// pane of a two-way diff finds the same session.
1464    pub fn lookup_session_for(&self, buffer_id: BufferId) -> Option<Arc<DiffSession>> {
1465        if let Some(session) = self.lookup(buffer_id) {
1466            return Some(session);
1467        }
1468        let primary = self
1469            .secondary_index
1470            .lock()
1471            .expect("DiffSubsystem mutex poisoned")
1472            .get(&buffer_id)
1473            .copied()?;
1474        self.lookup(primary)
1475    }
1476
1477    /// D.6.g (2026-05-31): every session `buffer_id`
1478    /// participates in — as primary key *or* as a member
1479    /// of any descriptor's `watch` list. Used by
1480    /// `:diffoff!` (the force bang) to cascade tear-down
1481    /// across all sessions the active buffer belongs to,
1482    /// not just the one [`Self::lookup_session_for`]
1483    /// happens to resolve.
1484    ///
1485    /// Today's secondary_index is single-valued
1486    /// (`HashMap<BufferId, BufferId>`), so a buffer
1487    /// participating in two simultaneous sessions only
1488    /// resolves to the most-recently-registered one via
1489    /// `lookup_session_for`. This method iterates the
1490    /// `descriptors` map directly and returns every
1491    /// session whose descriptor's `watch` list contains
1492    /// `buffer_id`. Order is unspecified (HashMap
1493    /// iteration). Empty when the buffer is not a
1494    /// participant anywhere.
1495    pub fn all_sessions_for(&self, buffer_id: BufferId) -> Vec<Arc<DiffSession>> {
1496        let sessions = self.sessions.lock().expect("DiffSubsystem mutex poisoned");
1497        let descriptors = self
1498            .descriptors
1499            .lock()
1500            .expect("DiffSubsystem mutex poisoned");
1501        sessions
1502            .iter()
1503            .filter(|(key, _)| {
1504                **key == buffer_id
1505                    || descriptors
1506                        .get(*key)
1507                        .map(|d| d.watch.contains(&buffer_id))
1508                        .unwrap_or(false)
1509            })
1510            .map(|(_, session)| session.clone())
1511            .collect()
1512    }
1513
1514    /// Snapshot of every registered session still **awaiting a verdict** — a
1515    /// bound completion sender (a programmatic / agent review opened via
1516    /// [`DiffSession::bind_completion`]). Ordered by primary `BufferId`
1517    /// ascending; since ids are monotonic, the LAST entry is the
1518    /// most-recently-opened review. `:diff-accept` / `:diff-reject` fall back
1519    /// to this when the active buffer isn't itself a diff pane, so a verdict
1520    /// typed from the `:claude` terminal (or anywhere) resolves the pending
1521    /// review instead of doing nothing and stranding the agent. Empty when no
1522    /// review is pending (e.g. only inline `:diff` / `:diffsplit` views exist).
1523    pub fn sessions_awaiting_outcome(&self) -> Vec<Arc<DiffSession>> {
1524        let mut pending: Vec<Arc<DiffSession>> = self
1525            .sessions
1526            .lock()
1527            .expect("DiffSubsystem mutex poisoned")
1528            .values()
1529            .filter(|session| session.awaits_outcome())
1530            .cloned()
1531            .collect();
1532        pending.sort_by_key(|session| session.buffer_id().0);
1533        pending
1534    }
1535
1536    /// Look up the session for `buffer_id`. Returns `None` if no
1537    /// session is registered.
1538    pub fn lookup(&self, buffer_id: BufferId) -> Option<Arc<DiffSession>> {
1539        self.sessions
1540            .lock()
1541            .expect("DiffSubsystem mutex poisoned")
1542            .get(&buffer_id)
1543            .cloned()
1544    }
1545
1546    /// D.2.c: look up the descriptor for `buffer_id`. Returns
1547    /// `None` if the session was registered via [`Self::register`]
1548    /// (sources-less) or not registered at all.
1549    pub fn lookup_descriptor(&self, buffer_id: BufferId) -> Option<DiffDescriptor> {
1550        self.descriptors
1551            .lock()
1552            .expect("DiffSubsystem mutex poisoned")
1553            .get(&buffer_id)
1554            .cloned()
1555    }
1556
1557    /// D-fix.5 (2026-06-26): resolve the descriptor of whatever session
1558    /// `buffer_id` participates in — primary key *or* a watched
1559    /// secondary side. [`Self::lookup_descriptor`] only keys on the
1560    /// primary, so a baseline-side buffer needs the
1561    /// `secondary_index` → primary hop first (the
1562    /// [`Self::lookup_session_for`] shape, for descriptors).
1563    fn descriptor_for_participant(&self, buffer_id: BufferId) -> Option<DiffDescriptor> {
1564        if let Some(d) = self.lookup_descriptor(buffer_id) {
1565            return Some(d);
1566        }
1567        let primary = self
1568            .secondary_index
1569            .lock()
1570            .expect("DiffSubsystem mutex poisoned")
1571            .get(&buffer_id)
1572            .copied()?;
1573        self.lookup_descriptor(primary)
1574    }
1575
1576    /// D-fix.5: the `Hunk::ranges` slot `buffer_id` occupies in its
1577    /// session (0 = baseline / two-way left, 1 = current / right, 2 =
1578    /// remote in three-way). The diff modes need this so a per-side
1579    /// fold source (`HunkFoldSource`, `UnchangedFoldSource`) folds the
1580    /// buffer's OWN side rather than always `ranges[1]`. `None` when the
1581    /// buffer participates in no session, or the session was registered
1582    /// sources-less (no descriptor). Resolves from either side via
1583    /// [`Self::descriptor_for_participant`].
1584    pub fn participant_slot(&self, buffer_id: BufferId) -> Option<usize> {
1585        let descriptor = self.descriptor_for_participant(buffer_id)?;
1586        pane_index_of(&descriptor, buffer_id)
1587    }
1588
1589    /// D-fix.5: the line (on `buffer_id`'s own side) of the first hunk —
1590    /// the auto-scroll target on diff open (vim positions the cursor at
1591    /// the first diff). `None` when `buffer_id` participates in no
1592    /// session, has no descriptor (no resolvable slot), or the published
1593    /// `HunkIndex` is empty (a clean diff — nothing to scroll to). The
1594    /// host moves the pane's cursor here + centres it (`zz`) through the
1595    /// generic cursor primitive; the *decision* (which line, which side)
1596    /// is the diff subsystem's.
1597    pub fn first_change_line(&self, buffer_id: BufferId) -> Option<u32> {
1598        let slot = self.participant_slot(buffer_id)?;
1599        let session = self.lookup_session_for(buffer_id)?;
1600        let hunks = session.current_hunks();
1601        hunks
1602            .hunks
1603            .first()
1604            .and_then(|h| h.ranges.get(slot))
1605            .map(|r| r.start)
1606    }
1607
1608    /// D.5.b (2026-05-30): compute the edit the diff-mode `do`
1609    /// chord would apply for `buffer_id` at `cursor_row`.
1610    ///
1611    /// Returns `None` (silent no-op) when:
1612    /// - no session is registered for `buffer_id`,
1613    /// - no descriptor is registered (sources-less test
1614    ///   registration — there's no baseline to read from),
1615    /// - no hunk covers `cursor_row` on the current side,
1616    /// - the matched hunk is a three-way [`HunkKind::Conflict`]
1617    ///   (D.6 lands the conflict-resolution path).
1618    ///
1619    /// Behaviour by hunk kind on the two-way current side
1620    /// (`ranges[1]`):
1621    /// - **Change**: replace `ranges[1]` with the baseline
1622    ///   slice for `ranges[0]`.
1623    /// - **Add**: current side has the extra lines; baseline
1624    ///   range is empty → delete `ranges[1]` (revert the add).
1625    /// - **Remove**: current side is empty at the deletion
1626    ///   point; baseline has the removed lines → insert the
1627    ///   baseline text at `ranges[1].start` (revert the
1628    ///   remove). For a `Remove` hunk the current range is
1629    ///   empty; `cursor_row` must equal `ranges[1].start`
1630    ///   exactly for the lookup to match (vim parity — `do`
1631    ///   only fires while the cursor sits on the deletion-
1632    ///   marker row).
1633    ///
1634    /// Reads the baseline through
1635    /// [`DiffDescriptor::baseline`]`.snapshot()`. The
1636    /// snapshot is potentially expensive (file re-read for
1637    /// [`OnDiskSource`]); callers invoke once per `do`
1638    /// keystroke, never inside a tight loop.
1639    /// D.6.d (2026-05-31): compute the edit the diff-mode
1640    /// `do` chord or `:diffget [<bufnr>]` ex-command would
1641    /// apply at `cursor_row` on `active_buffer_id`.
1642    ///
1643    /// `target` semantics:
1644    /// - `None` in two-way: pull from the peer (the only
1645    ///   other side) — preserves D.5.b's `do` chord
1646    ///   behaviour.
1647    /// - `Some(buffer)` in two-way: pull from that side
1648    ///   (must be the peer, else `DiffGetOutcome::Nothing`).
1649    /// - `None` in three-way: ambiguous →
1650    ///   `DiffGetOutcome::TargetRequired` with the two
1651    ///   available targets.
1652    /// - `Some(buffer)` in three-way: pull from that
1653    ///   participant's side; allows resolving Conflict
1654    ///   hunks by picking which side wins.
1655    ///
1656    /// Reads the target side's rope via
1657    /// [`snapshot_for_pane`]. Cheap for buffer-backed
1658    /// sources (rope-Arc clone); the snapshot is called
1659    /// once per dispatch, never inside a tight loop.
1660    pub fn compute_get_edit(
1661        &self,
1662        active_buffer_id: BufferId,
1663        cursor_row: u32,
1664        target: Option<BufferId>,
1665    ) -> DiffGetOutcome {
1666        let Some(session) = self.lookup_session_for(active_buffer_id) else {
1667            return DiffGetOutcome::Nothing;
1668        };
1669        let session_key = session.buffer_id();
1670        let Some(descriptor) = self.lookup_descriptor(session_key) else {
1671            return DiffGetOutcome::Nothing;
1672        };
1673        let Some(active_pane) = pane_index_of(&descriptor, active_buffer_id) else {
1674            return DiffGetOutcome::Nothing;
1675        };
1676        let target_pane = match resolve_target_pane(&descriptor, active_pane, target) {
1677            TargetResolution::Pane(p) => p,
1678            TargetResolution::Required => {
1679                return DiffGetOutcome::TargetRequired {
1680                    available_targets: other_participants(&descriptor, active_pane),
1681                };
1682            }
1683            TargetResolution::Unknown => return DiffGetOutcome::Nothing,
1684        };
1685        let allow_conflict = target.is_some();
1686        let hunks = session.current_hunks();
1687        let Some(hunk) = find_covering_hunk(&hunks, active_pane, cursor_row, allow_conflict) else {
1688            return DiffGetOutcome::Nothing;
1689        };
1690        let Some(active_range) = hunk.ranges.get(active_pane).copied() else {
1691            return DiffGetOutcome::Nothing;
1692        };
1693        let Some(target_range) = hunk.ranges.get(target_pane).copied() else {
1694            return DiffGetOutcome::Nothing;
1695        };
1696        let Some(target_rope) = snapshot_for_pane(&descriptor, target_pane) else {
1697            return DiffGetOutcome::Nothing;
1698        };
1699        let target_text = slice_line_range(&target_rope, target_range);
1700        let edit = lattice_protocol::edit::Edit::replace(
1701            lattice_protocol::position::Range::new(
1702                lattice_protocol::position::Position::new(active_range.start, 0),
1703                lattice_protocol::position::Position::new(active_range.end, 0),
1704            ),
1705            target_text,
1706        );
1707        let target_buffer_id = descriptor
1708            .participants
1709            .get(target_pane)
1710            .copied()
1711            .unwrap_or(active_buffer_id);
1712        DiffGetOutcome::Edit {
1713            target_buffer_id,
1714            edit,
1715            post_cursor_row: active_range.start,
1716        }
1717    }
1718
1719    /// CR.2 (2026-06-24): compute the "keep both" resolution edit for the
1720    /// conflict hunk under `cursor_row` on the active (local / "ours")
1721    /// side of a three-way session — the `dB` chord. Splices the active
1722    /// side's lines followed by `theirs`' lines (ours-then-theirs, the v1
1723    /// convention; base is omitted, matching git's non-diff3 style) into
1724    /// the active range, so the conflict region ends up holding both
1725    /// sides' content in order. The edit applies to the active buffer
1726    /// (like keep-ours / keep-theirs, which reuse
1727    /// [`Self::compute_get_edit`] with the resolved slot→bufnr target);
1728    /// `target_buffer_id` in the returned [`DiffGetOutcome::Edit`] is the
1729    /// active buffer — the apply destination.
1730    ///
1731    /// Conflict-only: a non-`Conflict` covering hunk (or none) →
1732    /// `Nothing`, as does an unknown / self `theirs`, or a
1733    /// session/descriptor miss. The whole-line ranges are
1734    /// newline-terminated, so the splice is a clean line concatenation.
1735    pub fn compute_keep_both_edit(
1736        &self,
1737        active_buffer_id: BufferId,
1738        cursor_row: u32,
1739        theirs: BufferId,
1740    ) -> DiffGetOutcome {
1741        let Some(session) = self.lookup_session_for(active_buffer_id) else {
1742            return DiffGetOutcome::Nothing;
1743        };
1744        let session_key = session.buffer_id();
1745        let Some(descriptor) = self.lookup_descriptor(session_key) else {
1746            return DiffGetOutcome::Nothing;
1747        };
1748        let Some(active_pane) = pane_index_of(&descriptor, active_buffer_id) else {
1749            return DiffGetOutcome::Nothing;
1750        };
1751        let Some(theirs_pane) = pane_index_of(&descriptor, theirs) else {
1752            return DiffGetOutcome::Nothing;
1753        };
1754        if theirs_pane == active_pane {
1755            return DiffGetOutcome::Nothing;
1756        }
1757        let hunks = session.current_hunks();
1758        let Some(hunk) = find_covering_hunk(&hunks, active_pane, cursor_row, true) else {
1759            return DiffGetOutcome::Nothing;
1760        };
1761        // keep-both resolves Conflict hunks only — a clean 2-way Change
1762        // under the cursor is `do`/`dp` territory, not `dB`.
1763        if !matches!(hunk.kind, HunkKind::Conflict) {
1764            return DiffGetOutcome::Nothing;
1765        }
1766        let Some(active_range) = hunk.ranges.get(active_pane).copied() else {
1767            return DiffGetOutcome::Nothing;
1768        };
1769        let Some(theirs_range) = hunk.ranges.get(theirs_pane).copied() else {
1770            return DiffGetOutcome::Nothing;
1771        };
1772        let Some(active_rope) = snapshot_for_pane(&descriptor, active_pane) else {
1773            return DiffGetOutcome::Nothing;
1774        };
1775        let Some(theirs_rope) = snapshot_for_pane(&descriptor, theirs_pane) else {
1776            return DiffGetOutcome::Nothing;
1777        };
1778        let mut text = slice_line_range(&active_rope, active_range);
1779        text.push_str(&slice_line_range(&theirs_rope, theirs_range));
1780        let edit = lattice_protocol::edit::Edit::replace(
1781            lattice_protocol::position::Range::new(
1782                lattice_protocol::position::Position::new(active_range.start, 0),
1783                lattice_protocol::position::Position::new(active_range.end, 0),
1784            ),
1785            text,
1786        );
1787        DiffGetOutcome::Edit {
1788            target_buffer_id: active_buffer_id,
1789            edit,
1790            post_cursor_row: active_range.start,
1791        }
1792    }
1793
1794    /// D.5.c (2026-05-30): compute the outcome the diff-mode
1795    /// `dp` chord would produce for `buffer_id` at
1796    /// `cursor_row`. Mirror of [`Self::compute_get_edit`] but
1797    /// pushes the current side's text *into the peer* instead
1798    /// of pulling from the baseline.
1799    ///
1800    /// Returns:
1801    /// - [`DiffPutOutcome::Edit`] for two-pane sessions —
1802    ///   carries `peer_buffer_id`, the `Edit` to apply to
1803    ///   the peer, and the current-side cursor row.
1804    /// - [`DiffPutOutcome::NoPeerBuffer`] when the session's
1805    ///   participants don't include a peer buffer (inline
1806    ///   file-on-disk; D.7 git baseline). Dispatch surfaces
1807    ///   the clear error rather than silently doing nothing.
1808    /// - [`DiffPutOutcome::Nothing`] for the same silent
1809    ///   no-op cases as [`Self::compute_get_edit`]: no
1810    ///   session, no descriptor, no covering hunk, three-way
1811    ///   `Conflict`.
1812    ///
1813    /// Reads the current side via
1814    /// [`DiffDescriptor::current`]`.snapshot()`. Cheap for
1815    /// buffer-backed current sources (rope-Arc clone); the
1816    /// snapshot reads happen once per `dp` keystroke at the
1817    /// production rate.
1818    ///
1819    /// **Three-way scope.** Participants length other than
1820    /// 2 is treated as "no peer" rather than synthesising a
1821    /// best-guess target. D.6 lands `:diffput <bufnr>` /
1822    /// `:diffget <bufnr>` with the disambiguating argument
1823    /// and replaces this conservative bail-out with a
1824    /// participant-indexed peer lookup. v1's two-pane shape
1825    /// (the only one D.5.c claims) cleanly hits the `2`
1826    /// arm.
1827    pub fn compute_put_plan(
1828        &self,
1829        active_buffer_id: BufferId,
1830        cursor_row: u32,
1831        target: Option<BufferId>,
1832    ) -> DiffPutOutcome {
1833        let Some(session) = self.lookup_session_for(active_buffer_id) else {
1834            return DiffPutOutcome::Nothing;
1835        };
1836        let session_key = session.buffer_id();
1837        let Some(descriptor) = self.lookup_descriptor(session_key) else {
1838            return DiffPutOutcome::Nothing;
1839        };
1840        // Inline session (single participant — no peer to
1841        // push to). Defensive against any future
1842        // non-buffer-backed baseline source (D.7 `:Gdiff`).
1843        if descriptor.participants.len() < 2 {
1844            return DiffPutOutcome::NoPeerBuffer;
1845        }
1846        let Some(active_pane) = pane_index_of(&descriptor, active_buffer_id) else {
1847            return DiffPutOutcome::Nothing;
1848        };
1849        let target_pane = match resolve_target_pane(&descriptor, active_pane, target) {
1850            TargetResolution::Pane(p) => p,
1851            TargetResolution::Required => {
1852                return DiffPutOutcome::TargetRequired {
1853                    available_targets: other_participants(&descriptor, active_pane),
1854                };
1855            }
1856            TargetResolution::Unknown => return DiffPutOutcome::Nothing,
1857        };
1858        let allow_conflict = target.is_some();
1859        let hunks = session.current_hunks();
1860        let Some(hunk) = find_covering_hunk(&hunks, active_pane, cursor_row, allow_conflict) else {
1861            return DiffPutOutcome::Nothing;
1862        };
1863        let Some(active_range) = hunk.ranges.get(active_pane).copied() else {
1864            return DiffPutOutcome::Nothing;
1865        };
1866        let Some(target_range) = hunk.ranges.get(target_pane).copied() else {
1867            return DiffPutOutcome::Nothing;
1868        };
1869        // The active side's rope is the source we copy
1870        // FROM; the target's range is what we overwrite.
1871        let Some(active_rope) = snapshot_for_pane(&descriptor, active_pane) else {
1872            return DiffPutOutcome::Nothing;
1873        };
1874        let active_text = slice_line_range(&active_rope, active_range);
1875        let edit = lattice_protocol::edit::Edit::replace(
1876            lattice_protocol::position::Range::new(
1877                lattice_protocol::position::Position::new(target_range.start, 0),
1878                lattice_protocol::position::Position::new(target_range.end, 0),
1879            ),
1880            active_text,
1881        );
1882        let target_buffer_id = match descriptor.participants.get(target_pane).copied() {
1883            Some(b) => b,
1884            None => return DiffPutOutcome::NoPeerBuffer,
1885        };
1886        DiffPutOutcome::Edit {
1887            target_buffer_id,
1888            edit,
1889            post_cursor_row: active_range.start,
1890        }
1891    }
1892
1893    /// CR.1 (2026-06-24): resolve the diff-get (`do` chord / `:diffget`)
1894    /// at `cursor_row` on `active_buffer` into an [`Effect`] the host
1895    /// applies — the mode-owned replacement for the host's former
1896    /// `Editor::do_diff_get`. Diff-get rewrites the *active* side's hunk
1897    /// to match the resolved baseline, so the edit targets `active_buffer`
1898    /// (the cursor's buffer); the `target_buffer_id` from
1899    /// [`Self::compute_get_edit`] names only the source side and is NOT
1900    /// the apply target.
1901    ///
1902    /// - covering hunk → `Effect::ApplyEdit { target: active_buffer, .. }`
1903    ///   carrying the post-edit cursor row;
1904    /// - three-way without a disambiguating `target` → an error `Echo`
1905    ///   listing the available bufnrs;
1906    /// - nothing under the cursor / no session → `None` (silent no-op).
1907    pub fn diff_get_effect(
1908        &self,
1909        active_buffer: BufferId,
1910        cursor_row: u32,
1911        target: Option<BufferId>,
1912    ) -> Option<lattice_grammar::Effect> {
1913        match self.compute_get_edit(active_buffer, cursor_row, target) {
1914            DiffGetOutcome::Edit {
1915                edit,
1916                post_cursor_row,
1917                ..
1918            } => Some(lattice_grammar::Effect::ApplyEdit {
1919                target: active_buffer,
1920                edit,
1921                cursor: Some(lattice_protocol::position::Position::new(
1922                    post_cursor_row,
1923                    0,
1924                )),
1925            }),
1926            DiffGetOutcome::TargetRequired { available_targets } => {
1927                Some(target_required_echo("diffget", &available_targets))
1928            }
1929            DiffGetOutcome::Nothing => None,
1930        }
1931    }
1932
1933    /// CR.1 (2026-06-24): resolve the diff-put (`dp` chord / `:diffput`)
1934    /// at `cursor_row` on `active_buffer` into an [`Effect`] — the
1935    /// mode-owned replacement for `Editor::do_diff_put`. Diff-put pushes
1936    /// the active side's hunk INTO the peer, so the edit targets the
1937    /// resolved `target_buffer_id` (the peer); the cursor parks on the
1938    /// active side.
1939    ///
1940    /// - peer + covering hunk → `Effect::ApplyEdit { target: peer, .. }`;
1941    /// - inline baseline (no live peer buffer) → an error `Echo`
1942    ///   ("dp: baseline is not a buffer; use :write");
1943    /// - three-way without a `target` → an error `Echo` listing bufnrs;
1944    /// - nothing under the cursor / no session → `None`.
1945    pub fn diff_put_effect(
1946        &self,
1947        active_buffer: BufferId,
1948        cursor_row: u32,
1949        target: Option<BufferId>,
1950    ) -> Option<lattice_grammar::Effect> {
1951        match self.compute_put_plan(active_buffer, cursor_row, target) {
1952            DiffPutOutcome::Edit {
1953                target_buffer_id,
1954                edit,
1955                post_cursor_row,
1956            } => Some(lattice_grammar::Effect::ApplyEdit {
1957                target: target_buffer_id,
1958                edit,
1959                cursor: Some(lattice_protocol::position::Position::new(
1960                    post_cursor_row,
1961                    0,
1962                )),
1963            }),
1964            DiffPutOutcome::NoPeerBuffer => Some(lattice_grammar::Effect::Echo {
1965                level: lattice_grammar::EchoLevel::Error,
1966                text: "dp: baseline is not a buffer; use :write".to_string(),
1967            }),
1968            DiffPutOutcome::TargetRequired { available_targets } => {
1969                Some(target_required_echo("diffput", &available_targets))
1970            }
1971            DiffPutOutcome::Nothing => None,
1972        }
1973    }
1974
1975    /// CR.3 (2026-06-24): the participant buffers of the session keyed by
1976    /// `session_key`, in slot order (`[base, local, remote]` for a
1977    /// three-way). Used by the host to drive `diff-conflict-mode`
1978    /// activation off the published sign map.
1979    pub fn session_participants(&self, session_key: BufferId) -> Option<Vec<BufferId>> {
1980        self.lookup_descriptor(session_key)
1981            .map(|d| d.participants.clone())
1982    }
1983
1984    /// CR.3: resolve "theirs" (the remote side) for the three-way
1985    /// conflict session active on `active_buffer`. In the
1986    /// `[base, local, remote]` model the cursor sits on `local` (= ours =
1987    /// `active_buffer`); "theirs" is the non-base (slot ≠ 0), non-active
1988    /// participant. `None` for a non-three-way session (no distinct theirs
1989    /// to resolve against).
1990    fn conflict_theirs(&self, active_buffer: BufferId) -> Option<BufferId> {
1991        let session = self.lookup_session_for(active_buffer)?;
1992        let descriptor = self.lookup_descriptor(session.buffer_id())?;
1993        if descriptor.participants.len() < 3 {
1994            return None;
1995        }
1996        descriptor
1997            .participants
1998            .iter()
1999            .enumerate()
2000            .find(|(i, b)| *i != 0 && **b != active_buffer)
2001            .map(|(_, b)| *b)
2002    }
2003
2004    /// CR.3: is there a `Conflict` hunk under `cursor_row` on the active
2005    /// side? Gates the degenerate keep-ours / put-ours echoes so they
2006    /// only fire over an actual conflict region (off-hunk → silent).
2007    fn conflict_hunk_under_cursor(&self, active_buffer: BufferId, cursor_row: u32) -> bool {
2008        let Some(session) = self.lookup_session_for(active_buffer) else {
2009            return false;
2010        };
2011        let Some(descriptor) = self.lookup_descriptor(session.buffer_id()) else {
2012            return false;
2013        };
2014        let Some(active_pane) = pane_index_of(&descriptor, active_buffer) else {
2015            return false;
2016        };
2017        let hunks = session.current_hunks();
2018        find_covering_hunk(&hunks, active_pane, cursor_row, true)
2019            .is_some_and(|h| matches!(h.kind, HunkKind::Conflict))
2020    }
2021
2022    /// CR.3 `d2o` keep-ours: the local side already holds ours, so there
2023    /// is nothing to apply — but the chord is a recognised resolution
2024    /// command (Dhruva 2026-06-24: full fugitive set, degenerate →
2025    /// informative echo, not a silent no-op). Echoes over a conflict
2026    /// region; `None` off-hunk (silent, like the other chords).
2027    pub fn diff_keep_ours_effect(
2028        &self,
2029        active_buffer: BufferId,
2030        cursor_row: u32,
2031    ) -> Option<lattice_grammar::Effect> {
2032        self.conflict_hunk_under_cursor(active_buffer, cursor_row)
2033            .then(|| lattice_grammar::Effect::Echo {
2034                level: lattice_grammar::EchoLevel::Info,
2035                text: "keep-ours: the local side already holds your version; nothing to apply"
2036                    .to_string(),
2037            })
2038    }
2039
2040    /// CR.3 `d3o` keep-theirs: pull theirs (remote) into the local range
2041    /// — `compute_get_edit` with `target = theirs` (already
2042    /// conflict-capable). `None` when there's no three-way session, no
2043    /// covering conflict, etc.
2044    pub fn diff_keep_theirs_effect(
2045        &self,
2046        active_buffer: BufferId,
2047        cursor_row: u32,
2048    ) -> Option<lattice_grammar::Effect> {
2049        let theirs = self.conflict_theirs(active_buffer)?;
2050        self.diff_get_effect(active_buffer, cursor_row, Some(theirs))
2051    }
2052
2053    /// CR.3 `d2p` put-ours: the local side IS ours, so there is nothing
2054    /// to push — informative echo over a conflict region (degenerate
2055    /// self-target), else `None`.
2056    pub fn diff_put_ours_effect(
2057        &self,
2058        active_buffer: BufferId,
2059        cursor_row: u32,
2060    ) -> Option<lattice_grammar::Effect> {
2061        self.conflict_hunk_under_cursor(active_buffer, cursor_row)
2062            .then(|| lattice_grammar::Effect::Echo {
2063                level: lattice_grammar::EchoLevel::Info,
2064                text: "diffput ours: the local side is already your version; nothing to push"
2065                    .to_string(),
2066            })
2067    }
2068
2069    /// CR.3 `d3p` put-theirs: push the local side's hunk into theirs
2070    /// (remote) — `compute_put_plan` with `target = theirs`.
2071    pub fn diff_put_theirs_effect(
2072        &self,
2073        active_buffer: BufferId,
2074        cursor_row: u32,
2075    ) -> Option<lattice_grammar::Effect> {
2076        let theirs = self.conflict_theirs(active_buffer)?;
2077        self.diff_put_effect(active_buffer, cursor_row, Some(theirs))
2078    }
2079
2080    /// CR.3 `dB` keep-both: splice ours⌢theirs into the local range via
2081    /// [`Self::compute_keep_both_edit`], returning an `Effect::ApplyEdit`
2082    /// targeting the active (local) buffer. `None` when there's no
2083    /// three-way session or no covering conflict.
2084    pub fn diff_keep_both_effect(
2085        &self,
2086        active_buffer: BufferId,
2087        cursor_row: u32,
2088    ) -> Option<lattice_grammar::Effect> {
2089        let theirs = self.conflict_theirs(active_buffer)?;
2090        match self.compute_keep_both_edit(active_buffer, cursor_row, theirs) {
2091            DiffGetOutcome::Edit {
2092                edit,
2093                post_cursor_row,
2094                ..
2095            } => Some(lattice_grammar::Effect::ApplyEdit {
2096                target: active_buffer,
2097                edit,
2098                cursor: Some(lattice_protocol::position::Position::new(
2099                    post_cursor_row,
2100                    0,
2101                )),
2102            }),
2103            _ => None,
2104        }
2105    }
2106
2107    /// CR.6 `]c` / `:hunk-next`: move the cursor to the next hunk start
2108    /// (slot 1, wraps to the first) — the mode-owned replacement for the
2109    /// host's `do_next_hunk`. Returns a generic `Effect::SelectionChange`
2110    /// (the host owns the cursor write), or an info `Echo` ("no diff
2111    /// session" / "no hunks") preserving the former host messages for the
2112    /// `:hunk-next` ex-command path (the `]c` chord is K.1.c-gated and
2113    /// never hits those).
2114    pub fn diff_next_hunk_effect(
2115        &self,
2116        active_buffer: BufferId,
2117        cursor_row: u32,
2118    ) -> Option<lattice_grammar::Effect> {
2119        let Some(session) = self.lookup_session_for(active_buffer) else {
2120            return Some(hunk_nav_echo("no diff session"));
2121        };
2122        let hunks = session.current_hunks();
2123        let rows = hunk_starts(&hunks);
2124        if rows.is_empty() {
2125            return Some(hunk_nav_echo("no hunks"));
2126        }
2127        let row = rows
2128            .iter()
2129            .copied()
2130            .find(|&l| l > cursor_row)
2131            .unwrap_or(rows[0]);
2132        Some(hunk_selection(row))
2133    }
2134
2135    /// CR.6 `[c` / `:hunk-prev`: mirror of [`Self::diff_next_hunk_effect`]
2136    /// — largest slot-1 start strictly before `cursor_row`, wrapping to
2137    /// the last hunk.
2138    pub fn diff_prev_hunk_effect(
2139        &self,
2140        active_buffer: BufferId,
2141        cursor_row: u32,
2142    ) -> Option<lattice_grammar::Effect> {
2143        let Some(session) = self.lookup_session_for(active_buffer) else {
2144            return Some(hunk_nav_echo("no diff session"));
2145        };
2146        let hunks = session.current_hunks();
2147        let rows = hunk_starts(&hunks);
2148        if rows.is_empty() {
2149            return Some(hunk_nav_echo("no hunks"));
2150        }
2151        let row = rows
2152            .iter()
2153            .rev()
2154            .copied()
2155            .find(|&l| l < cursor_row)
2156            .unwrap_or_else(|| *rows.last().expect("rows non-empty"));
2157        Some(hunk_selection(row))
2158    }
2159
2160    /// D.2.c: snapshot of the inverse routing index for
2161    /// `watched_buffer`. Returns the session keys whose
2162    /// descriptors include `watched_buffer` in their `watch`
2163    /// list. Empty if no sessions watch it.
2164    ///
2165    /// Test-friendly; production code uses
2166    /// [`Self::note_buffer_edited`].
2167    pub fn watchers_of(&self, watched_buffer: BufferId) -> Vec<BufferId> {
2168        self.watchers
2169            .lock()
2170            .expect("DiffSubsystem mutex poisoned")
2171            .get(&watched_buffer)
2172            .cloned()
2173            .unwrap_or_default()
2174    }
2175
2176    /// Drop the registry entry for `buffer_id`. Removes the
2177    /// session, descriptor, every watchers-bucket entry, and
2178    /// the debouncer. Returns `true` if a session entry was
2179    /// removed. Safe to call on a non-registered id.
2180    ///
2181    /// In-flight `Arc<DiffSession>` holders stay coherent; the
2182    /// registry's job is naming, not lifetime enforcement.
2183    pub fn drop_session(&self, buffer_id: BufferId) -> bool {
2184        let removed = self
2185            .sessions
2186            .lock()
2187            .expect("DiffSubsystem mutex poisoned")
2188            .remove(&buffer_id)
2189            .is_some();
2190        let descriptor = self
2191            .descriptors
2192            .lock()
2193            .expect("DiffSubsystem mutex poisoned")
2194            .remove(&buffer_id);
2195        if let Some(d) = descriptor {
2196            self.scrub_watcher_entries(buffer_id, &d.watch);
2197            self.scrub_secondary_entries(buffer_id, &d.watch);
2198        }
2199        // Drop the debouncer Arc — any in-flight task holds its
2200        // own Arc clone and will run to completion (it'll call
2201        // runner() then exit), but no further pokes can arrive
2202        // for this session.
2203        self.debouncers
2204            .lock()
2205            .expect("DiffSubsystem mutex poisoned")
2206            .remove(&buffer_id);
2207        // D.5.a (2026-05-30): notify the bridge AFTER all
2208        // registry state for this session has been torn down.
2209        // Bridge consults its own per-session record (set at
2210        // open) so it doesn't depend on the descriptor.
2211        // `note_session_closed` on an unknown key is a no-op,
2212        // so calling unconditionally is safe even when
2213        // `removed` is false (double-drop / drop-before-open).
2214        self.mode_bridge.note_session_closed(buffer_id);
2215        removed
2216    }
2217
2218    /// D.8.d (2026-05-31): add a participant to an existing
2219    /// session. Appends `source` to `descriptor.sources` and
2220    /// (if `participant_buffer` is `Some`) to `descriptor.
2221    /// watch` + `descriptor.participants` + the inverse
2222    /// watcher index. Triggers a recompute through the
2223    /// existing debouncer so the new arity shows up
2224    /// immediately.
2225    ///
2226    /// Returns the **new arity** after the add (on success).
2227    ///
2228    /// Errors:
2229    /// - [`MembershipError::NoSession`] — no descriptor
2230    ///   registered for `session_key`.
2231    /// - [`MembershipError::EngineRejected`] — the new arity
2232    ///   would exceed what the engine supports (v1: N≥4).
2233    ///   The session's descriptor is **not** mutated when
2234    ///   this fires — the caller's add fails atomically.
2235    pub fn add_participant(
2236        self: &Arc<Self>,
2237        session_key: BufferId,
2238        source: Arc<dyn DiffParticipantSource>,
2239        participant_buffer: Option<BufferId>,
2240    ) -> Result<usize, MembershipError> {
2241        // Pre-check arity against the engine cap before any
2242        // mutation so a rejected add is atomic.
2243        let new_arity = {
2244            let descriptors = self
2245                .descriptors
2246                .lock()
2247                .expect("DiffSubsystem mutex poisoned");
2248            let descriptor = descriptors
2249                .get(&session_key)
2250                .ok_or(MembershipError::NoSession(session_key))?;
2251            let proposed = descriptor.arity() + 1;
2252            // Probe the engine. v1 caps at 3; if we'd cross
2253            // the cap, surface the typed engine error
2254            // untouched.
2255            if proposed >= 4 {
2256                return Err(MembershipError::EngineRejected(
2257                    crate::DiffEngineError::Unsupported { n: proposed },
2258                ));
2259            }
2260            proposed
2261        };
2262
2263        // Mutate the descriptor under the mutex. The
2264        // borrow above was read-only; this block re-locks
2265        // for write so we don't hold both locks at once.
2266        {
2267            let mut descriptors = self
2268                .descriptors
2269                .lock()
2270                .expect("DiffSubsystem mutex poisoned");
2271            let descriptor = descriptors
2272                .get_mut(&session_key)
2273                .ok_or(MembershipError::NoSession(session_key))?;
2274            descriptor.sources.push(source);
2275            if let Some(buf) = participant_buffer {
2276                if !descriptor.watch.contains(&buf) {
2277                    descriptor.watch.push(buf);
2278                }
2279                if !descriptor.participants.contains(&buf) {
2280                    descriptor.participants.push(buf);
2281                }
2282            }
2283        }
2284
2285        // Watcher index + secondary index gain the new
2286        // buffer (skips primary + duplicates internally).
2287        if let Some(buf) = participant_buffer {
2288            self.install_watcher_entries(session_key, &[buf]);
2289            self.install_secondary_entries(session_key, &[buf]);
2290            // Bridge: mode refcount + per-session
2291            // participant list grow.
2292            self.mode_bridge.note_session_extended(session_key, buf);
2293        }
2294
2295        // Kick a recompute so the new arity publishes
2296        // promptly without waiting for the next edit.
2297        Arc::clone(self).poke_session(session_key);
2298
2299        Ok(new_arity)
2300    }
2301
2302    /// D.8.d (2026-05-31): remove the participant at slot
2303    /// `slot` from `session_key`. Drops the corresponding
2304    /// `sources[slot]`; if the slot corresponded to a buffer
2305    /// in `participants`, scrubs it from the watcher index +
2306    /// notifies the bridge.
2307    ///
2308    /// **Auto-collapse semantics:**
2309    /// - New arity ≥ 2: session stays active, recompute
2310    ///   fires with the smaller participant set.
2311    /// - New arity == 1: session is **dormant** (registered,
2312    ///   refcount stays on the remaining buffer, but
2313    ///   `compute_diff` publishes an empty `HunkIndex` since
2314    ///   there's no peer to diff against).
2315    /// - New arity == 0: session **auto-drops** (calls
2316    ///   `drop_session` internally).
2317    ///
2318    /// Returns the new arity (0 on auto-drop).
2319    pub fn remove_participant(
2320        self: &Arc<Self>,
2321        session_key: BufferId,
2322        slot: usize,
2323    ) -> Result<usize, MembershipError> {
2324        // Read out the buffer-id at this slot (if any) so
2325        // the bridge + watcher index can update.
2326        let removed_buf = {
2327            let mut descriptors = self
2328                .descriptors
2329                .lock()
2330                .expect("DiffSubsystem mutex poisoned");
2331            let descriptor = descriptors
2332                .get_mut(&session_key)
2333                .ok_or(MembershipError::NoSession(session_key))?;
2334            let arity = descriptor.arity();
2335            if slot >= arity {
2336                return Err(MembershipError::SlotOutOfRange { slot, arity });
2337            }
2338            descriptor.sources.remove(slot);
2339            // `participants` indices may not align with
2340            // `sources` slot indices (participants only
2341            // lists buffer-backed sides), so we can't blindly
2342            // remove by slot. Instead, if the descriptor's
2343            // participants/watch lists carry a buffer at the
2344            // same position as a buffer-backed source, the
2345            // caller passes the buffer id explicitly via
2346            // `remove_participant_buffer`. For slot-based
2347            // removal we just trim the source vector and
2348            // leave participants/watch alone; the next
2349            // add_participant or recompute will reconcile.
2350            None::<BufferId>
2351        };
2352
2353        // If the new arity is 0, auto-drop.
2354        let new_arity = {
2355            let descriptors = self
2356                .descriptors
2357                .lock()
2358                .expect("DiffSubsystem mutex poisoned");
2359            descriptors
2360                .get(&session_key)
2361                .map(|d| d.arity())
2362                .unwrap_or(0)
2363        };
2364        if new_arity == 0 {
2365            self.drop_session(session_key);
2366            return Ok(0);
2367        }
2368
2369        // Bridge / index updates only fire if we know which
2370        // buffer left (the buffer-aware path).
2371        if let Some(buf) = removed_buf {
2372            self.scrub_watcher_entries(session_key, &[buf]);
2373            self.scrub_secondary_entries(session_key, &[buf]);
2374            self.mode_bridge.note_session_shrunk(session_key, buf);
2375        }
2376
2377        // Kick a recompute with the new arity.
2378        Arc::clone(self).poke_session(session_key);
2379        Ok(new_arity)
2380    }
2381
2382    /// D.8.d (2026-05-31): convenience — remove the slot
2383    /// whose `participants` entry equals `buffer_id`. Looks
2384    /// up the slot via [`pane_index_of`] (D.6.d helper),
2385    /// then delegates to [`Self::remove_participant`].
2386    /// Updates `watch` + `participants` + bridge in this
2387    /// path (unlike slot-only removal, since we know which
2388    /// buffer leaves).
2389    ///
2390    /// This is the typical entry point for `:diffthis` /
2391    /// per-buffer `:diffoff` (D.8.e / D.8.f).
2392    pub fn remove_participant_buffer(
2393        self: &Arc<Self>,
2394        session_key: BufferId,
2395        buffer_id: BufferId,
2396    ) -> Result<usize, MembershipError> {
2397        // Find the slot first under a read lock.
2398        let slot = {
2399            let descriptors = self
2400                .descriptors
2401                .lock()
2402                .expect("DiffSubsystem mutex poisoned");
2403            let descriptor = descriptors
2404                .get(&session_key)
2405                .ok_or(MembershipError::NoSession(session_key))?;
2406            pane_index_of(descriptor, buffer_id)
2407                .ok_or(MembershipError::NotParticipant(buffer_id))?
2408        };
2409
2410        // Mutate under a write lock: drop the source +
2411        // trim `watch` + `participants`.
2412        {
2413            let mut descriptors = self
2414                .descriptors
2415                .lock()
2416                .expect("DiffSubsystem mutex poisoned");
2417            let descriptor = descriptors
2418                .get_mut(&session_key)
2419                .ok_or(MembershipError::NoSession(session_key))?;
2420            descriptor.sources.remove(slot);
2421            descriptor.watch.retain(|&b| b != buffer_id);
2422            descriptor.participants.retain(|&b| b != buffer_id);
2423        }
2424
2425        // Auto-drop on N → 0.
2426        let new_arity = {
2427            let descriptors = self
2428                .descriptors
2429                .lock()
2430                .expect("DiffSubsystem mutex poisoned");
2431            descriptors
2432                .get(&session_key)
2433                .map(|d| d.arity())
2434                .unwrap_or(0)
2435        };
2436        if new_arity == 0 {
2437            self.drop_session(session_key);
2438            return Ok(0);
2439        }
2440
2441        // Scrub indexes + notify bridge.
2442        self.scrub_watcher_entries(session_key, &[buffer_id]);
2443        self.scrub_secondary_entries(session_key, &[buffer_id]);
2444        self.mode_bridge.note_session_shrunk(session_key, buffer_id);
2445
2446        // Kick recompute.
2447        Arc::clone(self).poke_session(session_key);
2448        Ok(new_arity)
2449    }
2450
2451    /// D.8.d (2026-05-31): atomically swap a session's
2452    /// descriptor while preserving session identity. The
2453    /// `Arc<DiffSession>` stays the same — any holder
2454    /// (`compute_get_edit` / `compute_put_plan` callers,
2455    /// renderer-side `current_hunks` readers) sees a smooth
2456    /// transition. Useful for transitioning a session from
2457    /// N=1 dormant to N=2 active (the natural
2458    /// `:diffthis` flow) when we want to swap the entire
2459    /// source list rather than `add_participant`-ing one
2460    /// at a time.
2461    ///
2462    /// Internally: `drop_session`'s scrub semantic for the
2463    /// old descriptor, then `register_with_sources`'s install
2464    /// semantic for the new one — but without dropping the
2465    /// session entry from the registry. The mode-bridge
2466    /// re-scrubs + re-installs participants the same way
2467    /// `note_session_opened` already does on re-open.
2468    pub fn replace_descriptor(
2469        self: &Arc<Self>,
2470        session_key: BufferId,
2471        descriptor: DiffDescriptor,
2472    ) -> Result<(), MembershipError> {
2473        // Reject N≥4 atomically before any mutation.
2474        if descriptor.arity() >= 4 {
2475            return Err(MembershipError::EngineRejected(
2476                crate::DiffEngineError::Unsupported {
2477                    n: descriptor.arity(),
2478                },
2479            ));
2480        }
2481        // Require an existing session.
2482        if self.lookup(session_key).is_none() {
2483            return Err(MembershipError::NoSession(session_key));
2484        }
2485
2486        // Scrub the old descriptor's index entries.
2487        let old_descriptor = {
2488            let mut descriptors = self
2489                .descriptors
2490                .lock()
2491                .expect("DiffSubsystem mutex poisoned");
2492            descriptors.insert(session_key, descriptor.clone())
2493        };
2494        if let Some(old) = old_descriptor {
2495            self.scrub_watcher_entries(session_key, &old.watch);
2496            self.scrub_secondary_entries(session_key, &old.watch);
2497        }
2498
2499        // Install the new descriptor's index entries.
2500        self.install_watcher_entries(session_key, &descriptor.watch);
2501        self.install_secondary_entries(session_key, &descriptor.watch);
2502
2503        // Bridge: re-open semantic (scrubs old participants,
2504        // installs new ones with refcount transitions).
2505        self.mode_bridge
2506            .note_session_opened(session_key, &descriptor.participants);
2507
2508        // Kick a recompute with the new shape.
2509        Arc::clone(self).poke_session(session_key);
2510        Ok(())
2511    }
2512
2513    // Internal helper: add this session_key to each watched
2514    // buffer's bucket. Called from register_with_sources.
2515    fn install_watcher_entries(&self, session_key: BufferId, watch: &[BufferId]) {
2516        let mut watchers = self.watchers.lock().expect("DiffSubsystem mutex poisoned");
2517        for &watched in watch {
2518            let bucket = watchers.entry(watched).or_default();
2519            if !bucket.contains(&session_key) {
2520                bucket.push(session_key);
2521            }
2522        }
2523    }
2524
2525    // Internal helper: remove this session_key from each watched
2526    // buffer's bucket. Called from drop_session + register
2527    // (when replacing a descriptor).
2528    fn scrub_watcher_entries(&self, session_key: BufferId, watch: &[BufferId]) {
2529        let mut watchers = self.watchers.lock().expect("DiffSubsystem mutex poisoned");
2530        for watched in watch {
2531            if let Some(bucket) = watchers.get_mut(watched) {
2532                bucket.retain(|s| *s != session_key);
2533                if bucket.is_empty() {
2534                    watchers.remove(watched);
2535                }
2536            }
2537        }
2538    }
2539
2540    // D.4.d.3.a internal helper: for each watched buffer that
2541    // isn't the session's primary key, record the secondary
2542    // → primary mapping so `lookup_session_for` can resolve a
2543    // session from either side of a two-pane diff. Skips
2544    // entries that equal `session_key` (inline `:diff`
2545    // `watch = [primary]` contributes nothing). Idempotent on
2546    // repeat installs.
2547    fn install_secondary_entries(&self, session_key: BufferId, watch: &[BufferId]) {
2548        let mut secondary = self
2549            .secondary_index
2550            .lock()
2551            .expect("DiffSubsystem mutex poisoned");
2552        for &watched in watch {
2553            if watched == session_key {
2554                continue;
2555            }
2556            secondary.insert(watched, session_key);
2557        }
2558    }
2559
2560    // D.4.d.3.a internal helper: remove any secondary entries
2561    // pointing at this `session_key`. Called from drop_session
2562    // and from register (when replacing a descriptor whose
2563    // watch list shrunk). Skips the primary entry like the
2564    // install path.
2565    fn scrub_secondary_entries(&self, session_key: BufferId, watch: &[BufferId]) {
2566        let mut secondary = self
2567            .secondary_index
2568            .lock()
2569            .expect("DiffSubsystem mutex poisoned");
2570        for watched in watch {
2571            if *watched == session_key {
2572                continue;
2573            }
2574            // Only remove if the entry still points at this
2575            // session — a re-register could have rerouted the
2576            // secondary to a different primary in between.
2577            if secondary.get(watched) == Some(&session_key) {
2578                secondary.remove(watched);
2579            }
2580        }
2581    }
2582
2583    /// `true` if no sessions are registered. Test-friendly.
2584    pub fn is_empty(&self) -> bool {
2585        self.sessions
2586            .lock()
2587            .expect("DiffSubsystem mutex poisoned")
2588            .is_empty()
2589    }
2590
2591    /// Number of registered sessions. Test-friendly.
2592    pub fn len(&self) -> usize {
2593        self.sessions
2594            .lock()
2595            .expect("DiffSubsystem mutex poisoned")
2596            .len()
2597    }
2598
2599    /// Snapshot of all currently-registered sessions. Returns
2600    /// fresh `Arc` clones — callers may hold them past a
2601    /// concurrent `drop_session` without affecting registry
2602    /// state. Order is unspecified (HashMap iteration); D.2.d
2603    /// sorts for display.
2604    pub fn iter_sessions(&self) -> Vec<Arc<DiffSession>> {
2605        self.sessions
2606            .lock()
2607            .expect("DiffSubsystem mutex poisoned")
2608            .values()
2609            .cloned()
2610            .collect()
2611    }
2612
2613    // ──────────────────────────────────────────────────────
2614    // D.2.d: introspection
2615    // ──────────────────────────────────────────────────────
2616
2617    /// Snapshot of all currently-registered sessions for
2618    /// `:describe-diff` introspection. Sorted by `BufferId` so
2619    /// the rendered output is stable across calls.
2620    ///
2621    /// Each row carries everything the renderer needs to format
2622    /// one line of the help buffer: the session key, the
2623    /// algorithm, the currently-published revision + hunk
2624    /// count, and (when a descriptor is registered) the
2625    /// declared `watch` list.
2626    pub fn describe_sessions(&self) -> Vec<DiffSessionDescription> {
2627        let sessions = self.sessions.lock().expect("DiffSubsystem mutex poisoned");
2628        let descriptors = self
2629            .descriptors
2630            .lock()
2631            .expect("DiffSubsystem mutex poisoned");
2632        let mut rows: Vec<DiffSessionDescription> = sessions
2633            .values()
2634            .map(|session| {
2635                let hunks = session.current_hunks();
2636                let watch = descriptors
2637                    .get(&session.buffer_id())
2638                    .map(|d| d.watch.clone())
2639                    .unwrap_or_default();
2640                DiffSessionDescription {
2641                    buffer_id: session.buffer_id(),
2642                    algorithm: session.algorithm(),
2643                    revision: hunks.revision,
2644                    hunk_count: hunks.len(),
2645                    watch,
2646                }
2647            })
2648            .collect();
2649        rows.sort_by_key(|row| row.buffer_id);
2650        rows
2651    }
2652
2653    /// Build the `:describe-diff` help-buffer body — the
2654    /// human-readable text rendered into the synthetic
2655    /// Document buffer that `do_describe_diff` opens.
2656    ///
2657    /// Output shape:
2658    /// ```text
2659    /// Active diff sessions: 2
2660    ///
2661    /// BufferId  Algorithm     Rev  Hunks  Watches
2662    /// --------  ------------  ---  -----  -------
2663    /// 1         Histogram     5    3      [1, 2]
2664    /// 7         MyersMinimal  0    0      [7]
2665    /// ```
2666    pub fn build_describe_diff_content(&self) -> String {
2667        let rows = self.describe_sessions();
2668        if rows.is_empty() {
2669            return "No active diff sessions.\n".to_string();
2670        }
2671        let mut out = String::new();
2672        out.push_str(&format!("Active diff sessions: {}\n\n", rows.len()));
2673        out.push_str("BufferId  Algorithm     Rev  Hunks  Watches\n");
2674        out.push_str("--------  ------------  ---  -----  -------\n");
2675        for row in rows {
2676            let watches = if row.watch.is_empty() {
2677                "[]".to_string()
2678            } else {
2679                format!(
2680                    "[{}]",
2681                    row.watch
2682                        .iter()
2683                        .map(|b| b.0.to_string())
2684                        .collect::<Vec<_>>()
2685                        .join(", ")
2686                )
2687            };
2688            out.push_str(&format!(
2689                "{:<8}  {:<12}  {:<3}  {:<5}  {}\n",
2690                row.buffer_id.0,
2691                format_algorithm(row.algorithm),
2692                row.revision,
2693                row.hunk_count,
2694                watches
2695            ));
2696        }
2697        out
2698    }
2699
2700    /// D.2.b: schedule a recompute of `buffer_id`'s session on
2701    /// the tokio blocking pool. Returns `None` if no session is
2702    /// registered.
2703    ///
2704    /// The returned `JoinHandle` resolves to the publish result
2705    /// (`Some(Arc<HunkIndex>)` on take, `None` if dropped as
2706    /// stale). Production callers can fire-and-forget — the
2707    /// session's `ArcSwap<HunkIndex>` is the source of truth and
2708    /// reads stay coherent regardless of whether anyone awaits
2709    /// the handle. Tests and `:describe-diff` await for
2710    /// observability.
2711    ///
2712    /// Supersede semantics: when two recomputes are scheduled in
2713    /// rapid succession, both run to completion on the blocking
2714    /// pool — there is no abort. Whichever finishes second
2715    /// allocates the higher revision and wins the
2716    /// [`DiffSession::try_publish_if_newer`] gate; whichever
2717    /// finishes first either gets there first (and is then
2718    /// superseded by the second's publish) or loses the gate.
2719    /// Either way the final state is the latest scheduled
2720    /// recompute's hunks. D.2.c's debounce will eliminate most
2721    /// of the redundant spawn cost before it hits the pool.
2722    pub fn schedule_recompute(
2723        &self,
2724        buffer_id: BufferId,
2725        sources: Vec<Arc<dyn DiffParticipantSource>>,
2726    ) -> Option<JoinHandle<Option<Arc<HunkIndex>>>> {
2727        let session = self.lookup(buffer_id)?;
2728        Some(tokio::task::spawn_blocking(move || {
2729            // D.8.c (2026-05-31): snapshot every source inside
2730            // the blocking task — the engine wants `&[Rope]`,
2731            // and snapshotting is potentially expensive
2732            // (file-on-disk reads, future git-blob reads). The
2733            // caller passes owned `Arc<dyn ...>` handles; the
2734            // task captures them so the descriptor's mutex
2735            // isn't held across the snapshot calls.
2736            let ropes: Vec<Rope> = sources.iter().map(|s| s.snapshot()).collect();
2737            session.recompute_blocking(&ropes)
2738        }))
2739    }
2740
2741    // ──────────────────────────────────────────────────────
2742    // D.2.c: routing entry points
2743    // ──────────────────────────────────────────────────────
2744
2745    /// Notify the subsystem that `buffer_id` was edited. Walks
2746    /// the inverse `watchers` index and pokes the debouncer for
2747    /// every session whose descriptor's `watch` list includes
2748    /// `buffer_id`. Each poke schedules a recompute after the
2749    /// debounce window; multiple pokes during the window
2750    /// collapse to one recompute (see [`Debouncer`]).
2751    ///
2752    /// Production driver is [`Self::bind`]'s drainer task; tests
2753    /// (and future non-bus drivers) can call this directly.
2754    pub fn note_buffer_edited(self: &Arc<Self>, buffer_id: BufferId) {
2755        let dependents = self.watchers_of(buffer_id);
2756        if dependents.is_empty() {
2757            return;
2758        }
2759        debug!(
2760            target: "lattice_host::diff::subsystem",
2761            ?buffer_id,
2762            n_dependents = dependents.len(),
2763            "diff: buffer edited, poking debouncers"
2764        );
2765        for session_key in dependents {
2766            self.poke_session(session_key);
2767        }
2768    }
2769
2770    /// Notify the subsystem that `buffer_id` was closed. Drops
2771    /// the session for that buffer. If the closed buffer was a
2772    /// watched-only dependency of some other session (e.g.
2773    /// `BufferSource(closed_id)` for session X), session X's
2774    /// watcher entry for `closed_id` is left in place — the
2775    /// next snapshot returns an empty rope per the
2776    /// [`BufferTextProvider`] contract, and the session will
2777    /// recompute the all-Add diff. The session itself is not
2778    /// dropped on a watched-side close; only on a current-side
2779    /// close.
2780    pub fn note_buffer_closed(&self, buffer_id: BufferId) {
2781        debug!(
2782            target: "lattice_host::diff::subsystem",
2783            ?buffer_id,
2784            "diff: buffer closed, dropping session if registered"
2785        );
2786        self.drop_session(buffer_id);
2787    }
2788
2789    // Internal: look up the descriptor + debouncer for
2790    // `session_key` and fire a debounced recompute via
2791    // `schedule_recompute`. The closure captured by the
2792    // debouncer holds an `Arc<Self>` so the subsystem stays
2793    // alive for the duration of the deferred work.
2794    fn poke_session(self: &Arc<Self>, session_key: BufferId) {
2795        let debouncer = match self
2796            .debouncers
2797            .lock()
2798            .expect("DiffSubsystem mutex poisoned")
2799            .get(&session_key)
2800            .cloned()
2801        {
2802            Some(d) => d,
2803            None => return,
2804        };
2805        let sub = Arc::clone(self);
2806        debouncer.poke(move || {
2807            sub.recompute_from_descriptor(session_key);
2808        });
2809    }
2810
2811    // Internal: read the session's descriptor and fire
2812    // `schedule_recompute` with a clone of the source list.
2813    // `schedule_recompute` spawns the diff on the blocking
2814    // pool and snapshots inside the task; we return
2815    // immediately. Stale or torn-down sessions return early
2816    // — the gated publish in D.2.b drops anything stale
2817    // that does land.
2818    fn recompute_from_descriptor(&self, session_key: BufferId) {
2819        let descriptor = match self.lookup_descriptor(session_key) {
2820            Some(d) => d,
2821            None => return,
2822        };
2823        let _ = self.schedule_recompute(session_key, descriptor.sources);
2824    }
2825
2826    /// D.2.c: bind the subsystem to an event bus. Subscribes to
2827    /// `EventKind::DocumentChanged` + `EventKind::DocumentClosed`,
2828    /// spawns one drainer task that translates each event's
2829    /// `DocumentId` to `BufferId` via `resolver` and fans the
2830    /// signal into the routing path.
2831    ///
2832    /// Returns a [`DiffSubscriptionGuard`] whose `Drop`
2833    /// unsubscribes the bus subscription and aborts the
2834    /// drainer task. Hosts hold the guard for the editor's
2835    /// lifetime; tests drop it to verify cleanup.
2836    pub fn bind(
2837        self: &Arc<Self>,
2838        bus: Arc<EventBus>,
2839        resolver: Arc<dyn DocumentBufferResolver>,
2840    ) -> DiffSubscriptionGuard {
2841        let (tx, mut rx) = mpsc::unbounded_channel::<Event>();
2842        let subscription = bus.subscribe(
2843            EventFilter::kinds(vec![EventKind::DocumentChanged, EventKind::DocumentClosed]),
2844            SubscriptionTarget::Channel(tx),
2845        );
2846        let sub_self = Arc::clone(self);
2847        let drainer = tokio::spawn(async move {
2848            while let Some(event) = rx.recv().await {
2849                match event {
2850                    Event::DocumentChanged { id, .. } => {
2851                        if let Some(buffer_id) = resolver.buffer_id_for(id) {
2852                            sub_self.note_buffer_edited(buffer_id);
2853                        }
2854                    }
2855                    Event::DocumentClosed { id } => {
2856                        if let Some(buffer_id) = resolver.buffer_id_for(id) {
2857                            sub_self.note_buffer_closed(buffer_id);
2858                        }
2859                    }
2860                    _ => {}
2861                }
2862            }
2863        });
2864        DiffSubscriptionGuard {
2865            bus,
2866            subscription,
2867            drainer,
2868        }
2869    }
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874    use super::*;
2875
2876    fn bid(n: u32) -> BufferId {
2877        BufferId(n)
2878    }
2879
2880    #[test]
2881    fn fresh_registry_is_empty() {
2882        let sub = DiffSubsystem::new();
2883        assert!(sub.is_empty());
2884        assert_eq!(sub.len(), 0);
2885        assert!(sub.lookup(bid(1)).is_none());
2886    }
2887
2888    #[test]
2889    fn register_returns_session_and_grows_registry() {
2890        let sub = DiffSubsystem::new();
2891        let s = sub.register(bid(1), DiffAlgorithm::Histogram);
2892        assert_eq!(s.buffer_id(), bid(1));
2893        assert_eq!(s.algorithm(), DiffAlgorithm::Histogram);
2894        assert_eq!(sub.len(), 1);
2895        assert!(!sub.is_empty());
2896    }
2897
2898    #[test]
2899    fn register_is_idempotent() {
2900        let sub = DiffSubsystem::new();
2901        let first = sub.register(bid(7), DiffAlgorithm::Histogram);
2902        let second = sub.register(bid(7), DiffAlgorithm::Myers);
2903        // Same Arc — registry returns the existing session and
2904        // ignores the second algorithm argument.
2905        assert!(Arc::ptr_eq(&first, &second));
2906        assert_eq!(second.algorithm(), DiffAlgorithm::Histogram);
2907        assert_eq!(sub.len(), 1);
2908    }
2909
2910    #[test]
2911    fn distinct_buffers_get_distinct_sessions() {
2912        let sub = DiffSubsystem::new();
2913        let a = sub.register(bid(1), DiffAlgorithm::Histogram);
2914        let b = sub.register(bid(2), DiffAlgorithm::Histogram);
2915        assert!(!Arc::ptr_eq(&a, &b));
2916        assert_eq!(sub.len(), 2);
2917    }
2918
2919    #[test]
2920    fn lookup_returns_same_arc_as_register() {
2921        let sub = DiffSubsystem::new();
2922        let registered = sub.register(bid(1), DiffAlgorithm::Histogram);
2923        let looked_up = sub.lookup(bid(1)).expect("session should be present");
2924        assert!(Arc::ptr_eq(&registered, &looked_up));
2925    }
2926
2927    #[test]
2928    fn drop_session_removes_entry_and_returns_true_only_once() {
2929        let sub = DiffSubsystem::new();
2930        sub.register(bid(1), DiffAlgorithm::Histogram);
2931        assert!(sub.drop_session(bid(1)));
2932        assert!(sub.is_empty());
2933        assert!(sub.lookup(bid(1)).is_none());
2934        // Second drop is a no-op.
2935        assert!(!sub.drop_session(bid(1)));
2936    }
2937
2938    #[test]
2939    fn drop_does_not_invalidate_held_arc() {
2940        let sub = DiffSubsystem::new();
2941        let held = sub.register(bid(1), DiffAlgorithm::Histogram);
2942        sub.drop_session(bid(1));
2943        // Caller still has a coherent session — the registry just
2944        // forgot the entry.
2945        assert_eq!(held.buffer_id(), bid(1));
2946        let snap = held.current_hunks();
2947        assert!(snap.is_empty());
2948    }
2949
2950    #[test]
2951    fn iter_sessions_enumerates_all_registered() {
2952        let sub = DiffSubsystem::new();
2953        sub.register(bid(1), DiffAlgorithm::Histogram);
2954        sub.register(bid(2), DiffAlgorithm::Histogram);
2955        sub.register(bid(3), DiffAlgorithm::Myers);
2956        let mut ids: Vec<BufferId> = sub.iter_sessions().iter().map(|s| s.buffer_id()).collect();
2957        ids.sort();
2958        assert_eq!(ids, vec![bid(1), bid(2), bid(3)]);
2959    }
2960
2961    #[test]
2962    fn session_starts_with_empty_hunks_tagged_with_algorithm() {
2963        let s = DiffSession::new(bid(1), DiffAlgorithm::MyersMinimal);
2964        let snap = s.current_hunks();
2965        assert!(snap.is_empty());
2966        assert_eq!(snap.algorithm, DiffAlgorithm::MyersMinimal);
2967        assert_eq!(snap.revision, 0);
2968    }
2969
2970    #[test]
2971    fn publish_replaces_current_hunks() {
2972        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
2973        let new_idx = Arc::new(HunkIndex {
2974            hunks: Vec::new(),
2975            algorithm: DiffAlgorithm::Histogram,
2976            revision: 42,
2977        });
2978        s.publish(new_idx);
2979        let snap = s.current_hunks();
2980        assert_eq!(snap.revision, 42);
2981    }
2982
2983    #[test]
2984    fn publish_is_visible_across_arc_clones() {
2985        // RCU semantics: two clones of the session see the same
2986        // latest publish.
2987        let s = Arc::new(DiffSession::new(bid(1), DiffAlgorithm::Histogram));
2988        let reader = Arc::clone(&s);
2989        s.publish(Arc::new(HunkIndex {
2990            hunks: Vec::new(),
2991            algorithm: DiffAlgorithm::Histogram,
2992            revision: 9,
2993        }));
2994        assert_eq!(reader.current_hunks().revision, 9);
2995    }
2996
2997    // ──────────────────────────────────────────────────────────
2998    // D.2.b: DiffParticipantSource + recompute + schedule
2999    // ──────────────────────────────────────────────────────────
3000
3001    #[test]
3002    fn static_baseline_clones_rope_on_snapshot() {
3003        let base = StaticSource::new(Rope::from("alpha\nbeta\n"));
3004        let snap = base.snapshot();
3005        assert_eq!(snap.to_string(), "alpha\nbeta\n");
3006        // Second snapshot is independent.
3007        let snap2 = base.snapshot();
3008        assert_eq!(snap2.to_string(), "alpha\nbeta\n");
3009    }
3010
3011    #[test]
3012    fn on_disk_baseline_reads_file() {
3013        // Write a tempfile, snapshot the baseline against it,
3014        // verify content.
3015        let dir = std::env::temp_dir();
3016        let path = dir.join(format!(
3017            "lattice-on-disk-baseline-test-{}.txt",
3018            std::process::id()
3019        ));
3020        std::fs::write(&path, "hello\nworld\n").expect("write tempfile");
3021        let base = OnDiskSource::new(path.clone());
3022        let snap = base.snapshot();
3023        assert_eq!(snap.to_string(), "hello\nworld\n");
3024        let _ = std::fs::remove_file(&path);
3025    }
3026
3027    #[test]
3028    fn on_disk_baseline_missing_file_returns_empty_rope() {
3029        // Per docs: missing path / I/O error degrades to
3030        // empty rope (all-Add presentation) rather than
3031        // panicking.
3032        let base = OnDiskSource::new(std::path::PathBuf::from(
3033            "/nonexistent/path/lattice-diff-test-does-not-exist",
3034        ));
3035        let snap = base.snapshot();
3036        assert_eq!(snap.len_chars(), 0);
3037    }
3038
3039    #[test]
3040    fn allocate_revision_is_monotonic() {
3041        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3042        assert_eq!(s.peek_next_revision(), 1);
3043        assert_eq!(s.allocate_revision(), 1);
3044        assert_eq!(s.allocate_revision(), 2);
3045        assert_eq!(s.allocate_revision(), 3);
3046        assert_eq!(s.peek_next_revision(), 4);
3047    }
3048
3049    #[test]
3050    fn recompute_blocking_on_identical_ropes_produces_empty_hunks() {
3051        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3052        let r = Rope::from("alpha\nbeta\ngamma\n");
3053        let published = s
3054            .recompute_blocking(&[r.clone(), r.clone()])
3055            .expect("first publish should always take");
3056        assert!(published.is_empty());
3057        assert_eq!(published.algorithm, DiffAlgorithm::Histogram);
3058        assert_eq!(published.revision, 1);
3059        // And the session's published state matches.
3060        assert_eq!(s.current_hunks().revision, 1);
3061    }
3062
3063    #[test]
3064    fn recompute_blocking_on_changed_ropes_produces_change_hunk() {
3065        use crate::HunkKind;
3066        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3067        let a = Rope::from("alpha\nbeta\ngamma\n");
3068        let b = Rope::from("alpha\nBETA\ngamma\n");
3069        let idx = s
3070            .recompute_blocking(&[a.clone(), b.clone()])
3071            .expect("first publish should take");
3072        assert_eq!(idx.len(), 1);
3073        assert_eq!(idx.hunks[0].kind, HunkKind::Change);
3074        assert_eq!(idx.revision, 1);
3075    }
3076
3077    #[test]
3078    fn recompute_blocking_publishes_sign_map_for_changed_ropes() {
3079        // D-fix.3a: the sign map is published in lockstep with the hunks at the
3080        // recompute choke point, so pane-group diffs (which never spawn the
3081        // inline `DiffOverlayRefreshTask`) still get in-buffer tints + gutter
3082        // signs — not just inline `:diff`.
3083        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3084        assert!(
3085            s.sign_map().sign_at(1).is_none(),
3086            "no signs before the first recompute"
3087        );
3088        let a = Rope::from("alpha\nbeta\ngamma\n");
3089        let b = Rope::from("alpha\nBETA\ngamma\n");
3090        s.recompute_blocking(&[a, b]).expect("first publish takes");
3091        let signs = s.sign_map();
3092        assert_eq!(
3093            signs.sign_at(1),
3094            Some(crate::overlay::DiffSignKind::Change),
3095            "changed current-side line 1 is signed Change after recompute"
3096        );
3097        assert!(signs.sign_at(0).is_none(), "unchanged line 0 has no sign");
3098    }
3099
3100    #[test]
3101    fn slot_line_count_publishes_from_recompute() {
3102        // D-fix.5: the per-slot line counts are published in lockstep
3103        // with the hunks at the recompute choke point so the
3104        // `UnchangedFoldSource` can bound its complement to each side's
3105        // EOF. Empty (None) before the first recompute.
3106        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3107        assert_eq!(
3108            s.slot_line_count(0),
3109            None,
3110            "no counts before first recompute"
3111        );
3112        assert_eq!(s.slot_line_count(1), None);
3113        let a = Rope::from("alpha\nbeta\n"); // len_lines = 3 (incl. trailing)
3114        let b = Rope::from("alpha\nBETA\ngamma\ndelta\n"); // len_lines = 5
3115        s.recompute_blocking(&[a.clone(), b.clone()])
3116            .expect("first publish takes");
3117        assert_eq!(s.slot_line_count(0), Some(a.len_lines() as u32));
3118        assert_eq!(s.slot_line_count(1), Some(b.len_lines() as u32));
3119        assert_eq!(s.slot_line_count(2), None, "no slot 2 in a two-way diff");
3120    }
3121
3122    #[test]
3123    fn participant_slot_resolves_each_side() {
3124        // D-fix.5: the slot a buffer occupies in `Hunk::ranges` is what
3125        // a per-side fold source uses to fold the buffer's OWN side.
3126        // Resolves from the primary key (current = slot 1) AND from the
3127        // baseline secondary side (slot 0, via the secondary index).
3128        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3129        let sub = DiffSubsystem::new();
3130        // primary = current = bid(1) at slot 1; baseline = bid(2) at slot 0.
3131        let desc = descriptor(&provider, bid(2), bid(1));
3132        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3133        assert_eq!(
3134            sub.participant_slot(bid(1)),
3135            Some(1),
3136            "current side = slot 1"
3137        );
3138        assert_eq!(
3139            sub.participant_slot(bid(2)),
3140            Some(0),
3141            "baseline side = slot 0"
3142        );
3143        assert_eq!(
3144            sub.participant_slot(bid(99)),
3145            None,
3146            "non-participant = None"
3147        );
3148    }
3149
3150    #[test]
3151    fn first_change_line_returns_first_hunk_on_buffers_own_side() {
3152        // D-fix.5: the auto-scroll target is the first hunk's start on
3153        // the queried buffer's OWN side. A Change hunk at baseline
3154        // [3,4) / current [5,6) gives line 3 for the baseline buffer and
3155        // line 5 for the current buffer.
3156        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3157        let sub = DiffSubsystem::new();
3158        let desc = descriptor(&provider, bid(2), bid(1));
3159        let session = sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3160        // Empty hunks → no change line (graceful: clean diff, no scroll).
3161        assert_eq!(sub.first_change_line(bid(1)), None);
3162        session.publish(Arc::new(HunkIndex {
3163            hunks: vec![Hunk {
3164                kind: HunkKind::Change,
3165                ranges: smallvec::smallvec![LineRange::new(3, 4), LineRange::new(5, 6)],
3166                refine: Default::default(),
3167            }],
3168            algorithm: DiffAlgorithm::Histogram,
3169            revision: 1,
3170        }));
3171        assert_eq!(sub.first_change_line(bid(1)), Some(5), "current side start");
3172        assert_eq!(
3173            sub.first_change_line(bid(2)),
3174            Some(3),
3175            "baseline side start"
3176        );
3177    }
3178
3179    #[test]
3180    fn revision_strictly_increases_across_recomputes() {
3181        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3182        let a = Rope::from("alpha\n");
3183        let b = Rope::from("beta\n");
3184        let r1 = s.recompute_blocking(&[a.clone(), b.clone()]).unwrap();
3185        let r2 = s.recompute_blocking(&[a.clone(), b.clone()]).unwrap();
3186        let r3 = s.recompute_blocking(&[a.clone(), b.clone()]).unwrap();
3187        assert_eq!(r1.revision, 1);
3188        assert_eq!(r2.revision, 2);
3189        assert_eq!(r3.revision, 3);
3190        // Final published state matches the last recompute.
3191        assert_eq!(s.current_hunks().revision, 3);
3192    }
3193
3194    #[test]
3195    fn try_publish_if_newer_drops_stale_revision() {
3196        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3197        // Land revision=5 first.
3198        let r5 = Arc::new(HunkIndex {
3199            hunks: Vec::new(),
3200            algorithm: DiffAlgorithm::Histogram,
3201            revision: 5,
3202        });
3203        assert!(s.try_publish_if_newer(r5));
3204        assert_eq!(s.current_hunks().revision, 5);
3205
3206        // Stale revision=3 is dropped.
3207        let r3 = Arc::new(HunkIndex {
3208            hunks: Vec::new(),
3209            algorithm: DiffAlgorithm::Histogram,
3210            revision: 3,
3211        });
3212        assert!(!s.try_publish_if_newer(r3));
3213        assert_eq!(s.current_hunks().revision, 5);
3214
3215        // Equal revision is also dropped (strict greater-than).
3216        let r5_again = Arc::new(HunkIndex {
3217            hunks: Vec::new(),
3218            algorithm: DiffAlgorithm::Histogram,
3219            revision: 5,
3220        });
3221        assert!(!s.try_publish_if_newer(r5_again));
3222        assert_eq!(s.current_hunks().revision, 5);
3223
3224        // Newer revision lands.
3225        let r9 = Arc::new(HunkIndex {
3226            hunks: Vec::new(),
3227            algorithm: DiffAlgorithm::Histogram,
3228            revision: 9,
3229        });
3230        assert!(s.try_publish_if_newer(r9));
3231        assert_eq!(s.current_hunks().revision, 9);
3232    }
3233
3234    #[test]
3235    fn recompute_blocking_returns_none_when_publish_is_stale() {
3236        // Force a stale outcome by landing a high revision first,
3237        // then running a recompute (which allocates revision=1)
3238        // — the gate drops it.
3239        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
3240        let high = Arc::new(HunkIndex {
3241            hunks: Vec::new(),
3242            algorithm: DiffAlgorithm::Histogram,
3243            revision: 100,
3244        });
3245        assert!(s.try_publish_if_newer(high));
3246        let r = Rope::from("x\n");
3247        let result = s.recompute_blocking(&[r.clone(), r.clone()]);
3248        assert!(result.is_none(), "stale recompute should not publish");
3249        assert_eq!(s.current_hunks().revision, 100);
3250    }
3251
3252    #[test]
3253    fn schedule_recompute_returns_none_for_unregistered_buffer() {
3254        let sub = DiffSubsystem::new();
3255        let baseline: Arc<dyn DiffParticipantSource> =
3256            Arc::new(StaticSource::new(Rope::from("x\n")));
3257        let handle = sub.schedule_recompute(
3258            bid(999),
3259            vec![baseline, Arc::new(StaticSource::new(Rope::from("y\n")))],
3260        );
3261        assert!(handle.is_none());
3262    }
3263
3264    #[tokio::test]
3265    async fn schedule_recompute_runs_on_blocking_pool_and_publishes() {
3266        let sub = DiffSubsystem::new();
3267        let session = sub.register(bid(1), DiffAlgorithm::Histogram);
3268        let baseline: Arc<dyn DiffParticipantSource> =
3269            Arc::new(StaticSource::new(Rope::from("alpha\nbeta\n")));
3270        let current = Rope::from("alpha\nBETA\n");
3271
3272        let handle = sub
3273            .schedule_recompute(bid(1), vec![baseline, Arc::new(StaticSource::new(current))])
3274            .expect("registered buffer has a session");
3275        let result = handle.await.expect("blocking task didn't panic");
3276        let idx = result.expect("first recompute publishes");
3277        assert_eq!(idx.revision, 1);
3278        assert_eq!(idx.len(), 1);
3279        // And the session sees it via RCU read.
3280        assert_eq!(session.current_hunks().revision, 1);
3281    }
3282
3283    #[tokio::test]
3284    async fn schedule_recompute_serial_pair_revisions_monotonic() {
3285        let sub = DiffSubsystem::new();
3286        let session = sub.register(bid(1), DiffAlgorithm::Histogram);
3287        let baseline: Arc<dyn DiffParticipantSource> =
3288            Arc::new(StaticSource::new(Rope::from("alpha\n")));
3289
3290        let h1 = sub
3291            .schedule_recompute(
3292                bid(1),
3293                vec![
3294                    Arc::clone(&baseline),
3295                    Arc::new(StaticSource::new(Rope::from("alpha\n"))),
3296                ],
3297            )
3298            .unwrap();
3299        h1.await.unwrap().unwrap();
3300
3301        let h2 = sub
3302            .schedule_recompute(
3303                bid(1),
3304                vec![
3305                    Arc::clone(&baseline),
3306                    Arc::new(StaticSource::new(Rope::from("beta\n"))),
3307                ],
3308            )
3309            .unwrap();
3310        let idx2 = h2.await.unwrap().unwrap();
3311
3312        assert_eq!(idx2.revision, 2);
3313        assert_eq!(session.current_hunks().revision, 2);
3314    }
3315
3316    // ──────────────────────────────────────────────────────────
3317    // D.2.c: routing + debounce + bus subscription
3318    // ──────────────────────────────────────────────────────────
3319
3320    use std::sync::atomic::AtomicU64;
3321
3322    // Mock impl of BufferTextProvider — stores ropes keyed by
3323    // BufferId. The test sets ropes; `BufferSource` /
3324    // `BufferSource` read them on snapshot.
3325    #[derive(Debug, Default)]
3326    struct MockProvider {
3327        ropes: Mutex<HashMap<BufferId, Rope>>,
3328    }
3329
3330    impl MockProvider {
3331        fn set(&self, id: BufferId, rope: Rope) {
3332            self.ropes.lock().unwrap().insert(id, rope);
3333        }
3334    }
3335
3336    impl BufferTextProvider for MockProvider {
3337        fn buffer_rope(&self, id: BufferId) -> Option<Rope> {
3338            self.ropes.lock().unwrap().get(&id).cloned()
3339        }
3340    }
3341
3342    // Mock impl of DocumentBufferResolver — stores DocumentId →
3343    // BufferId pairs the test sets up before publishing events.
3344    #[derive(Debug, Default)]
3345    struct MockResolver {
3346        map: Mutex<HashMap<DocumentId, BufferId>>,
3347    }
3348
3349    impl MockResolver {
3350        fn bind(&self, doc_id: DocumentId, buf_id: BufferId) {
3351            self.map.lock().unwrap().insert(doc_id, buf_id);
3352        }
3353    }
3354
3355    impl DocumentBufferResolver for MockResolver {
3356        fn buffer_id_for(&self, document_id: DocumentId) -> Option<BufferId> {
3357            self.map.lock().unwrap().get(&document_id).copied()
3358        }
3359    }
3360
3361    fn descriptor(
3362        provider: &Arc<dyn BufferTextProvider>,
3363        baseline_buf: BufferId,
3364        current_buf: BufferId,
3365    ) -> DiffDescriptor {
3366        DiffDescriptor {
3367            sources: vec![
3368                Arc::new(BufferSource::new(Arc::clone(provider), baseline_buf)),
3369                Arc::new(BufferSource::new(Arc::clone(provider), current_buf)),
3370            ],
3371            watch: vec![baseline_buf, current_buf],
3372            // D.5.a: tests don't exercise the mode bridge,
3373            // so participants stays empty by default.
3374            participants: vec![],
3375        }
3376    }
3377
3378    /// D.6.a (2026-05-30): test helper for three-way merge
3379    /// descriptors. `base` plays the role of common
3380    /// ancestor; `local` is the side the session is keyed
3381    /// under; `remote` is the third party. watch +
3382    /// participants = all three buffers so the routing index
3383    /// + mode bridge would activate uniformly.
3384    fn three_way_descriptor(
3385        provider: &Arc<dyn BufferTextProvider>,
3386        base_buf: BufferId,
3387        local_buf: BufferId,
3388        remote_buf: BufferId,
3389    ) -> DiffDescriptor {
3390        DiffDescriptor {
3391            sources: vec![
3392                Arc::new(BufferSource::new(Arc::clone(provider), base_buf)),
3393                Arc::new(BufferSource::new(Arc::clone(provider), local_buf)),
3394                Arc::new(BufferSource::new(Arc::clone(provider), remote_buf)),
3395            ],
3396            watch: vec![base_buf, local_buf, remote_buf],
3397            participants: vec![base_buf, local_buf, remote_buf],
3398        }
3399    }
3400
3401    // ── Concrete sources ──────────────────────────────────────
3402
3403    #[test]
3404    fn buffer_baseline_snapshots_through_provider() {
3405        let provider = Arc::new(MockProvider::default());
3406        provider.set(bid(1), Rope::from("hello\n"));
3407        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
3408        let base = BufferSource::new(dyn_provider, bid(1));
3409        assert_eq!(base.snapshot().to_string(), "hello\n");
3410    }
3411
3412    #[test]
3413    fn buffer_baseline_returns_empty_rope_when_provider_lacks_buffer() {
3414        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3415        let base = BufferSource::new(provider, bid(999));
3416        assert_eq!(base.snapshot().len_chars(), 0);
3417    }
3418
3419    #[test]
3420    fn buffer_current_source_snapshots_through_provider() {
3421        let provider = Arc::new(MockProvider::default());
3422        provider.set(bid(1), Rope::from("world\n"));
3423        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
3424        let cur = BufferSource::new(dyn_provider, bid(1));
3425        assert_eq!(cur.snapshot().to_string(), "world\n");
3426    }
3427
3428    // ── Descriptor + watchers ─────────────────────────────────
3429
3430    #[test]
3431    fn register_with_sources_stores_descriptor_and_debouncer() {
3432        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3433        let sub = DiffSubsystem::new();
3434        let desc = descriptor(&provider, bid(2), bid(1));
3435        let session = sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3436        assert_eq!(session.buffer_id(), bid(1));
3437        assert!(sub.lookup_descriptor(bid(1)).is_some());
3438        // Debouncer present (looked up via watchers_of → poke
3439        // path; here we just check the routing table directly).
3440        assert_eq!(sub.watchers_of(bid(1)), vec![bid(1)]);
3441        assert_eq!(sub.watchers_of(bid(2)), vec![bid(1)]);
3442    }
3443
3444    #[test]
3445    fn multiple_sessions_share_a_watched_buffer_bucket() {
3446        // Sessions A and B both watch buffer X — `watchers_of(X)`
3447        // returns both.
3448        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3449        let sub = DiffSubsystem::new();
3450        let desc_a = DiffDescriptor {
3451            sources: vec![
3452                Arc::new(BufferSource::new(Arc::clone(&provider), bid(10))),
3453                Arc::new(BufferSource::new(Arc::clone(&provider), bid(1))),
3454            ],
3455            watch: vec![bid(10), bid(1)],
3456            participants: vec![],
3457        };
3458        let desc_b = DiffDescriptor {
3459            sources: vec![
3460                Arc::new(BufferSource::new(Arc::clone(&provider), bid(10))),
3461                Arc::new(BufferSource::new(Arc::clone(&provider), bid(2))),
3462            ],
3463            watch: vec![bid(10), bid(2)],
3464            participants: vec![],
3465        };
3466        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc_a);
3467        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc_b);
3468        let mut watchers = sub.watchers_of(bid(10));
3469        watchers.sort();
3470        assert_eq!(watchers, vec![bid(1), bid(2)]);
3471    }
3472
3473    #[test]
3474    fn drop_session_clears_descriptor_and_watcher_buckets() {
3475        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3476        let sub = DiffSubsystem::new();
3477        let desc = descriptor(&provider, bid(2), bid(1));
3478        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3479        assert!(sub.lookup_descriptor(bid(1)).is_some());
3480        assert_eq!(sub.watchers_of(bid(2)), vec![bid(1)]);
3481
3482        assert!(sub.drop_session(bid(1)));
3483        assert!(sub.lookup_descriptor(bid(1)).is_none());
3484        assert!(sub.watchers_of(bid(2)).is_empty());
3485        assert!(sub.watchers_of(bid(1)).is_empty());
3486    }
3487
3488    #[test]
3489    fn drop_session_only_scrubs_dropped_sessions_watchers() {
3490        // Session A watches X; session B also watches X. Drop A
3491        // → bucket only loses A, B remains.
3492        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3493        let sub = DiffSubsystem::new();
3494        let desc_a = DiffDescriptor {
3495            sources: vec![
3496                Arc::new(BufferSource::new(Arc::clone(&provider), bid(10))),
3497                Arc::new(BufferSource::new(Arc::clone(&provider), bid(1))),
3498            ],
3499            watch: vec![bid(10), bid(1)],
3500            participants: vec![],
3501        };
3502        let desc_b = DiffDescriptor {
3503            sources: vec![
3504                Arc::new(BufferSource::new(Arc::clone(&provider), bid(10))),
3505                Arc::new(BufferSource::new(Arc::clone(&provider), bid(2))),
3506            ],
3507            watch: vec![bid(10), bid(2)],
3508            participants: vec![],
3509        };
3510        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc_a);
3511        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc_b);
3512
3513        sub.drop_session(bid(1));
3514        assert_eq!(sub.watchers_of(bid(10)), vec![bid(2)]);
3515    }
3516
3517    #[test]
3518    fn reregister_with_smaller_watch_scrubs_stale_entries() {
3519        // Initial watch [10, 1]; re-register with [1] only → 10
3520        // loses its entry for this session.
3521        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3522        let sub = DiffSubsystem::new();
3523        let desc_a = DiffDescriptor {
3524            sources: vec![
3525                Arc::new(StaticSource::new(Rope::from(""))),
3526                Arc::new(BufferSource::new(Arc::clone(&provider), bid(1))),
3527            ],
3528            watch: vec![bid(10), bid(1)],
3529            participants: vec![],
3530        };
3531        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc_a);
3532        assert_eq!(sub.watchers_of(bid(10)), vec![bid(1)]);
3533
3534        let desc_b = DiffDescriptor {
3535            sources: vec![
3536                Arc::new(StaticSource::new(Rope::from(""))),
3537                Arc::new(BufferSource::new(Arc::clone(&provider), bid(1))),
3538            ],
3539            watch: vec![bid(1)],
3540            participants: vec![],
3541        };
3542        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc_b);
3543        assert!(sub.watchers_of(bid(10)).is_empty());
3544        assert_eq!(sub.watchers_of(bid(1)), vec![bid(1)]);
3545    }
3546
3547    // ── Debouncer ─────────────────────────────────────────────
3548
3549    // Helper: under paused time, sleeping in the main task lets
3550    // the runtime auto-advance — when all tasks are idle, time
3551    // jumps to the next earliest deadline. So `sleep(60ms)`
3552    // here drives the debouncer's `sleep(50ms)` to completion
3553    // without wall-clock waits. The yield_now+advance pattern
3554    // used elsewhere is unreliable because a yield-spinning main
3555    // task isn't "idle" for auto-advance purposes.
3556
3557    #[tokio::test(start_paused = true)]
3558    async fn debouncer_single_poke_fires_runner_after_window() {
3559        let counter = Arc::new(AtomicU64::new(0));
3560        let window = Duration::from_millis(50);
3561        let deb = Debouncer::new(window);
3562        let c = Arc::clone(&counter);
3563        deb.poke(move || {
3564            c.fetch_add(1, Ordering::Relaxed);
3565        });
3566
3567        // Sleep just shy of the window: debouncer task is
3568        // still mid-sleep, not yet fired.
3569        tokio::time::sleep(Duration::from_millis(40)).await;
3570        assert_eq!(counter.load(Ordering::Relaxed), 0);
3571
3572        // Sleep past the window: debouncer's sleep completes,
3573        // runner fires once.
3574        tokio::time::sleep(Duration::from_millis(20)).await;
3575        assert_eq!(counter.load(Ordering::Relaxed), 1);
3576    }
3577
3578    #[tokio::test(start_paused = true)]
3579    async fn debouncer_rapid_pokes_coalesce_to_one_runner_invocation() {
3580        let counter = Arc::new(AtomicU64::new(0));
3581        let window = Duration::from_millis(50);
3582        let deb = Debouncer::new(window);
3583        let c = Arc::clone(&counter);
3584        let runner = move || {
3585            c.fetch_add(1, Ordering::Relaxed);
3586        };
3587
3588        // Five pokes spaced 10ms apart — each one within the
3589        // debounce window of the previous. Total elapsed = 50ms.
3590        for _ in 0..5 {
3591            let r = runner.clone();
3592            deb.poke(r);
3593            tokio::time::sleep(Duration::from_millis(10)).await;
3594        }
3595        // Sleep past the window from the last poke (elapsed
3596        // ~50ms when last poke fired; debouncer task started a
3597        // new 50ms sleep at t=50 → wakes at t=100). Sleep 80ms
3598        // to land safely past it.
3599        tokio::time::sleep(Duration::from_millis(80)).await;
3600        assert_eq!(counter.load(Ordering::Relaxed), 1);
3601    }
3602
3603    #[tokio::test(start_paused = true)]
3604    async fn debouncer_pokes_after_idle_fire_runner_twice() {
3605        let counter = Arc::new(AtomicU64::new(0));
3606        let window = Duration::from_millis(50);
3607        let deb = Debouncer::new(window);
3608        let c = Arc::clone(&counter);
3609        let runner = move || {
3610            c.fetch_add(1, Ordering::Relaxed);
3611        };
3612
3613        // First poke → fires after window.
3614        deb.poke(runner.clone());
3615        tokio::time::sleep(Duration::from_millis(80)).await;
3616        assert_eq!(counter.load(Ordering::Relaxed), 1);
3617
3618        // Idle gap, then second poke → fires again.
3619        tokio::time::sleep(Duration::from_millis(500)).await;
3620        deb.poke(runner);
3621        tokio::time::sleep(Duration::from_millis(80)).await;
3622        assert_eq!(counter.load(Ordering::Relaxed), 2);
3623    }
3624
3625    // ── Routing integration ───────────────────────────────────
3626
3627    // Routing / bus tests run with REAL tokio time: the
3628    // debounce → schedule_recompute path eventually calls
3629    // `tokio::task::spawn_blocking`, which runs on the blocking
3630    // thread pool (separate OS threads). The blocking pool
3631    // doesn't observe paused time, so wall-clock progress is
3632    // required to see published results. Debounce windows kept
3633    // to ~10ms so tests stay fast.
3634
3635    async fn wait_for_revision_above(
3636        session: &Arc<DiffSession>,
3637        threshold: u64,
3638        deadline: Duration,
3639    ) -> u64 {
3640        let start = std::time::Instant::now();
3641        loop {
3642            let rev = session.current_hunks().revision;
3643            if rev > threshold {
3644                return rev;
3645            }
3646            if start.elapsed() >= deadline {
3647                return rev;
3648            }
3649            tokio::time::sleep(Duration::from_millis(5)).await;
3650        }
3651    }
3652
3653    #[tokio::test]
3654    async fn note_buffer_edited_triggers_debounced_recompute() {
3655        let provider = Arc::new(MockProvider::default());
3656        provider.set(bid(2), Rope::from("alpha\n"));
3657        provider.set(bid(1), Rope::from("alpha\nbeta\n"));
3658        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
3659
3660        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
3661            10,
3662        )));
3663        let desc = descriptor(&dyn_provider, bid(2), bid(1));
3664        let session = sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3665
3666        // Edit the buffer (simulated by changing the provider's
3667        // rope) then fire the routing entry point.
3668        provider.set(bid(1), Rope::from("alpha\nBETA\n"));
3669        Arc::clone(&sub).note_buffer_edited(bid(1));
3670
3671        let rev = wait_for_revision_above(&session, 0, Duration::from_secs(2)).await;
3672        assert!(rev > 0, "session should have recomputed");
3673        let idx = session.current_hunks();
3674        assert_eq!(idx.len(), 1, "one hunk for the Change");
3675    }
3676
3677    #[tokio::test]
3678    async fn note_buffer_edited_with_no_session_is_noop() {
3679        // No session registered for bid(99); call must not
3680        // panic, must not spawn anything.
3681        let sub = Arc::new(DiffSubsystem::new());
3682        Arc::clone(&sub).note_buffer_edited(bid(99));
3683        tokio::time::sleep(Duration::from_millis(20)).await;
3684    }
3685
3686    #[tokio::test]
3687    async fn editing_watched_baseline_wakes_session() {
3688        // Session X watches its own buffer (bid 1) AND a
3689        // sibling buffer (bid 2 — the baseline). Editing the
3690        // baseline must wake X.
3691        let provider = Arc::new(MockProvider::default());
3692        provider.set(bid(2), Rope::from("alpha\n"));
3693        provider.set(bid(1), Rope::from("alpha\n"));
3694        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
3695
3696        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
3697            10,
3698        )));
3699        let desc = descriptor(&dyn_provider, bid(2), bid(1));
3700        let session = sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3701
3702        // Initially identical — recompute produces empty hunks.
3703        Arc::clone(&sub).note_buffer_edited(bid(1));
3704        let rev1 = wait_for_revision_above(&session, 0, Duration::from_secs(2)).await;
3705        assert!(rev1 > 0);
3706        assert!(session.current_hunks().is_empty());
3707
3708        // Now edit the *baseline* buffer (bid 2). Session should
3709        // recompute and see hunks.
3710        provider.set(bid(2), Rope::from("alpha\nBETA\n"));
3711        Arc::clone(&sub).note_buffer_edited(bid(2));
3712        let rev2 = wait_for_revision_above(&session, rev1, Duration::from_secs(2)).await;
3713        assert!(rev2 > rev1);
3714        assert_eq!(session.current_hunks().len(), 1);
3715    }
3716
3717    #[tokio::test]
3718    async fn note_buffer_closed_drops_session() {
3719        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3720        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
3721            10,
3722        )));
3723        let desc = descriptor(&provider, bid(2), bid(1));
3724        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3725        assert!(sub.lookup(bid(1)).is_some());
3726
3727        sub.note_buffer_closed(bid(1));
3728        assert!(sub.lookup(bid(1)).is_none());
3729        assert!(sub.lookup_descriptor(bid(1)).is_none());
3730    }
3731
3732    // ── Bus binding ───────────────────────────────────────────
3733
3734    fn doc_id(n: u64) -> DocumentId {
3735        DocumentId::new(n)
3736    }
3737
3738    #[tokio::test]
3739    async fn bind_routes_document_changed_to_debounced_recompute() {
3740        let provider = Arc::new(MockProvider::default());
3741        provider.set(bid(2), Rope::from("alpha\n"));
3742        provider.set(bid(1), Rope::from("alpha\nbeta\n"));
3743        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
3744
3745        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
3746            10,
3747        )));
3748        let desc = descriptor(&dyn_provider, bid(2), bid(1));
3749        let session = sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3750
3751        let bus = Arc::new(EventBus::new());
3752        let resolver = Arc::new(MockResolver::default());
3753        resolver.bind(doc_id(1001), bid(1));
3754        let resolver_dyn: Arc<dyn DocumentBufferResolver> = resolver;
3755
3756        let _guard = sub.bind(Arc::clone(&bus), resolver_dyn);
3757
3758        // Yield once to let the drainer task park on rx.recv().
3759        tokio::task::yield_now().await;
3760
3761        // Publish a DocumentChanged for the bound DocumentId.
3762        bus.publish(Event::DocumentChanged {
3763            id: doc_id(1001),
3764            path: None,
3765            version: 2,
3766            edits: Vec::new(),
3767        });
3768
3769        let rev = wait_for_revision_above(&session, 0, Duration::from_secs(2)).await;
3770        assert!(rev > 0, "session should have recomputed after bus event");
3771    }
3772
3773    #[tokio::test]
3774    async fn bind_routes_document_closed_to_drop_session() {
3775        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3776        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
3777            10,
3778        )));
3779        let desc = descriptor(&provider, bid(2), bid(1));
3780        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3781        assert!(sub.lookup(bid(1)).is_some());
3782
3783        let bus = Arc::new(EventBus::new());
3784        let resolver = Arc::new(MockResolver::default());
3785        resolver.bind(doc_id(1001), bid(1));
3786        let resolver_dyn: Arc<dyn DocumentBufferResolver> = resolver;
3787        let _guard = sub.bind(Arc::clone(&bus), resolver_dyn);
3788
3789        tokio::task::yield_now().await;
3790
3791        bus.publish(Event::DocumentClosed { id: doc_id(1001) });
3792
3793        // Wait for drainer to process the event.
3794        let deadline = std::time::Instant::now() + Duration::from_secs(2);
3795        while sub.lookup(bid(1)).is_some() && std::time::Instant::now() < deadline {
3796            tokio::time::sleep(Duration::from_millis(5)).await;
3797        }
3798        assert!(sub.lookup(bid(1)).is_none());
3799    }
3800
3801    // ── D.2.d introspection ───────────────────────────────────
3802
3803    #[test]
3804    fn describe_sessions_empty_when_no_sessions() {
3805        let sub = DiffSubsystem::new();
3806        assert!(sub.describe_sessions().is_empty());
3807        assert_eq!(
3808            sub.build_describe_diff_content(),
3809            "No active diff sessions.\n"
3810        );
3811    }
3812
3813    #[test]
3814    fn describe_sessions_lists_sessions_sorted_by_buffer_id() {
3815        // Register out-of-order to verify the sort.
3816        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3817        let sub = DiffSubsystem::new();
3818        sub.register_with_sources(
3819            bid(7),
3820            DiffAlgorithm::MyersMinimal,
3821            descriptor(&provider, bid(99), bid(7)),
3822        );
3823        sub.register_with_sources(
3824            bid(1),
3825            DiffAlgorithm::Histogram,
3826            descriptor(&provider, bid(2), bid(1)),
3827        );
3828        let rows = sub.describe_sessions();
3829        assert_eq!(rows.len(), 2);
3830        assert_eq!(rows[0].buffer_id, bid(1));
3831        assert_eq!(rows[0].algorithm, DiffAlgorithm::Histogram);
3832        assert_eq!(rows[0].watch, vec![bid(2), bid(1)]);
3833        assert_eq!(rows[1].buffer_id, bid(7));
3834        assert_eq!(rows[1].algorithm, DiffAlgorithm::MyersMinimal);
3835        assert_eq!(rows[1].watch, vec![bid(99), bid(7)]);
3836    }
3837
3838    #[test]
3839    fn describe_sessions_reflects_current_published_hunks() {
3840        let s = DiffSubsystem::new();
3841        let session = s.register(bid(1), DiffAlgorithm::Histogram);
3842        session.publish(Arc::new(HunkIndex {
3843            hunks: vec![],
3844            algorithm: DiffAlgorithm::Histogram,
3845            revision: 12,
3846        }));
3847        let rows = s.describe_sessions();
3848        assert_eq!(rows.len(), 1);
3849        assert_eq!(rows[0].revision, 12);
3850        assert_eq!(rows[0].hunk_count, 0);
3851    }
3852
3853    #[test]
3854    fn build_describe_diff_content_formats_columns() {
3855        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3856        let sub = DiffSubsystem::new();
3857        sub.register_with_sources(
3858            bid(1),
3859            DiffAlgorithm::Histogram,
3860            descriptor(&provider, bid(2), bid(1)),
3861        );
3862        let body = sub.build_describe_diff_content();
3863        assert!(body.starts_with("Active diff sessions: 1\n"));
3864        assert!(body.contains("BufferId  Algorithm     Rev  Hunks  Watches"));
3865        assert!(body.contains("--------  ------------  ---  -----  -------"));
3866        assert!(
3867            body.contains("Histogram"),
3868            "algorithm column missing: {body}"
3869        );
3870        assert!(body.contains("[2, 1]"), "watch column missing: {body}");
3871    }
3872
3873    #[test]
3874    fn describe_sessions_omits_watch_for_sources_less_register() {
3875        let s = DiffSubsystem::new();
3876        s.register(bid(1), DiffAlgorithm::Histogram);
3877        let rows = s.describe_sessions();
3878        assert_eq!(rows.len(), 1);
3879        assert!(
3880            rows[0].watch.is_empty(),
3881            "register() path has no descriptor → empty watch"
3882        );
3883    }
3884
3885    #[tokio::test]
3886    async fn dropping_guard_unsubscribes_bus_and_aborts_drainer() {
3887        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3888        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
3889            10,
3890        )));
3891        let desc = descriptor(&provider, bid(2), bid(1));
3892        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3893
3894        let bus = Arc::new(EventBus::new());
3895        let resolver = Arc::new(MockResolver::default());
3896        resolver.bind(doc_id(1001), bid(1));
3897        let resolver_dyn: Arc<dyn DocumentBufferResolver> = resolver;
3898        let guard = sub.bind(Arc::clone(&bus), resolver_dyn);
3899
3900        tokio::task::yield_now().await;
3901
3902        // Drop the guard — bus subscription should be cleaned.
3903        drop(guard);
3904        tokio::task::yield_now().await;
3905
3906        // Publish — the drainer is gone, so the session does
3907        // NOT receive the event.
3908        bus.publish(Event::DocumentClosed { id: doc_id(1001) });
3909        tokio::time::sleep(Duration::from_millis(100)).await;
3910        // Session should still be present — the guard's drop
3911        // prevented the drainer from acting on the event.
3912        assert!(sub.lookup(bid(1)).is_some());
3913    }
3914
3915    // ── D.5.b: compute_get_edit ────────────────────────────────
3916
3917    use crate::Hunk;
3918    use smallvec::smallvec;
3919
3920    /// Build a session with a [`StaticSource`] for testing
3921    /// `compute_get_edit` without exercising the buffer-backed
3922    /// machinery. Returns the subsystem so the caller can
3923    /// publish hunks and query.
3924    fn fixture_with_baseline(baseline: &str) -> (DiffSubsystem, BufferId) {
3925        let sub = DiffSubsystem::new();
3926        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
3927        // Current side is buffer-backed but never read by
3928        // compute_get_edit — the function reads the baseline
3929        // only. The current source is required for descriptor
3930        // construction.
3931        let desc = DiffDescriptor {
3932            sources: vec![
3933                Arc::new(StaticSource::new(Rope::from(baseline))),
3934                Arc::new(BufferSource::new(Arc::clone(&provider), bid(1))),
3935            ],
3936            watch: vec![bid(1)],
3937            participants: vec![bid(1)],
3938        };
3939        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
3940        (sub, bid(1))
3941    }
3942
3943    fn publish_hunks(sub: &DiffSubsystem, key: BufferId, hunks: Vec<Hunk>) {
3944        let session = sub.lookup(key).unwrap();
3945        let rev = session.allocate_revision();
3946        session.publish(Arc::new(HunkIndex {
3947            hunks,
3948            algorithm: DiffAlgorithm::Histogram,
3949            revision: rev,
3950        }));
3951    }
3952
3953    fn lr(start: u32, end: u32) -> LineRange {
3954        LineRange::new(start, end)
3955    }
3956
3957    /// `Change` on the current side: `do` replaces the
3958    /// current lines with the baseline slice for the
3959    /// corresponding baseline range.
3960    #[test]
3961    fn compute_get_edit_change_replaces_current_range_with_baseline() {
3962        let (sub, key) = fixture_with_baseline("base-a\nbase-b\n");
3963        publish_hunks(
3964            &sub,
3965            key,
3966            vec![Hunk {
3967                kind: HunkKind::Change,
3968                ranges: smallvec![lr(0, 2), lr(3, 5)],
3969                refine: Default::default(),
3970            }],
3971        );
3972        let plan = sub
3973            .compute_get_edit(key, 3, None)
3974            .into_plan()
3975            .expect("hunk covers row 3");
3976        assert_eq!(plan.post_cursor_row, 3);
3977        // Edit: replace current range (lines 3..5) with the
3978        // baseline lines 0..2 → "base-a\nbase-b\n".
3979        assert_eq!(plan.edit.range.start.line, 3);
3980        assert_eq!(plan.edit.range.end.line, 5);
3981        match plan.edit.kind {
3982            lattice_protocol::edit::EditKind::Replace { ref text } => {
3983                assert_eq!(text, "base-a\nbase-b\n");
3984            }
3985        }
3986    }
3987
3988    /// CR.1: `diff_get_effect` wraps a covering `compute_get_edit`
3989    /// outcome into `Effect::ApplyEdit` targeting the ACTIVE buffer
3990    /// (diff-get rewrites the cursor's side) with the post-edit cursor
3991    /// row; the carried edit equals the `compute_get_edit` plan's edit.
3992    #[test]
3993    fn diff_get_effect_wraps_edit_for_active_buffer() {
3994        let (sub, key) = fixture_with_baseline("base-a\nbase-b\n");
3995        publish_hunks(
3996            &sub,
3997            key,
3998            vec![Hunk {
3999                kind: HunkKind::Change,
4000                ranges: smallvec![lr(0, 2), lr(3, 5)],
4001                refine: Default::default(),
4002            }],
4003        );
4004        let plan_edit = sub.compute_get_edit(key, 3, None).into_plan().unwrap().edit;
4005        match sub.diff_get_effect(key, 3, None) {
4006            Some(lattice_grammar::Effect::ApplyEdit {
4007                target,
4008                edit,
4009                cursor,
4010            }) => {
4011                assert_eq!(target, key, "diff-get edits the active (cursor) buffer");
4012                assert_eq!(
4013                    cursor,
4014                    Some(lattice_protocol::position::Position::new(3, 0))
4015                );
4016                assert_eq!(edit, plan_edit);
4017            }
4018            other => panic!("expected ApplyEdit, got {other:?}"),
4019        }
4020    }
4021
4022    /// CR.1: no covering hunk → `diff_get_effect` is `None` (silent
4023    /// no-op), mirroring `compute_get_edit`'s `Nothing`.
4024    #[test]
4025    fn diff_get_effect_none_when_no_hunk() {
4026        let (sub, key) = fixture_with_baseline("base-a\n");
4027        assert!(sub.diff_get_effect(key, 0, None).is_none());
4028    }
4029
4030    /// CR.1: `diff_put_effect` against an inline baseline (the baseline
4031    /// side is a `StaticSource`, not a live buffer) yields the error
4032    /// `Echo` — preserving the pre-CR.1 `do_diff_put` wording verbatim —
4033    /// not an `ApplyEdit`.
4034    #[test]
4035    fn diff_put_effect_inline_baseline_yields_error_echo() {
4036        let (sub, key) = fixture_with_baseline("base-a\nbase-b\n");
4037        publish_hunks(
4038            &sub,
4039            key,
4040            vec![Hunk {
4041                kind: HunkKind::Change,
4042                ranges: smallvec![lr(0, 2), lr(3, 5)],
4043                refine: Default::default(),
4044            }],
4045        );
4046        match sub.diff_put_effect(key, 3, None) {
4047            Some(lattice_grammar::Effect::Echo { text, .. }) => {
4048                assert_eq!(text, "dp: baseline is not a buffer; use :write");
4049            }
4050            other => panic!("expected an error Echo, got {other:?}"),
4051        }
4052    }
4053
4054    /// `Add` hunk: lines appear in current side only;
4055    /// baseline range is empty. `do` deletes the current
4056    /// lines (revert the addition; baseline says no content
4057    /// here).
4058    #[test]
4059    fn compute_get_edit_add_deletes_current_range() {
4060        let (sub, key) = fixture_with_baseline("");
4061        publish_hunks(
4062            &sub,
4063            key,
4064            vec![Hunk {
4065                kind: HunkKind::Add,
4066                ranges: smallvec![lr(0, 0), lr(2, 4)],
4067                refine: Default::default(),
4068            }],
4069        );
4070        let plan = sub
4071            .compute_get_edit(key, 3, None)
4072            .into_plan()
4073            .expect("hunk covers row 3");
4074        // Cursor parks at the hunk start (row 2).
4075        assert_eq!(plan.post_cursor_row, 2);
4076        assert_eq!(plan.edit.range.start.line, 2);
4077        assert_eq!(plan.edit.range.end.line, 4);
4078        match plan.edit.kind {
4079            lattice_protocol::edit::EditKind::Replace { ref text } => {
4080                assert!(
4081                    text.is_empty(),
4082                    "Add → delete: text must be empty, was {text:?}"
4083                );
4084            }
4085        }
4086    }
4087
4088    /// `Remove` hunk: lines appear in baseline only;
4089    /// current range is empty. `do` inserts the baseline
4090    /// lines at the deletion anchor (revert the removal).
4091    #[test]
4092    fn compute_get_edit_remove_inserts_baseline_text_at_gap() {
4093        let (sub, key) = fixture_with_baseline("removed-1\nremoved-2\n");
4094        publish_hunks(
4095            &sub,
4096            key,
4097            vec![Hunk {
4098                kind: HunkKind::Remove,
4099                ranges: smallvec![lr(0, 2), lr(5, 5)],
4100                refine: Default::default(),
4101            }],
4102        );
4103        // Cursor must sit exactly at the empty-current anchor
4104        // (row 5) for the Remove lookup to match — vim parity.
4105        let plan = sub
4106            .compute_get_edit(key, 5, None)
4107            .into_plan()
4108            .expect("Remove hunk anchored at row 5 must match");
4109        assert_eq!(plan.post_cursor_row, 5);
4110        // Edit: insert at line 5 (empty range), text is
4111        // the baseline's "removed-1\nremoved-2\n".
4112        assert_eq!(plan.edit.range.start.line, 5);
4113        assert_eq!(plan.edit.range.end.line, 5);
4114        match plan.edit.kind {
4115            lattice_protocol::edit::EditKind::Replace { ref text } => {
4116                assert_eq!(text, "removed-1\nremoved-2\n");
4117            }
4118        }
4119    }
4120
4121    /// `Remove` hunks anchor at exactly `current.start`; a
4122    /// cursor one row off is a miss. (No "near enough"
4123    /// matching — vim's `do` only fires when the cursor
4124    /// sits on the deletion-marker row.)
4125    #[test]
4126    fn compute_get_edit_remove_misses_when_cursor_off_anchor() {
4127        let (sub, key) = fixture_with_baseline("x\n");
4128        publish_hunks(
4129            &sub,
4130            key,
4131            vec![Hunk {
4132                kind: HunkKind::Remove,
4133                ranges: smallvec![lr(0, 1), lr(5, 5)],
4134                refine: Default::default(),
4135            }],
4136        );
4137        assert!(sub.compute_get_edit(key, 4, None).is_nothing());
4138        assert!(sub.compute_get_edit(key, 6, None).is_nothing());
4139    }
4140
4141    /// Cursor outside every hunk returns `None`.
4142    #[test]
4143    fn compute_get_edit_cursor_outside_hunks_returns_none() {
4144        let (sub, key) = fixture_with_baseline("a\nb\n");
4145        publish_hunks(
4146            &sub,
4147            key,
4148            vec![Hunk {
4149                kind: HunkKind::Change,
4150                ranges: smallvec![lr(0, 2), lr(10, 12)],
4151                refine: Default::default(),
4152            }],
4153        );
4154        assert!(sub.compute_get_edit(key, 0, None).is_nothing());
4155        assert!(sub.compute_get_edit(key, 9, None).is_nothing());
4156        // End is exclusive — row 12 is past the hunk.
4157        assert!(sub.compute_get_edit(key, 12, None).is_nothing());
4158    }
4159
4160    /// Three-way `Conflict` hunks are skipped — D.6 owns the
4161    /// conflict-resolution path; `do` is two-way only.
4162    #[test]
4163    fn compute_get_edit_conflict_hunk_is_ignored() {
4164        let (sub, key) = fixture_with_baseline("base\n");
4165        publish_hunks(
4166            &sub,
4167            key,
4168            vec![Hunk {
4169                kind: HunkKind::Conflict,
4170                ranges: smallvec![lr(0, 1), lr(0, 1)],
4171                refine: Default::default(),
4172            }],
4173        );
4174        assert!(sub.compute_get_edit(key, 0, None).is_nothing());
4175    }
4176
4177    /// No session registered → no-op `None`. The keymap
4178    /// layer is global so the chord can fire on any buffer,
4179    /// but per-buffer K.1.c gating prevents that — if a test
4180    /// bypasses K.1.c and the action runs, it must still
4181    /// degrade cleanly.
4182    #[test]
4183    fn compute_get_edit_no_session_returns_none() {
4184        let sub = DiffSubsystem::new();
4185        assert!(sub.compute_get_edit(bid(42), 0, None).is_nothing());
4186    }
4187
4188    /// Session registered without a descriptor (the
4189    /// sources-less `register` path used in some tests) →
4190    /// no baseline to read from, returns `None` gracefully.
4191    #[test]
4192    fn compute_get_edit_no_descriptor_returns_none() {
4193        let sub = DiffSubsystem::new();
4194        // `register` is the sources-less path used by some
4195        // test fixtures; it stores no descriptor.
4196        sub.register(bid(1), DiffAlgorithm::Histogram);
4197        // Publish a Change hunk; cursor on it would normally
4198        // match — but the missing descriptor causes the
4199        // lookup to short-circuit to `None`.
4200        publish_hunks(
4201            &sub,
4202            bid(1),
4203            vec![Hunk {
4204                kind: HunkKind::Change,
4205                ranges: smallvec![lr(0, 1), lr(0, 1)],
4206                refine: Default::default(),
4207            }],
4208        );
4209        assert!(sub.compute_get_edit(bid(1), 0, None).is_nothing());
4210    }
4211
4212    // ── D.5.c: compute_put_plan ────────────────────────────────
4213
4214    /// Build a two-pane session fixture: baseline + current
4215    /// are both buffer-backed via a shared `MockProvider`
4216    /// with the supplied ropes. `participants =
4217    /// [baseline_bid, current_bid]`, so `compute_put_plan`
4218    /// resolves the peer to `baseline_bid`. Returns the
4219    /// subsystem + the session key.
4220    fn fixture_two_pane(
4221        baseline_text: &str,
4222        current_text: &str,
4223    ) -> (DiffSubsystem, BufferId, BufferId) {
4224        let sub = DiffSubsystem::new();
4225        let provider = Arc::new(MockProvider::default());
4226        let baseline_bid = bid(100);
4227        let current_bid = bid(200);
4228        provider.set(baseline_bid, Rope::from(baseline_text));
4229        provider.set(current_bid, Rope::from(current_text));
4230        let provider_dyn: Arc<dyn BufferTextProvider> = provider;
4231        let desc = DiffDescriptor {
4232            sources: vec![
4233                Arc::new(BufferSource::new(Arc::clone(&provider_dyn), baseline_bid)),
4234                Arc::new(BufferSource::new(Arc::clone(&provider_dyn), current_bid)),
4235            ],
4236            watch: vec![baseline_bid, current_bid],
4237            participants: vec![baseline_bid, current_bid],
4238        };
4239        sub.register_with_sources(current_bid, DiffAlgorithm::Histogram, desc);
4240        (sub, current_bid, baseline_bid)
4241    }
4242
4243    /// `Change` on the current side: `dp` replaces the
4244    /// peer's baseline lines with the current-side slice.
4245    #[test]
4246    fn compute_put_plan_change_pushes_current_into_peer() {
4247        let (sub, current, peer) = fixture_two_pane("base-a\nbase-b\n", "live-a\nlive-b\n");
4248        publish_hunks(
4249            &sub,
4250            current,
4251            vec![Hunk {
4252                kind: HunkKind::Change,
4253                ranges: smallvec![lr(0, 2), lr(0, 2)],
4254                refine: Default::default(),
4255            }],
4256        );
4257        let outcome = sub.compute_put_plan(current, 0, None);
4258        match outcome {
4259            DiffPutOutcome::Edit {
4260                target_buffer_id,
4261                edit,
4262                post_cursor_row,
4263            } => {
4264                assert_eq!(target_buffer_id, peer);
4265                assert_eq!(post_cursor_row, 0);
4266                assert_eq!(edit.range.start.line, 0);
4267                assert_eq!(edit.range.end.line, 2);
4268                match edit.kind {
4269                    lattice_protocol::edit::EditKind::Replace { ref text } => {
4270                        assert_eq!(text, "live-a\nlive-b\n");
4271                    }
4272                }
4273            }
4274            other => panic!("expected Edit, got {other:?}"),
4275        }
4276    }
4277
4278    /// `Add` hunk (current has extra lines; baseline range
4279    /// empty): `dp` *inserts* those lines into the peer at
4280    /// `baseline.start` (empty-range replace == insertion).
4281    #[test]
4282    fn compute_put_plan_add_inserts_into_peer_at_baseline_anchor() {
4283        let (sub, current, peer) = fixture_two_pane("", "added\n");
4284        publish_hunks(
4285            &sub,
4286            current,
4287            vec![Hunk {
4288                kind: HunkKind::Add,
4289                ranges: smallvec![lr(0, 0), lr(0, 1)],
4290                refine: Default::default(),
4291            }],
4292        );
4293        let outcome = sub.compute_put_plan(current, 0, None);
4294        match outcome {
4295            DiffPutOutcome::Edit {
4296                target_buffer_id,
4297                edit,
4298                ..
4299            } => {
4300                assert_eq!(target_buffer_id, peer);
4301                // Empty baseline range → insertion point.
4302                assert_eq!(edit.range.start.line, 0);
4303                assert_eq!(edit.range.end.line, 0);
4304                match edit.kind {
4305                    lattice_protocol::edit::EditKind::Replace { ref text } => {
4306                        assert_eq!(text, "added\n");
4307                    }
4308                }
4309            }
4310            other => panic!("expected Edit, got {other:?}"),
4311        }
4312    }
4313
4314    /// `Remove` hunk (current empty, baseline has lines):
4315    /// `dp` deletes the peer's lines (push the empty
4316    /// current-side state into the peer).
4317    #[test]
4318    fn compute_put_plan_remove_deletes_peer_range() {
4319        let (sub, current, peer) = fixture_two_pane("removed-1\nremoved-2\n", "");
4320        publish_hunks(
4321            &sub,
4322            current,
4323            vec![Hunk {
4324                kind: HunkKind::Remove,
4325                ranges: smallvec![lr(0, 2), lr(0, 0)],
4326                refine: Default::default(),
4327            }],
4328        );
4329        let outcome = sub.compute_put_plan(current, 0, None);
4330        match outcome {
4331            DiffPutOutcome::Edit {
4332                target_buffer_id,
4333                edit,
4334                ..
4335            } => {
4336                assert_eq!(target_buffer_id, peer);
4337                assert_eq!(edit.range.start.line, 0);
4338                assert_eq!(edit.range.end.line, 2);
4339                match edit.kind {
4340                    lattice_protocol::edit::EditKind::Replace { ref text } => {
4341                        assert!(
4342                            text.is_empty(),
4343                            "Remove `dp` deletes peer range; text was {text:?}"
4344                        );
4345                    }
4346                }
4347            }
4348            other => panic!("expected Edit, got {other:?}"),
4349        }
4350    }
4351
4352    /// Inline (single-participant) session has no peer
4353    /// buffer — `dp` returns `NoPeerBuffer` so dispatch can
4354    /// surface the clear error. Single-participant is the
4355    /// shape that `:diff` (file-on-disk baseline) and
4356    /// future `:Gdiff` produce.
4357    #[test]
4358    fn compute_put_plan_inline_session_returns_no_peer_buffer() {
4359        let (sub, key) = fixture_with_baseline("base\n");
4360        // `fixture_with_baseline` creates a single-participant
4361        // inline descriptor.
4362        publish_hunks(
4363            &sub,
4364            key,
4365            vec![Hunk {
4366                kind: HunkKind::Change,
4367                ranges: smallvec![lr(0, 1), lr(0, 1)],
4368                refine: Default::default(),
4369            }],
4370        );
4371        assert_eq!(
4372            sub.compute_put_plan(key, 0, None),
4373            DiffPutOutcome::NoPeerBuffer
4374        );
4375    }
4376
4377    /// No session → `Nothing` (silent no-op shape). The
4378    /// per-buffer K.1.c gate suppresses `dp` on non-diff
4379    /// buffers; this is the defensive belt-and-braces case.
4380    #[test]
4381    fn compute_put_plan_no_session_returns_nothing() {
4382        let sub = DiffSubsystem::new();
4383        assert_eq!(
4384            sub.compute_put_plan(bid(99), 0, None),
4385            DiffPutOutcome::Nothing
4386        );
4387    }
4388
4389    /// Cursor outside every hunk → `Nothing`.
4390    #[test]
4391    fn compute_put_plan_cursor_outside_hunks_returns_nothing() {
4392        let (sub, current, _peer) = fixture_two_pane("a\n", "b\n");
4393        publish_hunks(
4394            &sub,
4395            current,
4396            vec![Hunk {
4397                kind: HunkKind::Change,
4398                ranges: smallvec![lr(0, 1), lr(0, 1)],
4399                refine: Default::default(),
4400            }],
4401        );
4402        assert_eq!(
4403            sub.compute_put_plan(current, 5, None),
4404            DiffPutOutcome::Nothing
4405        );
4406    }
4407
4408    /// Three-way `Conflict` hunks are skipped (D.6 owns
4409    /// `:diffput <bufnr>` with the disambiguating arg).
4410    #[test]
4411    fn compute_put_plan_conflict_hunk_is_skipped() {
4412        let (sub, current, _peer) = fixture_two_pane("a\n", "a\n");
4413        publish_hunks(
4414            &sub,
4415            current,
4416            vec![Hunk {
4417                kind: HunkKind::Conflict,
4418                ranges: smallvec![lr(0, 1), lr(0, 1)],
4419                refine: Default::default(),
4420            }],
4421        );
4422        assert_eq!(
4423            sub.compute_put_plan(current, 0, None),
4424            DiffPutOutcome::Nothing
4425        );
4426    }
4427
4428    /// First hunk wins when several hunks coexist — the
4429    /// search is linear over the published list. The
4430    /// algorithm doesn't sort, but published HunkIndex
4431    /// preserves the diff order which is non-overlapping +
4432    /// monotonic per `imara-diff`, so the first match is
4433    /// also the only match.
4434    #[test]
4435    fn compute_get_edit_finds_hunk_among_many() {
4436        let (sub, key) = fixture_with_baseline("first-hunk\nsecond-hunk\nthird-hunk\n");
4437        publish_hunks(
4438            &sub,
4439            key,
4440            vec![
4441                Hunk {
4442                    kind: HunkKind::Change,
4443                    ranges: smallvec![lr(0, 1), lr(0, 1)],
4444                    refine: Default::default(),
4445                },
4446                Hunk {
4447                    kind: HunkKind::Change,
4448                    ranges: smallvec![lr(1, 2), lr(5, 6)],
4449                    refine: Default::default(),
4450                },
4451                Hunk {
4452                    kind: HunkKind::Change,
4453                    ranges: smallvec![lr(2, 3), lr(10, 11)],
4454                    refine: Default::default(),
4455                },
4456            ],
4457        );
4458        let plan = sub
4459            .compute_get_edit(key, 5, None)
4460            .into_plan()
4461            .expect("second hunk covers 5");
4462        match plan.edit.kind {
4463            lattice_protocol::edit::EditKind::Replace { ref text } => {
4464                assert_eq!(text, "second-hunk\n");
4465            }
4466        }
4467        assert_eq!(plan.post_cursor_row, 5);
4468    }
4469
4470    // ──────────────────────────────────────────────────────────
4471    // D.6.a (2026-05-30): three-way merge lifecycle
4472    // ──────────────────────────────────────────────────────────
4473
4474    /// Three sources, non-overlapping changes (local mutates one
4475    /// region, remote mutates a disjoint region) — engine emits
4476    /// two non-conflict hunks with three ranges each.
4477    #[test]
4478    fn three_way_non_overlapping_changes_produce_no_conflict_hunks() {
4479        let provider = Arc::new(MockProvider::default());
4480        provider.set(bid(1), Rope::from("aaa\nbbb\nccc\nddd\neee\n"));
4481        provider.set(bid(2), Rope::from("aaa\nBBB\nccc\nddd\neee\n"));
4482        provider.set(bid(3), Rope::from("aaa\nbbb\nccc\nddd\nEEE\n"));
4483        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
4484
4485        let sub = DiffSubsystem::new();
4486        let desc = three_way_descriptor(&dyn_provider, bid(1), bid(2), bid(3));
4487        let session = sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4488
4489        let base = provider.buffer_rope(bid(1)).unwrap();
4490        let local = provider.buffer_rope(bid(2)).unwrap();
4491        let remote = provider.buffer_rope(bid(3)).unwrap();
4492        let idx = session
4493            .recompute_blocking(&[base.clone(), local.clone(), remote.clone()])
4494            .expect("three-way recompute publishes");
4495
4496        assert!(
4497            idx.hunks
4498                .iter()
4499                .all(|h| !matches!(h.kind, HunkKind::Conflict)),
4500            "disjoint edits must not conflict; got {:?}",
4501            idx.hunks
4502        );
4503        assert_eq!(idx.hunks.len(), 2, "one hunk per side");
4504        // All three ranges per hunk — `[base, local, remote]`.
4505        for h in &idx.hunks {
4506            assert_eq!(h.ranges.len(), 3, "three-way hunks carry 3 ranges");
4507        }
4508    }
4509
4510    /// Three sources, overlapping changes (local and remote both
4511    /// mutate the same base region) — engine emits a Conflict
4512    /// hunk.
4513    #[test]
4514    fn three_way_overlapping_changes_produce_conflict_hunk() {
4515        let provider = Arc::new(MockProvider::default());
4516        provider.set(bid(1), Rope::from("aaa\nbbb\nccc\n"));
4517        provider.set(bid(2), Rope::from("aaa\nBBB-local\nccc\n"));
4518        provider.set(bid(3), Rope::from("aaa\nBBB-remote\nccc\n"));
4519        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
4520
4521        let sub = DiffSubsystem::new();
4522        let desc = three_way_descriptor(&dyn_provider, bid(1), bid(2), bid(3));
4523        let session = sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4524
4525        let base = provider.buffer_rope(bid(1)).unwrap();
4526        let local = provider.buffer_rope(bid(2)).unwrap();
4527        let remote = provider.buffer_rope(bid(3)).unwrap();
4528        let idx = session
4529            .recompute_blocking(&[base.clone(), local.clone(), remote.clone()])
4530            .expect("three-way recompute publishes");
4531
4532        assert!(
4533            idx.hunks
4534                .iter()
4535                .any(|h| matches!(h.kind, HunkKind::Conflict)),
4536            "overlapping edits must surface at least one Conflict; got {:?}",
4537            idx.hunks
4538        );
4539    }
4540
4541    /// `arity()` discriminates on the participant count —
4542    /// load-bearing for the D.6.c compute_get_plan /
4543    /// compute_put_plan dispatch (D.8.c rename from
4544    /// `is_three_way()`).
4545    #[test]
4546    fn arity_reflects_participant_count() {
4547        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4548        let two_way = descriptor(&provider, bid(1), bid(2));
4549        assert_eq!(two_way.arity(), 2);
4550        let three_way = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4551        assert_eq!(three_way.arity(), 3);
4552    }
4553
4554    // ──────────────────────────────────────────────────────
4555    // D.8.d (2026-05-31): membership API
4556    // ──────────────────────────────────────────────────────
4557
4558    /// `add_participant` on a 2-pane session grows the arity
4559    /// to 3, mutates the descriptor's sources / watch /
4560    /// participants lists, and routes a recompute through
4561    /// the debouncer.
4562    #[tokio::test]
4563    async fn add_participant_grows_arity_2_to_3() {
4564        let provider = Arc::new(MockProvider::default());
4565        provider.set(bid(1), Rope::from("a\n"));
4566        provider.set(bid(2), Rope::from("b\n"));
4567        provider.set(bid(3), Rope::from("c\n"));
4568        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
4569
4570        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
4571            1,
4572        )));
4573        let desc = descriptor(&dyn_provider, bid(1), bid(2));
4574        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4575
4576        // Add a third participant.
4577        let new_source: Arc<dyn DiffParticipantSource> =
4578            Arc::new(BufferSource::new(Arc::clone(&dyn_provider), bid(3)));
4579        let new_arity = sub
4580            .add_participant(bid(2), new_source, Some(bid(3)))
4581            .expect("add must succeed");
4582        assert_eq!(new_arity, 3);
4583
4584        // Descriptor now has 3 sources + bid(3) in
4585        // watch + participants.
4586        let updated = sub.lookup_descriptor(bid(2)).expect("descriptor present");
4587        assert_eq!(updated.arity(), 3);
4588        assert!(updated.watch.contains(&bid(3)));
4589        assert!(updated.participants.contains(&bid(3)));
4590    }
4591
4592    /// `add_participant` that would push arity to 4 returns
4593    /// `EngineRejected` and **does not mutate** the
4594    /// descriptor (atomic-failure invariant).
4595    #[test]
4596    fn add_participant_fourth_returns_engine_rejected_and_no_mutation() {
4597        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4598        let sub = Arc::new(DiffSubsystem::new());
4599        let desc = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4600        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4601
4602        let result = sub.add_participant(
4603            bid(2),
4604            Arc::new(StaticSource::new(Rope::new())),
4605            Some(bid(4)),
4606        );
4607        assert!(matches!(
4608            result,
4609            Err(MembershipError::EngineRejected(
4610                crate::DiffEngineError::Unsupported { n: 4 }
4611            ))
4612        ));
4613
4614        // Descriptor unchanged — arity still 3, no bid(4)
4615        // in watch/participants.
4616        let unchanged = sub.lookup_descriptor(bid(2)).expect("session intact");
4617        assert_eq!(unchanged.arity(), 3);
4618        assert!(!unchanged.watch.contains(&bid(4)));
4619        assert!(!unchanged.participants.contains(&bid(4)));
4620    }
4621
4622    /// `add_participant` on a missing session returns
4623    /// `NoSession`.
4624    #[test]
4625    fn add_participant_no_session_returns_no_session_error() {
4626        let sub = Arc::new(DiffSubsystem::new());
4627        let result = sub.add_participant(bid(99), Arc::new(StaticSource::new(Rope::new())), None);
4628        assert!(matches!(result, Err(MembershipError::NoSession(b)) if b == bid(99)));
4629    }
4630
4631    /// `remove_participant_buffer` on a 3-pane session
4632    /// shrinks to 2-pane (still active, recompute fires
4633    /// with the smaller participant set).
4634    #[tokio::test]
4635    async fn remove_participant_buffer_3_to_2_keeps_session_active() {
4636        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4637        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
4638            1,
4639        )));
4640        let desc = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4641        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4642
4643        let new_arity = sub
4644            .remove_participant_buffer(bid(2), bid(3))
4645            .expect("remove must succeed");
4646        assert_eq!(new_arity, 2);
4647
4648        // Session still registered; descriptor narrowed.
4649        let session = sub.lookup(bid(2)).expect("session alive");
4650        assert_eq!(session.buffer_id(), bid(2));
4651        let updated = sub.lookup_descriptor(bid(2)).expect("descriptor present");
4652        assert_eq!(updated.arity(), 2);
4653        assert!(!updated.watch.contains(&bid(3)));
4654        assert!(!updated.participants.contains(&bid(3)));
4655    }
4656
4657    /// `remove_participant_buffer` that drops arity to 1
4658    /// leaves the session **dormant** — registered but
4659    /// no peer to diff against.
4660    #[tokio::test]
4661    async fn remove_participant_buffer_to_1_leaves_session_dormant() {
4662        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4663        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
4664            1,
4665        )));
4666        let desc = descriptor(&provider, bid(1), bid(2));
4667        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4668
4669        let new_arity = sub
4670            .remove_participant_buffer(bid(2), bid(1))
4671            .expect("remove must succeed");
4672        assert_eq!(new_arity, 1);
4673
4674        // Session stays registered.
4675        assert!(sub.lookup(bid(2)).is_some());
4676        let updated = sub.lookup_descriptor(bid(2)).expect("descriptor present");
4677        assert_eq!(updated.arity(), 1);
4678    }
4679
4680    /// `remove_participant_buffer` that drops arity to 0
4681    /// **auto-drops** the session entirely.
4682    #[tokio::test]
4683    async fn remove_participant_buffer_to_0_auto_drops_session() {
4684        let provider = Arc::new(MockProvider::default());
4685        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
4686        let sub = Arc::new(DiffSubsystem::new());
4687
4688        // Build a 1-participant dormant session by
4689        // registering with sources = [BufferSource(bid(1))]
4690        // only.
4691        let desc = DiffDescriptor {
4692            sources: vec![Arc::new(BufferSource::new(
4693                Arc::clone(&dyn_provider),
4694                bid(1),
4695            ))],
4696            watch: vec![bid(1)],
4697            participants: vec![bid(1)],
4698        };
4699        sub.register_with_sources(bid(1), DiffAlgorithm::Histogram, desc);
4700        assert!(sub.lookup(bid(1)).is_some(), "dormant session registered");
4701
4702        let new_arity = sub
4703            .remove_participant_buffer(bid(1), bid(1))
4704            .expect("remove must succeed");
4705        assert_eq!(new_arity, 0);
4706        assert!(sub.lookup(bid(1)).is_none(), "session auto-dropped");
4707    }
4708
4709    /// `remove_participant_buffer` for a buffer that isn't a
4710    /// participant returns `NotParticipant`.
4711    #[test]
4712    fn remove_participant_buffer_not_a_participant_errors() {
4713        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4714        let sub = Arc::new(DiffSubsystem::new());
4715        let desc = descriptor(&provider, bid(1), bid(2));
4716        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4717
4718        let result = sub.remove_participant_buffer(bid(2), bid(99));
4719        assert!(matches!(
4720            result,
4721            Err(MembershipError::NotParticipant(b)) if b == bid(99)
4722        ));
4723    }
4724
4725    /// `remove_participant_buffer` decrements the mode
4726    /// bridge's refcount on the removed buffer.
4727    #[tokio::test]
4728    async fn remove_participant_buffer_decrements_mode_bridge_refcount() {
4729        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4730        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
4731            1,
4732        )));
4733        let desc = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4734        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4735        let bridge = sub.mode_bridge();
4736        assert_eq!(bridge.refcount(bid(3)), 1, "3-way activates bid(3)");
4737
4738        sub.remove_participant_buffer(bid(2), bid(3))
4739            .expect("remove succeeds");
4740        assert_eq!(
4741            bridge.refcount(bid(3)),
4742            0,
4743            "bid(3) refcount drops to zero after removal"
4744        );
4745        // Other participants still active.
4746        assert_eq!(bridge.refcount(bid(1)), 1);
4747        assert_eq!(bridge.refcount(bid(2)), 1);
4748    }
4749
4750    /// `add_participant` increments the mode bridge's
4751    /// refcount on the new buffer.
4752    #[tokio::test]
4753    async fn add_participant_increments_mode_bridge_refcount() {
4754        let provider = Arc::new(MockProvider::default());
4755        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
4756        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
4757            1,
4758        )));
4759        let desc = descriptor(&dyn_provider, bid(1), bid(2));
4760        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4761        let bridge = sub.mode_bridge();
4762        assert_eq!(bridge.refcount(bid(3)), 0, "bid(3) not yet a participant");
4763
4764        sub.add_participant(
4765            bid(2),
4766            Arc::new(BufferSource::new(Arc::clone(&dyn_provider), bid(3))),
4767            Some(bid(3)),
4768        )
4769        .expect("add succeeds");
4770        assert_eq!(
4771            bridge.refcount(bid(3)),
4772            1,
4773            "bid(3) refcount activates on add"
4774        );
4775    }
4776
4777    /// `replace_descriptor` swaps the source list while
4778    /// preserving session identity. The `Arc<DiffSession>`
4779    /// is the same after replace; only the descriptor's
4780    /// contents change.
4781    #[tokio::test]
4782    async fn replace_descriptor_preserves_session_identity() {
4783        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4784        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
4785            1,
4786        )));
4787        let desc_a = descriptor(&provider, bid(1), bid(2));
4788        let session_a = sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc_a);
4789        let before_ptr = Arc::as_ptr(&session_a);
4790
4791        // Replace with a three-way descriptor.
4792        let desc_b = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4793        sub.replace_descriptor(bid(2), desc_b)
4794            .expect("replace succeeds");
4795
4796        // Session Arc identity preserved.
4797        let session_b = sub.lookup(bid(2)).expect("session alive");
4798        assert_eq!(Arc::as_ptr(&session_b), before_ptr);
4799        // Descriptor's arity reflects the new shape.
4800        assert_eq!(
4801            sub.lookup_descriptor(bid(2)).unwrap().arity(),
4802            3,
4803            "new descriptor is three-way"
4804        );
4805    }
4806
4807    /// `replace_descriptor` rejects a new descriptor with
4808    /// N≥4 atomically — the existing descriptor is not
4809    /// mutated.
4810    #[test]
4811    fn replace_descriptor_rejects_n4_atomically() {
4812        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4813        let sub = Arc::new(DiffSubsystem::new());
4814        let desc_3 = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4815        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc_3);
4816
4817        // Build an N=4 descriptor by extending sources.
4818        let mut bad = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4819        bad.sources.push(Arc::new(StaticSource::new(Rope::new())));
4820        bad.watch.push(bid(4));
4821        bad.participants.push(bid(4));
4822
4823        let result = sub.replace_descriptor(bid(2), bad);
4824        assert!(matches!(
4825            result,
4826            Err(MembershipError::EngineRejected(
4827                crate::DiffEngineError::Unsupported { n: 4 }
4828            ))
4829        ));
4830        // Old descriptor still arity 3.
4831        assert_eq!(sub.lookup_descriptor(bid(2)).unwrap().arity(), 3);
4832    }
4833
4834    /// `register_with_sources` on a three-source descriptor
4835    /// installs secondary-index entries for every non-primary
4836    /// participant, so `lookup_session_for` resolves the same
4837    /// session from any of the three buffers.
4838    #[test]
4839    fn three_way_lookup_session_for_resolves_all_three_participants() {
4840        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4841        let sub = DiffSubsystem::new();
4842        let desc = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4843        let session = sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4844
4845        for participant in [bid(1), bid(2), bid(3)] {
4846            let resolved = sub
4847                .lookup_session_for(participant)
4848                .unwrap_or_else(|| panic!("lookup_session_for({participant:?}) returned None"));
4849            assert!(
4850                Arc::ptr_eq(&session, &resolved),
4851                "all three participants must resolve to the same session"
4852            );
4853        }
4854    }
4855
4856    /// `register_with_sources` for a three-source descriptor
4857    /// activates `diff-mode` on every participating buffer via
4858    /// the ref-counting bridge; `drop_session` deactivates all
4859    /// three.
4860    #[test]
4861    fn three_way_session_activates_and_deactivates_diff_mode_for_all_three() {
4862        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4863        let sub = DiffSubsystem::new();
4864        let bridge = sub.mode_bridge();
4865        let desc = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4866        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4867
4868        for participant in [bid(1), bid(2), bid(3)] {
4869            assert_eq!(
4870                bridge.refcount(participant),
4871                1,
4872                "buffer {participant:?} should be diff-mode active after 3-way open"
4873            );
4874        }
4875
4876        sub.drop_session(bid(2));
4877        for participant in [bid(1), bid(2), bid(3)] {
4878            assert_eq!(
4879                bridge.refcount(participant),
4880                0,
4881                "buffer {participant:?} should be deactivated after drop"
4882            );
4883        }
4884    }
4885
4886    /// Same buffer participating in both a two-way and a
4887    /// three-way session simultaneously stays diff-mode-active
4888    /// until the *last* session closes. Verifies refcount
4889    /// semantics hold across mixed session shapes.
4890    #[test]
4891    fn shared_buffer_across_two_way_and_three_way_keeps_diff_mode_until_last_close() {
4892        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
4893        let sub = DiffSubsystem::new();
4894        let bridge = sub.mode_bridge();
4895
4896        // Three-way: bid(1) is the base of a [bid(1), bid(2), bid(3)]
4897        // session.
4898        let three_way = three_way_descriptor(&provider, bid(1), bid(2), bid(3));
4899        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, three_way);
4900
4901        // Two-way: bid(1) is also the baseline of a [bid(1), bid(4)]
4902        // session. Participant list mirrors what the two-pane
4903        // dispatch helper produces.
4904        let mut two_way = descriptor(&provider, bid(1), bid(4));
4905        two_way.participants = vec![bid(1), bid(4)];
4906        sub.register_with_sources(bid(4), DiffAlgorithm::Histogram, two_way);
4907
4908        // bid(1) participates in both sessions.
4909        assert_eq!(bridge.refcount(bid(1)), 2);
4910        assert_eq!(bridge.refcount(bid(2)), 1);
4911        assert_eq!(bridge.refcount(bid(3)), 1);
4912        assert_eq!(bridge.refcount(bid(4)), 1);
4913
4914        // Close the three-way. bid(1) still in the two-way → mode
4915        // stays active.
4916        sub.drop_session(bid(2));
4917        assert_eq!(bridge.refcount(bid(1)), 1);
4918        assert_eq!(bridge.refcount(bid(2)), 0);
4919        assert_eq!(bridge.refcount(bid(3)), 0);
4920        assert_eq!(bridge.refcount(bid(4)), 1);
4921
4922        // Close the two-way. bid(1) finally deactivates.
4923        sub.drop_session(bid(4));
4924        assert_eq!(bridge.refcount(bid(1)), 0);
4925        assert_eq!(bridge.refcount(bid(4)), 0);
4926    }
4927
4928    /// `recompute_from_descriptor` (the path driven by the
4929    /// debounce/bus pipeline) forwards `descriptor.remote` to
4930    /// `schedule_recompute`. End-to-end check via the public
4931    /// `note_buffer_edited` driver: edit any of the three
4932    /// watched buffers, expect a three-range hunk to publish.
4933    #[tokio::test]
4934    async fn three_way_routing_publishes_three_way_hunks_on_edit() {
4935        let provider = Arc::new(MockProvider::default());
4936        provider.set(bid(1), Rope::from("aaa\nbbb\nccc\n"));
4937        provider.set(bid(2), Rope::from("aaa\nbbb\nccc\n"));
4938        provider.set(bid(3), Rope::from("aaa\nbbb\nccc\n"));
4939        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
4940
4941        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
4942            1,
4943        )));
4944        let desc = three_way_descriptor(&dyn_provider, bid(1), bid(2), bid(3));
4945        let session = sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
4946
4947        // Edit the remote side and let the debounce → spawn_blocking
4948        // → publish chain run.
4949        provider.set(bid(3), Rope::from("aaa\nbbb\nREMOTE\n"));
4950        sub.note_buffer_edited(bid(3));
4951        // Wait up to ~200ms for the debounce + blocking task to
4952        // land a publish. Polls the revision counter so the test
4953        // finishes as soon as the publish lands rather than
4954        // burning the full window.
4955        let deadline = std::time::Instant::now() + Duration::from_millis(200);
4956        while session.current_hunks().revision == 0 {
4957            if std::time::Instant::now() >= deadline {
4958                panic!("three-way recompute never published");
4959            }
4960            tokio::time::sleep(Duration::from_millis(5)).await;
4961        }
4962        let idx = session.current_hunks();
4963        assert!(
4964            idx.hunks.iter().any(|h| h.ranges.len() == 3),
4965            "expected three-range hunks from compute_three_way; got {:?}",
4966            idx.hunks
4967        );
4968    }
4969
4970    /// D.6.h (2026-05-31) — design-doc §11 risk gate:
4971    /// **multi-doc edit-event coalesce**. A three-way
4972    /// session subscribes to three documents' edit
4973    /// streams. If two (or all three) documents edit
4974    /// "simultaneously" (within the debounce window),
4975    /// exactly *one* recompute must reflect the
4976    /// combined state — not N parallel recomputes (one
4977    /// per event).
4978    ///
4979    /// The debouncer's "reset on each poke" semantic
4980    /// (D.2.c) is the load-bearing piece: each
4981    /// `note_buffer_edited` call within the window
4982    /// bumps the epoch but doesn't spawn a fresh
4983    /// recompute; the trailing spawn fires only after
4984    /// the burst quiesces.
4985    ///
4986    /// Uses a deliberately wide debounce window (50ms)
4987    /// to keep three `note_buffer_edited` calls
4988    /// comfortably inside the burst, then waits for the
4989    /// settled publish. Asserts revision == 1
4990    /// post-burst.
4991    #[tokio::test]
4992    async fn three_way_rapid_edits_coalesce_to_one_recompute() {
4993        let provider = Arc::new(MockProvider::default());
4994        provider.set(bid(1), Rope::from("aaa\nbbb\nccc\n"));
4995        provider.set(bid(2), Rope::from("aaa\nbbb\nccc\n"));
4996        provider.set(bid(3), Rope::from("aaa\nbbb\nccc\n"));
4997        let dyn_provider: Arc<dyn BufferTextProvider> = provider.clone();
4998
4999        // 50ms debounce window: long enough that three
5000        // poke()s inside the same task tick stay inside
5001        // the burst. The runtime's `sleep().await` after
5002        // the bursts lets the debouncer settle.
5003        let sub = Arc::new(DiffSubsystem::with_debounce_window(Duration::from_millis(
5004            50,
5005        )));
5006        let desc = three_way_descriptor(&dyn_provider, bid(1), bid(2), bid(3));
5007        let session = sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
5008
5009        // Three rapid edits, one per participant. Each
5010        // mutation diverges in a different region so
5011        // `compute_three_way` produces a multi-hunk
5012        // index — but only ONE recompute should fire.
5013        provider.set(bid(1), Rope::from("AAA\nbbb\nccc\n"));
5014        sub.note_buffer_edited(bid(1));
5015        provider.set(bid(2), Rope::from("aaa\nBBB\nccc\n"));
5016        sub.note_buffer_edited(bid(2));
5017        provider.set(bid(3), Rope::from("aaa\nbbb\nCCC\n"));
5018        sub.note_buffer_edited(bid(3));
5019
5020        // Wait for the debounce window + spawn_blocking
5021        // to settle. Use a deadline poll rather than a
5022        // fixed sleep so the test doesn't artificially
5023        // inflate CI runtime.
5024        let deadline = std::time::Instant::now() + Duration::from_millis(500);
5025        while session.current_hunks().revision == 0 {
5026            if std::time::Instant::now() >= deadline {
5027                panic!("recompute never published after burst");
5028            }
5029            tokio::time::sleep(Duration::from_millis(5)).await;
5030        }
5031
5032        // The key invariant: exactly one recompute fired.
5033        // The session's revision counter starts at 1 and
5034        // allocates a fresh number per recompute, so
5035        // "coalesced to one" means revision == 1 after
5036        // the burst settles. If the debouncer spawned
5037        // per-event we'd see revision == 3.
5038        assert_eq!(
5039            session.current_hunks().revision,
5040            1,
5041            "3 rapid edits within the debounce window must coalesce \
5042			 to a single recompute; got revision={}",
5043            session.current_hunks().revision
5044        );
5045
5046        // And the resulting HunkIndex reflects ALL three
5047        // edits in one combined output — not just the
5048        // last one. Each side modified a distinct row
5049        // (0/1/2) so we expect three hunks.
5050        let idx = session.current_hunks();
5051        assert!(
5052            !idx.hunks.is_empty(),
5053            "combined-state recompute should produce hunks"
5054        );
5055    }
5056
5057    // ──────────────────────────────────────────────────────
5058    // D.6.d (2026-05-31): target-aware compute_get_edit /
5059    // compute_put_plan
5060    // ──────────────────────────────────────────────────────
5061
5062    /// Build a three-pane session fixture with three buffer-
5063    /// backed sides, returning the subsystem + the three
5064    /// buffer ids in role order (base, local, remote).
5065    fn fixture_three_pane(
5066        base_text: &str,
5067        local_text: &str,
5068        remote_text: &str,
5069    ) -> (DiffSubsystem, BufferId, BufferId, BufferId) {
5070        let provider = Arc::new(MockProvider::default());
5071        provider.set(bid(1), Rope::from(base_text));
5072        provider.set(bid(2), Rope::from(local_text));
5073        provider.set(bid(3), Rope::from(remote_text));
5074        let dyn_provider: Arc<dyn BufferTextProvider> = provider;
5075        let sub = DiffSubsystem::new();
5076        let desc = three_way_descriptor(&dyn_provider, bid(1), bid(2), bid(3));
5077        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
5078        (sub, bid(1), bid(2), bid(3))
5079    }
5080
5081    /// Three-way `compute_get_edit` with no target returns
5082    /// `TargetRequired` listing the two non-active
5083    /// participants.
5084    #[test]
5085    fn compute_get_edit_three_way_no_target_requires_one() {
5086        let (sub, base, local, remote) = fixture_three_pane("a\n", "b\n", "c\n");
5087        let outcome = sub.compute_get_edit(local, 0, None);
5088        match outcome {
5089            DiffGetOutcome::TargetRequired { available_targets } => {
5090                assert_eq!(available_targets, vec![base, remote]);
5091            }
5092            other => panic!("expected TargetRequired, got {other:?}"),
5093        }
5094    }
5095
5096    /// Three-way `compute_get_edit` with explicit target
5097    /// pulls from that side. Conflict hunks resolvable.
5098    #[test]
5099    fn compute_get_edit_three_way_with_target_resolves_conflict() {
5100        let (sub, _base, local, remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5101        publish_hunks(
5102            &sub,
5103            local, // session key
5104            vec![Hunk {
5105                kind: HunkKind::Conflict,
5106                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5107                refine: Default::default(),
5108            }],
5109        );
5110        // Active = local, target = remote → pull REMOTE's
5111        // text into local.
5112        let outcome = sub.compute_get_edit(local, 0, Some(remote));
5113        match outcome {
5114            DiffGetOutcome::Edit {
5115                target_buffer_id,
5116                edit,
5117                post_cursor_row,
5118            } => {
5119                assert_eq!(target_buffer_id, remote);
5120                assert_eq!(post_cursor_row, 0);
5121                match edit.kind {
5122                    lattice_protocol::edit::EditKind::Replace { ref text } => {
5123                        assert_eq!(text, "REMOTE\n");
5124                    }
5125                }
5126            }
5127            other => panic!("expected Edit, got {other:?}"),
5128        }
5129    }
5130
5131    /// CR.2: `dB` keep-both splices ours-then-theirs into the active
5132    /// (local) conflict range — base omitted. Active = local, theirs =
5133    /// remote → the local range becomes "LOCAL\nREMOTE\n"; the edit
5134    /// applies to the active (local) buffer.
5135    #[test]
5136    fn compute_keep_both_edit_splices_ours_then_theirs() {
5137        let (sub, _base, local, remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5138        publish_hunks(
5139            &sub,
5140            local,
5141            vec![Hunk {
5142                kind: HunkKind::Conflict,
5143                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5144                refine: Default::default(),
5145            }],
5146        );
5147        match sub.compute_keep_both_edit(local, 0, remote) {
5148            DiffGetOutcome::Edit {
5149                target_buffer_id,
5150                edit,
5151                post_cursor_row,
5152            } => {
5153                assert_eq!(
5154                    target_buffer_id, local,
5155                    "keep-both edits the active/local side"
5156                );
5157                assert_eq!(post_cursor_row, 0);
5158                assert_eq!(edit.range.start.line, 0);
5159                assert_eq!(edit.range.end.line, 1);
5160                match edit.kind {
5161                    lattice_protocol::edit::EditKind::Replace { ref text } => {
5162                        assert_eq!(text, "LOCAL\nREMOTE\n");
5163                    }
5164                }
5165            }
5166            other => panic!("expected Edit, got {other:?}"),
5167        }
5168    }
5169
5170    /// CR.2: keep-both is conflict-only — a `Change` hunk under the
5171    /// cursor yields `Nothing` (that's `do`/`dp` territory).
5172    #[test]
5173    fn compute_keep_both_edit_non_conflict_hunk_returns_nothing() {
5174        let (sub, _base, local, remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5175        publish_hunks(
5176            &sub,
5177            local,
5178            vec![Hunk {
5179                kind: HunkKind::Change,
5180                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5181                refine: Default::default(),
5182            }],
5183        );
5184        assert!(matches!(
5185            sub.compute_keep_both_edit(local, 0, remote),
5186            DiffGetOutcome::Nothing
5187        ));
5188    }
5189
5190    /// CR.2: an unknown `theirs` buffer (not a participant) → `Nothing`.
5191    #[test]
5192    fn compute_keep_both_edit_unknown_theirs_returns_nothing() {
5193        let (sub, _base, local, _remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5194        publish_hunks(
5195            &sub,
5196            local,
5197            vec![Hunk {
5198                kind: HunkKind::Conflict,
5199                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5200                refine: Default::default(),
5201            }],
5202        );
5203        assert!(matches!(
5204            sub.compute_keep_both_edit(local, 0, bid(99)),
5205            DiffGetOutcome::Nothing
5206        ));
5207    }
5208
5209    /// CR.3 `d3o`: keep-theirs pulls REMOTE into the active (local)
5210    /// range via the conflict-aware `diff_get_effect` path — no explicit
5211    /// target needed at the call site (resolved to "theirs").
5212    #[test]
5213    fn diff_keep_theirs_effect_pulls_remote_into_local() {
5214        let (sub, _base, local, _remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5215        publish_hunks(
5216            &sub,
5217            local,
5218            vec![Hunk {
5219                kind: HunkKind::Conflict,
5220                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5221                refine: Default::default(),
5222            }],
5223        );
5224        match sub.diff_keep_theirs_effect(local, 0) {
5225            Some(lattice_grammar::Effect::ApplyEdit {
5226                target,
5227                edit,
5228                cursor,
5229            }) => {
5230                assert_eq!(target, local, "keep-theirs edits the active/local side");
5231                assert_eq!(
5232                    cursor,
5233                    Some(lattice_protocol::position::Position::new(0, 0))
5234                );
5235                match edit.kind {
5236                    lattice_protocol::edit::EditKind::Replace { ref text } => {
5237                        assert_eq!(text, "REMOTE\n");
5238                    }
5239                }
5240            }
5241            other => panic!("expected ApplyEdit, got {other:?}"),
5242        }
5243    }
5244
5245    /// CR.3 `dB`: keep-both effect wraps the splice into an
5246    /// `Effect::ApplyEdit` targeting the active (local) buffer.
5247    #[test]
5248    fn diff_keep_both_effect_splices_into_local() {
5249        let (sub, _base, local, _remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5250        publish_hunks(
5251            &sub,
5252            local,
5253            vec![Hunk {
5254                kind: HunkKind::Conflict,
5255                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5256                refine: Default::default(),
5257            }],
5258        );
5259        match sub.diff_keep_both_effect(local, 0) {
5260            Some(lattice_grammar::Effect::ApplyEdit { target, edit, .. }) => {
5261                assert_eq!(target, local);
5262                match edit.kind {
5263                    lattice_protocol::edit::EditKind::Replace { ref text } => {
5264                        assert_eq!(text, "LOCAL\nREMOTE\n");
5265                    }
5266                }
5267            }
5268            other => panic!("expected ApplyEdit, got {other:?}"),
5269        }
5270    }
5271
5272    /// CR.3 `d2o`: keep-ours over a conflict region is the degenerate
5273    /// self-target — an informative `Echo`, not a silent no-op (Dhruva's
5274    /// "full set, degenerate → echo" choice). Off-hunk → `None`.
5275    #[test]
5276    fn diff_keep_ours_effect_echoes_over_conflict_else_none() {
5277        let (sub, _base, local, _remote) =
5278            fixture_three_pane("a\nb\n", "LOCAL\nb\n", "REMOTE\nb\n");
5279        publish_hunks(
5280            &sub,
5281            local,
5282            vec![Hunk {
5283                kind: HunkKind::Conflict,
5284                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5285                refine: Default::default(),
5286            }],
5287        );
5288        assert!(matches!(
5289            sub.diff_keep_ours_effect(local, 0),
5290            Some(lattice_grammar::Effect::Echo { .. })
5291        ));
5292        // Row 1 is outside the conflict hunk → silent None.
5293        assert!(sub.diff_keep_ours_effect(local, 1).is_none());
5294    }
5295
5296    /// CR.3 `d3p`: put-theirs pushes the local side's hunk INTO remote —
5297    /// the edit targets the remote buffer.
5298    #[test]
5299    fn diff_put_theirs_effect_pushes_local_into_remote() {
5300        let (sub, _base, local, remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5301        publish_hunks(
5302            &sub,
5303            local,
5304            vec![Hunk {
5305                kind: HunkKind::Conflict,
5306                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5307                refine: Default::default(),
5308            }],
5309        );
5310        match sub.diff_put_theirs_effect(local, 0) {
5311            Some(lattice_grammar::Effect::ApplyEdit { target, edit, .. }) => {
5312                assert_eq!(target, remote, "put-theirs edits the remote buffer");
5313                match edit.kind {
5314                    lattice_protocol::edit::EditKind::Replace { ref text } => {
5315                        assert_eq!(text, "LOCAL\n");
5316                    }
5317                }
5318            }
5319            other => panic!("expected ApplyEdit, got {other:?}"),
5320        }
5321    }
5322
5323    /// CR.6: `]c`/`[c` hunk-nav resolvers return a `SelectionChange` to
5324    /// the next/prev hunk's slot-1 start, wrapping at both ends — the
5325    /// mode-owned replacement for the host `do_next_hunk`/`do_prev_hunk`.
5326    #[test]
5327    fn hunk_nav_effects_move_to_neighbouring_hunk() {
5328        let (sub, key) = fixture_with_baseline("base\n");
5329        publish_hunks(
5330            &sub,
5331            key,
5332            vec![
5333                Hunk {
5334                    kind: HunkKind::Change,
5335                    ranges: smallvec![lr(0, 1), lr(1, 2)],
5336                    refine: Default::default(),
5337                },
5338                Hunk {
5339                    kind: HunkKind::Change,
5340                    ranges: smallvec![lr(2, 3), lr(5, 6)],
5341                    refine: Default::default(),
5342                },
5343            ],
5344        );
5345        // slot-1 hunk starts are [1, 5].
5346        let row = |eff: Option<lattice_grammar::Effect>| match eff {
5347            Some(lattice_grammar::Effect::CursorMove(pos)) => pos.line,
5348            other => panic!("expected CursorMove, got {other:?}"),
5349        };
5350        assert_eq!(row(sub.diff_next_hunk_effect(key, 0)), 1, "next from 0 → 1");
5351        assert_eq!(row(sub.diff_next_hunk_effect(key, 3)), 5, "next from 3 → 5");
5352        assert_eq!(
5353            row(sub.diff_next_hunk_effect(key, 5)),
5354            1,
5355            "next past last → wrap to 1"
5356        );
5357        assert_eq!(row(sub.diff_prev_hunk_effect(key, 6)), 5, "prev from 6 → 5");
5358        assert_eq!(
5359            row(sub.diff_prev_hunk_effect(key, 0)),
5360            5,
5361            "prev before first → wrap to 5"
5362        );
5363    }
5364
5365    /// Target buffer that isn't a participant → `Nothing`.
5366    #[test]
5367    fn compute_get_edit_unknown_target_buffer_returns_nothing() {
5368        let (sub, _base, local, _remote) = fixture_three_pane("a\n", "b\n", "c\n");
5369        publish_hunks(
5370            &sub,
5371            local,
5372            vec![Hunk {
5373                kind: HunkKind::Conflict,
5374                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5375                refine: Default::default(),
5376            }],
5377        );
5378        let outcome = sub.compute_get_edit(local, 0, Some(bid(99)));
5379        assert!(matches!(outcome, DiffGetOutcome::Nothing));
5380    }
5381
5382    /// Two-way `compute_get_edit` with explicit target =
5383    /// peer behaves identically to no-target (back-compat
5384    /// for callers that want to be explicit).
5385    #[test]
5386    fn compute_get_edit_two_way_explicit_target_matches_default() {
5387        // fixture_two_pane registers a session with
5388        // participants = [baseline_buf, current_buf]. Use
5389        // current as the session key (= active), baseline
5390        // as the peer/target.
5391        let (sub, current, baseline) = fixture_two_pane("base\n", "curr\n");
5392        publish_hunks(
5393            &sub,
5394            current,
5395            vec![Hunk {
5396                kind: HunkKind::Change,
5397                ranges: smallvec![lr(0, 1), lr(0, 1)],
5398                refine: Default::default(),
5399            }],
5400        );
5401        let default = sub.compute_get_edit(current, 0, None);
5402        let explicit = sub.compute_get_edit(current, 0, Some(baseline));
5403        assert_eq!(default, explicit);
5404    }
5405
5406    /// Three-way `compute_put_plan` with no target returns
5407    /// `TargetRequired`.
5408    #[test]
5409    fn compute_put_plan_three_way_no_target_requires_one() {
5410        let (sub, base, local, remote) = fixture_three_pane("a\n", "b\n", "c\n");
5411        let outcome = sub.compute_put_plan(local, 0, None);
5412        match outcome {
5413            DiffPutOutcome::TargetRequired { available_targets } => {
5414                assert_eq!(available_targets, vec![base, remote]);
5415            }
5416            other => panic!("expected TargetRequired, got {other:?}"),
5417        }
5418    }
5419
5420    /// Three-way `:diffput <bufnr>` resolves a Conflict by
5421    /// pushing the active side's content into the target's
5422    /// range. From the slice plan: `:diffput 2` resolves a
5423    /// conflict by pushing pane 1's (= local's) version.
5424    #[test]
5425    fn compute_put_plan_three_way_resolves_conflict_to_explicit_target() {
5426        let (sub, _base, local, remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5427        publish_hunks(
5428            &sub,
5429            local,
5430            vec![Hunk {
5431                kind: HunkKind::Conflict,
5432                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5433                refine: Default::default(),
5434            }],
5435        );
5436        // Active = local (pane 1), target = remote (pane 2).
5437        // Push LOCAL\n into remote's range [0, 1).
5438        let outcome = sub.compute_put_plan(local, 0, Some(remote));
5439        match outcome {
5440            DiffPutOutcome::Edit {
5441                target_buffer_id,
5442                edit,
5443                post_cursor_row,
5444            } => {
5445                assert_eq!(target_buffer_id, remote);
5446                assert_eq!(post_cursor_row, 0);
5447                match edit.kind {
5448                    lattice_protocol::edit::EditKind::Replace { ref text } => {
5449                        assert_eq!(text, "LOCAL\n");
5450                    }
5451                }
5452            }
5453            other => panic!("expected Edit, got {other:?}"),
5454        }
5455    }
5456
5457    /// Two-way unchanged: `compute_put_plan` with no target
5458    /// targets the peer (D.5.c semantics preserved).
5459    #[test]
5460    fn compute_put_plan_two_way_no_target_targets_peer() {
5461        let (sub, current, baseline) = fixture_two_pane("base\n", "curr\n");
5462        publish_hunks(
5463            &sub,
5464            current,
5465            vec![Hunk {
5466                kind: HunkKind::Change,
5467                ranges: smallvec![lr(0, 1), lr(0, 1)],
5468                refine: Default::default(),
5469            }],
5470        );
5471        let outcome = sub.compute_put_plan(current, 0, None);
5472        match outcome {
5473            DiffPutOutcome::Edit {
5474                target_buffer_id, ..
5475            } => {
5476                assert_eq!(target_buffer_id, baseline);
5477            }
5478            other => panic!("expected Edit targeting baseline, got {other:?}"),
5479        }
5480    }
5481
5482    // ──────────────────────────────────────────────────────
5483    // D.6.e (2026-05-31): completion-signal lifecycle
5484    // ──────────────────────────────────────────────────────
5485
5486    /// `bind_completion` + `take_completion` are
5487    /// single-shot: the first take returns Some, the
5488    /// second None.
5489    #[test]
5490    fn completion_take_is_single_shot() {
5491        let session = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
5492        assert!(session.take_completion().is_none(), "no sender before bind");
5493        let (tx, _rx) = oneshot::channel::<DiffOutcome>();
5494        session.bind_completion(tx);
5495        let taken_first = session.take_completion();
5496        assert!(taken_first.is_some(), "first take after bind returns Some");
5497        assert!(
5498            session.take_completion().is_none(),
5499            "subsequent take returns None"
5500        );
5501    }
5502
5503    /// Re-binding overwrites the previous sender; the
5504    /// previously-bound receiver observes Closed (its
5505    /// sender is dropped).
5506    #[tokio::test]
5507    async fn completion_rebind_drops_previous_sender() {
5508        let session = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
5509        let (tx1, rx1) = oneshot::channel::<DiffOutcome>();
5510        session.bind_completion(tx1);
5511        let (tx2, _rx2) = oneshot::channel::<DiffOutcome>();
5512        session.bind_completion(tx2);
5513        // rx1 must observe Closed since tx1 was overwritten.
5514        assert!(rx1.await.is_err());
5515    }
5516
5517    /// Programmatic API: a consumer binds a sender,
5518    /// `:diff-accept` (simulated by direct
5519    /// `take_completion` + `send(Accept)`) fires it, and
5520    /// the awaiting receiver sees Accept.
5521    #[tokio::test]
5522    async fn completion_send_accept_routes_to_receiver() {
5523        let session = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
5524        let (tx, rx) = oneshot::channel::<DiffOutcome>();
5525        session.bind_completion(tx);
5526        // Simulate the `do_diff_accept` flow: take the
5527        // sender and send Accept.
5528        let taken = session.take_completion().expect("sender bound");
5529        taken
5530            .send(DiffOutcome::Accept)
5531            .expect("receiver still alive");
5532        let outcome = rx.await.expect("receiver returns the sent outcome");
5533        assert_eq!(outcome, DiffOutcome::Accept);
5534    }
5535
5536    /// Receiver dropped before the user resolves: the
5537    /// teardown path's `let _ = tx.send(...)` ignores the
5538    /// Err. Verifies the send error is non-fatal.
5539    #[test]
5540    fn completion_send_after_receiver_dropped_is_ignored() {
5541        let session = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
5542        let (tx, rx) = oneshot::channel::<DiffOutcome>();
5543        session.bind_completion(tx);
5544        drop(rx);
5545        let taken = session.take_completion().expect("sender bound");
5546        // `Result::Err` is returned but the call doesn't
5547        // panic — matches the production teardown's
5548        // `let _ =` discard.
5549        let result = taken.send(DiffOutcome::Reject);
5550        assert!(result.is_err());
5551    }
5552
5553    /// Sessions without a bound completion silently
5554    /// no-op on outcome dispatch: the teardown helper's
5555    /// `take_completion()` returns None and the rest of
5556    /// the teardown proceeds unaffected.
5557    #[test]
5558    fn unbound_completion_take_returns_none() {
5559        let session = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
5560        assert!(session.take_completion().is_none());
5561    }
5562
5563    // ──────────────────────────────────────────────────────
5564    // D.6.g (2026-05-31): all_sessions_for lookup
5565    // ──────────────────────────────────────────────────────
5566
5567    /// Buffer not in any session → empty vec, not None.
5568    #[test]
5569    fn all_sessions_for_unregistered_buffer_is_empty() {
5570        let sub = DiffSubsystem::new();
5571        assert!(sub.all_sessions_for(bid(99)).is_empty());
5572    }
5573
5574    /// `sessions_awaiting_outcome` returns ONLY sessions with a bound
5575    /// completion (pending agent reviews), ordered oldest→newest by id, so
5576    /// `:diff-accept`/`:diff-reject` can resolve the pending review from any
5577    /// pane (`.last()` = most-recent). A plain `:diff` session (no completion)
5578    /// is excluded; taking the outcome clears it.
5579    #[test]
5580    fn sessions_awaiting_outcome_tracks_bound_completions_in_order() {
5581        let sub = DiffSubsystem::new();
5582        // Plain diff view — no completion bound → not awaiting.
5583        sub.register(bid(5), DiffAlgorithm::Histogram);
5584        assert!(sub.sessions_awaiting_outcome().is_empty());
5585
5586        // Two agent reviews bind completions (registered out of id order).
5587        let s30 = sub.register(bid(30), DiffAlgorithm::Histogram);
5588        let (tx30, _rx30) = tokio::sync::oneshot::channel();
5589        s30.bind_completion(tx30);
5590        let s10 = sub.register(bid(10), DiffAlgorithm::Histogram);
5591        let (tx10, _rx10) = tokio::sync::oneshot::channel();
5592        s10.bind_completion(tx10);
5593
5594        let pending = sub.sessions_awaiting_outcome();
5595        let ids: Vec<u32> = pending.iter().map(|s| s.buffer_id().0).collect();
5596        assert_eq!(ids, vec![10, 30], "ascending by id; last() is most-recent");
5597
5598        // Resolving (taking the outcome) drops it from the pending set.
5599        let _ = s30.take_completion();
5600        let ids: Vec<u32> = sub
5601            .sessions_awaiting_outcome()
5602            .iter()
5603            .map(|s| s.buffer_id().0)
5604            .collect();
5605        assert_eq!(ids, vec![10], "taken completion no longer awaits");
5606    }
5607
5608    /// Single-session buffer: returns exactly one session.
5609    #[test]
5610    fn all_sessions_for_single_session_returns_one() {
5611        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
5612        let sub = DiffSubsystem::new();
5613        let desc = descriptor(&provider, bid(1), bid(2));
5614        sub.register_with_sources(bid(2), DiffAlgorithm::Histogram, desc);
5615        // Primary lookup.
5616        assert_eq!(sub.all_sessions_for(bid(2)).len(), 1);
5617        // Watched-side lookup (bid(1) is in the watch list as baseline).
5618        assert_eq!(sub.all_sessions_for(bid(1)).len(), 1);
5619    }
5620
5621    /// **The key D.6.g case.** A shared buffer participating
5622    /// in two simultaneous sessions: `lookup_session_for`
5623    /// resolves to one (the most recently registered, per
5624    /// secondary-index single-valued map), but
5625    /// `all_sessions_for` returns both — letting
5626    /// `:diffoff!` cascade-close them.
5627    #[test]
5628    fn all_sessions_for_shared_buffer_returns_every_session() {
5629        let provider: Arc<dyn BufferTextProvider> = Arc::new(MockProvider::default());
5630        let sub = DiffSubsystem::new();
5631        // Session A: shared (slot 0) ↔ peer_a (primary).
5632        let desc_a = descriptor(&provider, bid(10), bid(20));
5633        sub.register_with_sources(bid(20), DiffAlgorithm::Histogram, desc_a);
5634        // Session B: shared (slot 0) ↔ peer_b (primary).
5635        let desc_b = descriptor(&provider, bid(10), bid(30));
5636        sub.register_with_sources(bid(30), DiffAlgorithm::Histogram, desc_b);
5637        // shared participates in both A and B.
5638        let all = sub.all_sessions_for(bid(10));
5639        assert_eq!(all.len(), 2);
5640        let buffer_ids: std::collections::HashSet<BufferId> =
5641            all.iter().map(|s| s.buffer_id()).collect();
5642        assert!(buffer_ids.contains(&bid(20)));
5643        assert!(buffer_ids.contains(&bid(30)));
5644        // `lookup_session_for` only finds one of them.
5645        assert!(sub.lookup_session_for(bid(10)).is_some());
5646        assert_eq!(
5647            sub.all_sessions_for(bid(20)).len(),
5648            1,
5649            "non-shared buffer still resolves to its single session"
5650        );
5651    }
5652
5653    /// Target = active buffer itself is rejected (Unknown
5654    /// path → Nothing). Prevents accidental self-edits via
5655    /// `:diffput <self-bufnr>`.
5656    #[test]
5657    fn compute_put_plan_target_equal_to_active_returns_nothing() {
5658        let (sub, _base, local, _remote) = fixture_three_pane("a\n", "LOCAL\n", "REMOTE\n");
5659        publish_hunks(
5660            &sub,
5661            local,
5662            vec![Hunk {
5663                kind: HunkKind::Change,
5664                ranges: smallvec![lr(0, 1), lr(0, 1), lr(0, 1)],
5665                refine: Default::default(),
5666            }],
5667        );
5668        let outcome = sub.compute_put_plan(local, 0, Some(local));
5669        assert!(matches!(outcome, DiffPutOutcome::Nothing));
5670    }
5671}