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(®istered, &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}