Skip to main content

lattice_plugin_host/
boundary_effect.rs

1//! `WitBoundary` mirrors for the `Effect` payload types (plugin-host.md §4.4).
2//!
3//! Slice PH7.3b1a: the ~12 nested payload records/enums the `effect` variant
4//! (PH7.3b1b) composes — `Position`/`Range`/`Edit`/`EditDelta`/`AppliedEdit`,
5//! the selection model (`Selection`/`SelectionSet`/`VisualMode`), the modal
6//! model (`ModalState`/`VisualKind`/`SearchDirection`), `Register`, `YankKind`.
7//! Each is pure data and its conversion is infallible, but still returns
8//! `Result<_, String>` to satisfy the uniform [`WitBoundary`] contract.
9//!
10//! `SelectionSet` is reconstructed via `SelectionSet::from_parts` (added to
11//! `lattice-protocol` for exactly this boundary-projection need — the
12//! counterpart to its `all()` + `primary_index()` readers).
13
14use std::path::PathBuf;
15
16use crate::WitBoundary;
17use crate::boundary::path_to_wit;
18use crate::lattice::plugin_host::types::{
19    AppliedEdit as WitAppliedEdit, ApplyEditPayload as WitApplyEditPayload,
20    CloseSessionDiffsPayload as WitCloseSessionDiffsPayload, ConfirmPayload as WitConfirmPayload,
21    DescribeCommandPayload as WitDescribeCommandPayload, DiffsplitPayload as WitDiffsplitPayload,
22    EchoLevel as WitEchoLevel, EchoPayload as WitEchoPayload, Edit as WitEdit,
23    EditDelta as WitEditDelta, EditKind as WitEditKind, Effect as WitEffect,
24    FileAnchor as WitFileAnchor, LspRequest as WitLspRequest, ModalState as WitModalState,
25    OpenBufferAtColumnPayload as WitOpenBufferAtColumnPayload,
26    OpenBufferAtPayload as WitOpenBufferAtPayload, OpenBufferPayload as WitOpenBufferPayload,
27    OpenPickerPayload as WitOpenPickerPayload, OpenPopupPayload as WitOpenPopupPayload,
28    OpenPromptPayload as WitOpenPromptPayload,
29    OpenSyntheticBufferPayload as WitOpenSyntheticBufferPayload,
30    OpenTransientPayload as WitOpenTransientPayload, PopupFocus as WitPopupFocus,
31    PopupPlacement as WitPopupPlacement, Position as WitPosition, QuitPayload as WitQuitPayload,
32    QuitScope as WitQuitScope, Range as WitRange, Register as WitRegister,
33    SearchDirection as WitSearchDirection, Selection as WitSelection,
34    SelectionSet as WitSelectionSet, SetLspLogLevelPayload as WitSetLspLogLevelPayload,
35    SpawnTerminalPayload as WitSpawnTerminalPayload, SubstitutePayload as WitSubstitutePayload,
36    SubstituteScope as WitSubstituteScope, Utf16Pos as WitUtf16Pos, VisualKind as WitVisualKind,
37    VisualMode as WitVisualMode, WriteToFilePayload as WitWriteToFilePayload,
38    YankKind as WitYankKind, YankPayload as WitYankPayload,
39};
40use lattice_core::BufferId;
41use lattice_core::buffer::AppliedEdit as NativeAppliedEdit;
42use lattice_core::ui::popup::{
43    PopupFocus as NativePopupFocus, PopupPlacement as NativePopupPlacement,
44};
45use lattice_grammar::app_effect::AppEffect as NativeAppEffect;
46use lattice_grammar::args::Args as NativeArgs;
47use lattice_grammar::effect::{
48    EchoLevel as NativeEchoLevel, Effect as NativeEffect, LspRequest as NativeLspRequest,
49    QuitScope as NativeQuitScope, SubstituteScope as NativeSubstituteScope,
50    Utf16Pos as NativeUtf16Pos, YankKind as NativeYankKind,
51};
52use lattice_grammar::modal::{
53    ModalState as NativeModalState, SearchDirection as NativeSearchDirection,
54    VisualKind as NativeVisualKind,
55};
56use lattice_grammar::register::Register as NativeRegister;
57use lattice_protocol::edit::{
58    Edit as NativeEdit, EditDelta as NativeEditDelta, EditKind as NativeEditKind,
59};
60use lattice_protocol::position::{Position as NativePosition, Range as NativeRange};
61use lattice_protocol::selection::{
62    Selection as NativeSelection, SelectionSet as NativeSelectionSet,
63    VisualMode as NativeVisualMode,
64};
65
66impl WitBoundary for NativePosition {
67    type Wit = WitPosition;
68    fn to_wit(&self) -> Result<WitPosition, String> {
69        Ok(WitPosition {
70            line: self.line,
71            byte: self.byte,
72        })
73    }
74    fn from_wit(w: WitPosition) -> Result<Self, String> {
75        Ok(NativePosition {
76            line: w.line,
77            byte: w.byte,
78        })
79    }
80}
81
82impl WitBoundary for NativeRange {
83    type Wit = WitRange;
84    fn to_wit(&self) -> Result<WitRange, String> {
85        Ok(WitRange {
86            start: self.start.to_wit()?,
87            end: self.end.to_wit()?,
88        })
89    }
90    fn from_wit(w: WitRange) -> Result<Self, String> {
91        Ok(NativeRange {
92            start: NativePosition::from_wit(w.start)?,
93            end: NativePosition::from_wit(w.end)?,
94        })
95    }
96}
97
98impl WitBoundary for NativeEditKind {
99    type Wit = WitEditKind;
100    fn to_wit(&self) -> Result<WitEditKind, String> {
101        Ok(match self {
102            NativeEditKind::Replace { text } => WitEditKind::Replace(text.clone()),
103        })
104    }
105    fn from_wit(w: WitEditKind) -> Result<Self, String> {
106        Ok(match w {
107            WitEditKind::Replace(text) => NativeEditKind::Replace { text },
108        })
109    }
110}
111
112impl WitBoundary for NativeEdit {
113    type Wit = WitEdit;
114    fn to_wit(&self) -> Result<WitEdit, String> {
115        Ok(WitEdit {
116            range: self.range.to_wit()?,
117            kind: self.kind.to_wit()?,
118        })
119    }
120    fn from_wit(w: WitEdit) -> Result<Self, String> {
121        Ok(NativeEdit {
122            range: NativeRange::from_wit(w.range)?,
123            kind: NativeEditKind::from_wit(w.kind)?,
124        })
125    }
126}
127
128impl WitBoundary for NativeEditDelta {
129    type Wit = WitEditDelta;
130    fn to_wit(&self) -> Result<WitEditDelta, String> {
131        Ok(WitEditDelta {
132            start_byte: self.start_byte,
133            old_end_byte: self.old_end_byte,
134            new_end_byte: self.new_end_byte,
135            start_position: self.start_position.to_wit()?,
136            old_end_position: self.old_end_position.to_wit()?,
137            new_end_position: self.new_end_position.to_wit()?,
138        })
139    }
140    fn from_wit(w: WitEditDelta) -> Result<Self, String> {
141        Ok(NativeEditDelta {
142            start_byte: w.start_byte,
143            old_end_byte: w.old_end_byte,
144            new_end_byte: w.new_end_byte,
145            start_position: NativePosition::from_wit(w.start_position)?,
146            old_end_position: NativePosition::from_wit(w.old_end_position)?,
147            new_end_position: NativePosition::from_wit(w.new_end_position)?,
148        })
149    }
150}
151
152impl WitBoundary for NativeAppliedEdit {
153    type Wit = WitAppliedEdit;
154    fn to_wit(&self) -> Result<WitAppliedEdit, String> {
155        Ok(WitAppliedEdit {
156            original_range: self.original_range.to_wit()?,
157            inserted_range: self.inserted_range.to_wit()?,
158            replaced_text: self.replaced_text.clone(),
159            inserted_text: self.inserted_text.clone(),
160            delta: self.delta.to_wit()?,
161        })
162    }
163    fn from_wit(w: WitAppliedEdit) -> Result<Self, String> {
164        Ok(NativeAppliedEdit {
165            original_range: NativeRange::from_wit(w.original_range)?,
166            inserted_range: NativeRange::from_wit(w.inserted_range)?,
167            replaced_text: w.replaced_text,
168            inserted_text: w.inserted_text,
169            delta: NativeEditDelta::from_wit(w.delta)?,
170        })
171    }
172}
173
174impl WitBoundary for NativeVisualMode {
175    type Wit = WitVisualMode;
176    fn to_wit(&self) -> Result<WitVisualMode, String> {
177        Ok(match self {
178            NativeVisualMode::Charwise => WitVisualMode::Charwise,
179            NativeVisualMode::Linewise => WitVisualMode::Linewise,
180            NativeVisualMode::Blockwise => WitVisualMode::Blockwise,
181        })
182    }
183    fn from_wit(w: WitVisualMode) -> Result<Self, String> {
184        Ok(match w {
185            WitVisualMode::Charwise => NativeVisualMode::Charwise,
186            WitVisualMode::Linewise => NativeVisualMode::Linewise,
187            WitVisualMode::Blockwise => NativeVisualMode::Blockwise,
188        })
189    }
190}
191
192impl WitBoundary for NativeSelection {
193    type Wit = WitSelection;
194    fn to_wit(&self) -> Result<WitSelection, String> {
195        Ok(WitSelection {
196            anchor: self.anchor.to_wit()?,
197            head: self.head.to_wit()?,
198            visual: self.visual.map(|v| v.to_wit()).transpose()?,
199        })
200    }
201    fn from_wit(w: WitSelection) -> Result<Self, String> {
202        Ok(NativeSelection {
203            anchor: NativePosition::from_wit(w.anchor)?,
204            head: NativePosition::from_wit(w.head)?,
205            visual: w.visual.map(NativeVisualMode::from_wit).transpose()?,
206        })
207    }
208}
209
210impl WitBoundary for NativeSelectionSet {
211    type Wit = WitSelectionSet;
212    fn to_wit(&self) -> Result<WitSelectionSet, String> {
213        Ok(WitSelectionSet {
214            selections: self
215                .all()
216                .iter()
217                .map(WitBoundary::to_wit)
218                .collect::<Result<Vec<_>, _>>()?,
219            primary: self.primary_index() as u32,
220        })
221    }
222    fn from_wit(w: WitSelectionSet) -> Result<Self, String> {
223        let selections = w
224            .selections
225            .into_iter()
226            .map(NativeSelection::from_wit)
227            .collect::<Result<Vec<_>, _>>()?;
228        Ok(NativeSelectionSet::from_parts(
229            selections,
230            w.primary as usize,
231        ))
232    }
233}
234
235impl WitBoundary for NativeVisualKind {
236    type Wit = WitVisualKind;
237    fn to_wit(&self) -> Result<WitVisualKind, String> {
238        Ok(match self {
239            NativeVisualKind::Charwise => WitVisualKind::Charwise,
240            NativeVisualKind::Linewise => WitVisualKind::Linewise,
241            NativeVisualKind::Blockwise => WitVisualKind::Blockwise,
242        })
243    }
244    fn from_wit(w: WitVisualKind) -> Result<Self, String> {
245        Ok(match w {
246            WitVisualKind::Charwise => NativeVisualKind::Charwise,
247            WitVisualKind::Linewise => NativeVisualKind::Linewise,
248            WitVisualKind::Blockwise => NativeVisualKind::Blockwise,
249        })
250    }
251}
252
253impl WitBoundary for NativeSearchDirection {
254    type Wit = WitSearchDirection;
255    fn to_wit(&self) -> Result<WitSearchDirection, String> {
256        Ok(match self {
257            NativeSearchDirection::Forward => WitSearchDirection::Forward,
258            NativeSearchDirection::Backward => WitSearchDirection::Backward,
259        })
260    }
261    fn from_wit(w: WitSearchDirection) -> Result<Self, String> {
262        Ok(match w {
263            WitSearchDirection::Forward => NativeSearchDirection::Forward,
264            WitSearchDirection::Backward => NativeSearchDirection::Backward,
265        })
266    }
267}
268
269impl WitBoundary for NativeModalState {
270    type Wit = WitModalState;
271    fn to_wit(&self) -> Result<WitModalState, String> {
272        Ok(match self {
273            NativeModalState::Normal => WitModalState::Normal,
274            NativeModalState::Insert => WitModalState::Insert,
275            NativeModalState::Visual(k) => WitModalState::Visual(k.to_wit()?),
276            NativeModalState::Select(k) => WitModalState::Select(k.to_wit()?),
277            NativeModalState::OperatorPending => WitModalState::OperatorPending,
278            NativeModalState::Command => WitModalState::Command,
279            NativeModalState::Search(d) => WitModalState::Search(d.to_wit()?),
280            NativeModalState::Replace => WitModalState::Replace,
281            NativeModalState::Prompt => WitModalState::Prompt,
282        })
283    }
284    fn from_wit(w: WitModalState) -> Result<Self, String> {
285        Ok(match w {
286            WitModalState::Normal => NativeModalState::Normal,
287            WitModalState::Insert => NativeModalState::Insert,
288            WitModalState::Visual(k) => NativeModalState::Visual(NativeVisualKind::from_wit(k)?),
289            WitModalState::Select(k) => NativeModalState::Select(NativeVisualKind::from_wit(k)?),
290            WitModalState::OperatorPending => NativeModalState::OperatorPending,
291            WitModalState::Command => NativeModalState::Command,
292            WitModalState::Search(d) => {
293                NativeModalState::Search(NativeSearchDirection::from_wit(d)?)
294            }
295            WitModalState::Replace => NativeModalState::Replace,
296            WitModalState::Prompt => NativeModalState::Prompt,
297        })
298    }
299}
300
301impl WitBoundary for NativeRegister {
302    type Wit = WitRegister;
303    fn to_wit(&self) -> Result<WitRegister, String> {
304        Ok(match self {
305            NativeRegister::Unnamed => WitRegister::Unnamed,
306            NativeRegister::Named(c) => WitRegister::Named(*c),
307            NativeRegister::System => WitRegister::System,
308            NativeRegister::BlackHole => WitRegister::BlackHole,
309            NativeRegister::Expression => WitRegister::Expression,
310            NativeRegister::ReadOnly(c) => WitRegister::ReadOnly(*c),
311            NativeRegister::Numbered(n) => WitRegister::Numbered(*n),
312        })
313    }
314    fn from_wit(w: WitRegister) -> Result<Self, String> {
315        Ok(match w {
316            WitRegister::Unnamed => NativeRegister::Unnamed,
317            WitRegister::Named(c) => NativeRegister::Named(c),
318            WitRegister::System => NativeRegister::System,
319            WitRegister::BlackHole => NativeRegister::BlackHole,
320            WitRegister::Expression => NativeRegister::Expression,
321            WitRegister::ReadOnly(c) => NativeRegister::ReadOnly(c),
322            WitRegister::Numbered(n) => NativeRegister::Numbered(n),
323        })
324    }
325}
326
327impl WitBoundary for NativeYankKind {
328    type Wit = WitYankKind;
329    fn to_wit(&self) -> Result<WitYankKind, String> {
330        Ok(match self {
331            NativeYankKind::Charwise => WitYankKind::Charwise,
332            NativeYankKind::Linewise => WitYankKind::Linewise,
333            NativeYankKind::Blockwise => WitYankKind::Blockwise,
334        })
335    }
336    fn from_wit(w: WitYankKind) -> Result<Self, String> {
337        Ok(match w {
338            WitYankKind::Charwise => NativeYankKind::Charwise,
339            WitYankKind::Linewise => NativeYankKind::Linewise,
340            WitYankKind::Blockwise => NativeYankKind::Blockwise,
341        })
342    }
343}
344
345// ---- The small `effect`-only helper enums/records (PH7.3b1b) ----
346
347impl WitBoundary for NativeQuitScope {
348    type Wit = WitQuitScope;
349    fn to_wit(&self) -> Result<WitQuitScope, String> {
350        Ok(match self {
351            NativeQuitScope::Pane => WitQuitScope::Pane,
352            NativeQuitScope::All => WitQuitScope::All,
353        })
354    }
355    fn from_wit(w: WitQuitScope) -> Result<Self, String> {
356        Ok(match w {
357            WitQuitScope::Pane => NativeQuitScope::Pane,
358            WitQuitScope::All => NativeQuitScope::All,
359        })
360    }
361}
362
363impl WitBoundary for NativeEchoLevel {
364    type Wit = WitEchoLevel;
365    fn to_wit(&self) -> Result<WitEchoLevel, String> {
366        Ok(match self {
367            NativeEchoLevel::Trace => WitEchoLevel::Trace,
368            NativeEchoLevel::Debug => WitEchoLevel::Debug,
369            NativeEchoLevel::Info => WitEchoLevel::Info,
370            NativeEchoLevel::Warn => WitEchoLevel::Warn,
371            NativeEchoLevel::Error => WitEchoLevel::Error,
372        })
373    }
374    fn from_wit(w: WitEchoLevel) -> Result<Self, String> {
375        Ok(match w {
376            WitEchoLevel::Trace => NativeEchoLevel::Trace,
377            WitEchoLevel::Debug => NativeEchoLevel::Debug,
378            WitEchoLevel::Info => NativeEchoLevel::Info,
379            WitEchoLevel::Warn => NativeEchoLevel::Warn,
380            WitEchoLevel::Error => NativeEchoLevel::Error,
381        })
382    }
383}
384
385impl WitBoundary for NativeSubstituteScope {
386    type Wit = WitSubstituteScope;
387    fn to_wit(&self) -> Result<WitSubstituteScope, String> {
388        Ok(match self {
389            NativeSubstituteScope::CurrentLine => WitSubstituteScope::CurrentLine,
390            NativeSubstituteScope::Whole => WitSubstituteScope::Whole,
391        })
392    }
393    fn from_wit(w: WitSubstituteScope) -> Result<Self, String> {
394        Ok(match w {
395            WitSubstituteScope::CurrentLine => NativeSubstituteScope::CurrentLine,
396            WitSubstituteScope::Whole => NativeSubstituteScope::Whole,
397        })
398    }
399}
400
401impl WitBoundary for NativeUtf16Pos {
402    type Wit = WitUtf16Pos;
403    fn to_wit(&self) -> Result<WitUtf16Pos, String> {
404        Ok(WitUtf16Pos {
405            line: self.line,
406            col: self.col,
407        })
408    }
409    fn from_wit(w: WitUtf16Pos) -> Result<Self, String> {
410        Ok(NativeUtf16Pos {
411            line: w.line,
412            col: w.col,
413        })
414    }
415}
416
417impl WitBoundary for NativeLspRequest {
418    type Wit = WitLspRequest;
419    fn to_wit(&self) -> Result<WitLspRequest, String> {
420        Ok(match self {
421            NativeLspRequest::Hover => WitLspRequest::Hover,
422            NativeLspRequest::Definition => WitLspRequest::Definition,
423            NativeLspRequest::Declaration => WitLspRequest::Declaration,
424            NativeLspRequest::TypeDefinition => WitLspRequest::TypeDefinition,
425            NativeLspRequest::Implementation => WitLspRequest::Implementation,
426            NativeLspRequest::References => WitLspRequest::References,
427            NativeLspRequest::FollowLink => WitLspRequest::FollowLink,
428            // LR.2: no WIT mirror yet. Adding one is a versioned
429            // plugin-API change and deserves its own slice rather than
430            // riding along with a host-side feature — the `BuryBuffer`
431            // shape. Collapsing it to `References` would be lossy in
432            // the direction that matters: the plugin would silently get
433            // the picker when it asked for the editable view.
434            NativeLspRequest::ReferencesView
435            | NativeLspRequest::ReferencesViewRefresh
436            | NativeLspRequest::ReferencesToErrorList => {
437                return Err(
438                    "LspRequest::ReferencesView (`:lsp-references`) has no WIT mirror yet; \
439                     adding one is a versioned plugin-API change (lsp-architecture.md §17)"
440                        .to_string(),
441                );
442            }
443        })
444    }
445    fn from_wit(w: WitLspRequest) -> Result<Self, String> {
446        Ok(match w {
447            WitLspRequest::Hover => NativeLspRequest::Hover,
448            WitLspRequest::Definition => NativeLspRequest::Definition,
449            WitLspRequest::Declaration => NativeLspRequest::Declaration,
450            WitLspRequest::TypeDefinition => NativeLspRequest::TypeDefinition,
451            WitLspRequest::Implementation => NativeLspRequest::Implementation,
452            WitLspRequest::References => NativeLspRequest::References,
453            WitLspRequest::FollowLink => NativeLspRequest::FollowLink,
454        })
455    }
456}
457
458/// `Option<PathBuf>` → `option<string>` — each present path must be UTF-8
459/// (§4.4); a non-UTF-8 path is a typed error, never lossy.
460fn opt_path_to_wit(path: &Option<PathBuf>) -> Result<Option<String>, String> {
461    path.as_ref().map(|p| path_to_wit(p)).transpose()
462}
463
464fn opt_path_from_wit(path: Option<String>) -> Option<PathBuf> {
465    path.map(PathBuf::from)
466}
467
468// ---- `Effect` (§4.4): the whole closed enum crosses as `list<effect>` ----
469
470impl WitBoundary for NativeEffect {
471    /// `Effect` is *recursive* (`Many(Vec<Effect>)`), which WIT value types
472    /// cannot express. The boundary crosses a **`list<effect>`** instead: a
473    /// single effect is a one-element list, and `Many` (associative
474    /// composition) is flattened. `from_wit` rebuilds `Many` when the list has
475    /// more than one element; an empty list is `Effect::None`. A single-element
476    /// `Many([x])` therefore normalises to `x` — a *semantic* identity, since
477    /// `Many([x]) ≡ x`.
478    type Wit = Vec<WitEffect>;
479
480    fn to_wit(&self) -> Result<Vec<WitEffect>, String> {
481        let mut out = Vec::new();
482        flatten_effect(self, &mut out)?;
483        Ok(out)
484    }
485
486    fn from_wit(wit: Vec<WitEffect>) -> Result<Self, String> {
487        let mut effects = wit
488            .into_iter()
489            .map(effect_from_wit)
490            .collect::<Result<Vec<_>, _>>()?;
491        Ok(if effects.is_empty() {
492            NativeEffect::None
493        } else if effects.len() == 1 {
494            effects.remove(0)
495        } else {
496            NativeEffect::Many(effects)
497        })
498    }
499}
500
501/// Flatten `Many` (recursively — `Many` is associative) into a list of atoms.
502/// A sub-effect that cannot cross (`Global`/`AppAction`) propagates its typed
503/// error out of the whole conversion.
504fn flatten_effect(e: &NativeEffect, out: &mut Vec<WitEffect>) -> Result<(), String> {
505    match e {
506        NativeEffect::Many(list) => {
507            for sub in list {
508                flatten_effect(sub, out)?;
509            }
510            Ok(())
511        }
512        atom => {
513            out.push(effect_to_wit(atom)?);
514            Ok(())
515        }
516    }
517}
518
519/// Map one non-`Many` `Effect` to its WIT mirror. `Global`/`AppAction` cross as
520/// typed errors until their mirrors land (§4.1 / PH7.3b2); `Many` never reaches
521/// here (flattened by [`flatten_effect`]) but is handled defensively.
522fn effect_to_wit(e: &NativeEffect) -> Result<WitEffect, String> {
523    Ok(match e {
524        NativeEffect::None => WitEffect::None,
525        NativeEffect::Declined => WitEffect::Declined,
526        NativeEffect::Edits(edits) => WitEffect::Edits(
527            edits
528                .iter()
529                .map(WitBoundary::to_wit)
530                .collect::<Result<Vec<_>, _>>()?,
531        ),
532        NativeEffect::ApplyEdit {
533            target,
534            edit,
535            cursor,
536        } => WitEffect::ApplyEdit(WitApplyEditPayload {
537            target: target.0,
538            edit: edit.to_wit()?,
539            cursor: cursor.as_ref().map(|p| p.to_wit()).transpose()?,
540        }),
541        NativeEffect::CursorMove(pos) => {
542            let p = *pos;
543            WitEffect::SelectionChange(
544                NativeSelectionSet::single(NativeSelection {
545                    anchor: p,
546                    head: p,
547                    visual: None,
548                })
549                .to_wit()?,
550            )
551        }
552        // MG.18d: no WIT mirror yet, and the `CursorMove` fallback above
553        // is NOT available to it — collapsing it to a selection would
554        // drop the `target` buffer, which is the entire point of the
555        // variant (an async producer's position is only meaningful in
556        // the buffer it was computed in). A typed error keeps that
557        // loud; the mirror lands with the WIT buffer-handle work.
558        NativeEffect::CursorMoveIn { .. } => {
559            return Err(
560                "Effect::CursorMoveIn addresses a BufferId; it crosses with the buffer-handle \
561                 mirror, and must not degrade to a target-less cursor move"
562                    .to_string(),
563            );
564        }
565        NativeEffect::WriteToFile {
566            path,
567            anchor,
568            text,
569            cut,
570            create_parents,
571            save,
572        } => WitEffect::WriteToFile(WitWriteToFilePayload {
573            // Lossy only for a non-UTF-8 path, which cannot cross a WIT
574            // `string` at all — refused rather than mangled, so a guest never
575            // receives a path that names a different file than the host meant.
576            path: path
577                .to_str()
578                .ok_or_else(|| {
579                    format!("Effect::WriteToFile path is not UTF-8: {}", path.display())
580                })?
581                .to_string(),
582            anchor: anchor_to_wit(*anchor),
583            text: text.clone(),
584            cut: cut.as_ref().map(|r| r.to_wit()).transpose()?,
585            create_parents: *create_parents,
586            save: *save,
587        }),
588        NativeEffect::SelectionChange(set) => WitEffect::SelectionChange(set.to_wit()?),
589        NativeEffect::Yank {
590            register,
591            content,
592            kind,
593            explicit_yank,
594        } => WitEffect::Yank(WitYankPayload {
595            register: register.to_wit()?,
596            content: content.clone(),
597            kind: kind.to_wit()?,
598            explicit_yank: *explicit_yank,
599        }),
600        NativeEffect::EnterMode(state) => WitEffect::EnterMode(state.to_wit()?),
601        NativeEffect::SaveBuffer { path } => WitEffect::SaveBuffer(opt_path_to_wit(path)?),
602        NativeEffect::QuitEditor { force, scope } => WitEffect::QuitEditor(WitQuitPayload {
603            force: *force,
604            scope: scope.to_wit()?,
605        }),
606        NativeEffect::OpenBuffer { path, force } => WitEffect::OpenBuffer(WitOpenBufferPayload {
607            path: opt_path_to_wit(path)?,
608            force: *force,
609        }),
610        NativeEffect::OpenBufferAt {
611            path,
612            position,
613            force,
614            content,
615            activate_minor,
616        } => WitEffect::OpenBufferAt(WitOpenBufferAtPayload {
617            path: opt_path_to_wit(path)?,
618            position: position.to_wit()?,
619            force: *force,
620            content: content.clone(),
621            activate_minor: activate_minor.clone(),
622        }),
623        NativeEffect::OpenExternalUri { uri } => WitEffect::OpenExternalUri(uri.clone()),
624        NativeEffect::OpenBufferAtColumn {
625            path,
626            column,
627            force,
628        } => WitEffect::OpenBufferAtColumn(WitOpenBufferAtColumnPayload {
629            path: opt_path_to_wit(path)?,
630            column: column.map(|c| c.to_wit()).transpose()?,
631            force: *force,
632        }),
633        NativeEffect::SpawnTerminal {
634            cwd,
635            cmd_line,
636            env,
637            activate_minor,
638        } => WitEffect::SpawnTerminal(WitSpawnTerminalPayload {
639            cwd: opt_path_to_wit(cwd)?,
640            cmd_line: cmd_line.clone(),
641            env: env.clone(),
642            activate_minor: activate_minor.clone(),
643        }),
644        NativeEffect::TerminalInput(bytes) => WitEffect::TerminalInput(bytes.clone()),
645        NativeEffect::SetOption { spec } => WitEffect::SetOption(spec.clone()),
646        NativeEffect::SetLocalOption { spec } => WitEffect::SetLocalOption(spec.clone()),
647        NativeEffect::SetGlobalOption { spec } => WitEffect::SetGlobalOption(spec.clone()),
648        NativeEffect::ClearSearchHighlight => WitEffect::ClearSearchHighlight,
649        NativeEffect::SetColorscheme(name) => WitEffect::SetColorscheme(name.clone()),
650        NativeEffect::Echo { level, text } => WitEffect::Echo(WitEchoPayload {
651            level: level.to_wit()?,
652            text: text.clone(),
653        }),
654        NativeEffect::ShowDiagnosticsPopup { lines } => {
655            WitEffect::ShowDiagnosticsPopup(lines.clone())
656        }
657        NativeEffect::Lsp(req) => WitEffect::Lsp(req.to_wit()?),
658        NativeEffect::EchoRegisters => WitEffect::EchoRegisters,
659        NativeEffect::EchoMarks => WitEffect::EchoMarks,
660        NativeEffect::Substitute {
661            scope,
662            pattern,
663            replacement,
664            global,
665        } => WitEffect::Substitute(WitSubstitutePayload {
666            scope: scope.to_wit()?,
667            pattern: pattern.clone(),
668            replacement: replacement.clone(),
669            global: *global,
670        }),
671        NativeEffect::Global { .. } => {
672            return Err(
673                "Effect::Global carries a Box<CommandInvocation>; it crosses with the \
674                        command mirror (fragment §4.1)"
675                    .to_string(),
676            );
677        }
678        NativeEffect::DeleteCurrentLine => WitEffect::DeleteCurrentLine,
679        NativeEffect::DescribeCommand { name, anchor } => {
680            WitEffect::DescribeCommand(WitDescribeCommandPayload {
681                name: name.clone(),
682                anchor: anchor.clone(),
683            })
684        }
685        NativeEffect::DescribeBuffer => WitEffect::DescribeBuffer,
686        NativeEffect::Apropos { pattern } => WitEffect::Apropos(pattern.clone()),
687        NativeEffect::DescribeKey { chord } => WitEffect::DescribeKey(chord.clone()),
688        NativeEffect::ListKeymap => WitEffect::ListKeymap,
689        NativeEffect::BufferNext => WitEffect::BufferNext,
690        NativeEffect::BufferPrev => WitEffect::BufferPrev,
691        NativeEffect::ListBuffers => WitEffect::ListBuffers,
692        NativeEffect::OpenBufferPicker => WitEffect::OpenBufferPicker,
693        NativeEffect::OpenPicker {
694            source,
695            args,
696            root,
697            fill_action,
698            query,
699        } => WitEffect::OpenPicker(WitOpenPickerPayload {
700            source: source.clone(),
701            args: args.clone(),
702            root: opt_path_to_wit(root)?,
703            fill_action: fill_action.clone(),
704            query: query.clone(),
705        }),
706        NativeEffect::BufferDelete { force } => WitEffect::BufferDelete(*force),
707        NativeEffect::FocusBuffer(id) => WitEffect::FocusBuffer(*id),
708        NativeEffect::InvokeCommand { id, args } => {
709            WitEffect::InvokeCommand(crate::lattice::plugin_host::types::CommandRef {
710                id: id.clone(),
711                args: args.to_wit()?,
712            })
713        }
714        NativeEffect::OpenFileTree { root } => WitEffect::OpenFileTree(opt_path_to_wit(root)?),
715        NativeEffect::CloseFileTree => WitEffect::CloseFileTree,
716        NativeEffect::OpenOil { dir } => WitEffect::OpenOil(opt_path_to_wit(dir)?),
717        NativeEffect::DescribeOption { name } => WitEffect::DescribeOption(name.clone()),
718        NativeEffect::DescribeElement { name } => WitEffect::DescribeElement(name.clone()),
719        NativeEffect::ListOptions => WitEffect::ListOptions,
720        NativeEffect::DescribePluginApi { seam } => WitEffect::DescribePluginApi(seam.clone()),
721        NativeEffect::ListPluginApis => WitEffect::ListPluginApis,
722        NativeEffect::ExportPluginApi { format } => WitEffect::ExportPluginApi(format.clone()),
723        NativeEffect::ListCommands => WitEffect::ListCommands,
724        NativeEffect::DescribePlugin { name } => WitEffect::DescribePlugin(name.clone()),
725        NativeEffect::ListPlugins => WitEffect::ListPlugins,
726        NativeEffect::OpenHover { markdown } => WitEffect::OpenHover(markdown.clone()),
727        NativeEffect::DismissPopup => WitEffect::DismissPopup,
728        NativeEffect::DismissPopupNamed { name } => WitEffect::DismissPopupNamed(name.clone()),
729        // Same pending-mirror shape as `BuryBuffer`, and for the same reason:
730        // a WIT variant is a versioned plugin-API change with its own slice.
731        NativeEffect::KillBuffer => {
732            return Err(
733                "Effect::KillBuffer has no WIT mirror yet — native-only until the \
734                 plugin-API slice adds it"
735                    .to_string(),
736            );
737        }
738        NativeEffect::BuryBuffer => {
739            // No WIT mirror yet. Adding one is a versioned plugin-API
740            // change and deserves its own slice rather than riding
741            // along with a bug fix; the native modes that need it
742            // (magit's `q`) reach it directly. Same typed-error shape
743            // `Effect::Global` uses while its mirror is pending.
744            return Err(
745                "Effect::BuryBuffer has no WIT mirror yet — native-only until the \
746                 plugin-API slice adds it"
747                    .to_string(),
748            );
749        }
750        NativeEffect::OpenPopup {
751            name,
752            mode_id,
753            placement,
754            focus,
755        } => WitEffect::OpenPopup(WitOpenPopupPayload {
756            name: name.clone(),
757            mode_id: mode_id.clone(),
758            placement: match placement {
759                NativePopupPlacement::Centered => WitPopupPlacement::Centered,
760                NativePopupPlacement::CursorAnchored => WitPopupPlacement::CursorAnchored,
761                NativePopupPlacement::MinibufferBand => WitPopupPlacement::MinibufferBand,
762            },
763            focus: match focus {
764                NativePopupFocus::Steal => WitPopupFocus::Steal,
765                NativePopupFocus::Passive => WitPopupFocus::Passive,
766            },
767        }),
768        NativeEffect::OpenHelpTopic { topic } => WitEffect::OpenHelpTopic(topic.clone()),
769        NativeEffect::ListDiagnostics => WitEffect::ListDiagnostics,
770        NativeEffect::NextDiagnostic => WitEffect::NextDiagnostic,
771        NativeEffect::PrevDiagnostic => WitEffect::PrevDiagnostic,
772        NativeEffect::OpenLspLog { server_id } => WitEffect::OpenLspLog(server_id.clone()),
773        NativeEffect::OpenMessages => WitEffect::OpenMessages,
774        NativeEffect::OpenDashboard => WitEffect::OpenDashboard,
775        NativeEffect::ToggleLspTrace { server_id } => WitEffect::ToggleLspTrace(server_id.clone()),
776        NativeEffect::OpenLspTraceLog { server_id } => {
777            WitEffect::OpenLspTraceLog(server_id.clone())
778        }
779        NativeEffect::LspStatus => WitEffect::LspStatus,
780        // EP.4: `:lsp-diagnostics-to-error-list` writes core error
781        // state from a native subsystem, exactly like
782        // `AppEffect::SetErrorList` — whose plugin surface is
783        // deliberately refused pending the plugin host. Same treatment
784        // here: typed error, never lossy.
785        NativeEffect::LspDiagnosticsToErrorList => {
786            return Err(
787                "Effect::LspDiagnosticsToErrorList feeds core error state from the native LSP \
788                 subsystem; its plugin (WIT) surface is deferred with AppEffect::SetErrorList \
789                 (error-list.md §3.2)"
790                    .to_string(),
791            );
792        }
793        NativeEffect::LspServerLogListing => WitEffect::LspServerLogListing,
794        NativeEffect::LspRestart { server_id } => WitEffect::LspRestart(server_id.clone()),
795        NativeEffect::LspProgressCancel { server_id } => {
796            WitEffect::LspProgressCancel(server_id.clone())
797        }
798        NativeEffect::LspExpandRegion => WitEffect::LspExpandRegion,
799        NativeEffect::LspShrinkRegion => WitEffect::LspShrinkRegion,
800        NativeEffect::SetLspLogLevel { server_id, level } => {
801            WitEffect::SetLspLogLevel(WitSetLspLogLevelPayload {
802                server_id: server_id.clone(),
803                level: level.clone(),
804            })
805        }
806        NativeEffect::LspLogClear { server_id } => WitEffect::LspLogClear(server_id.clone()),
807        NativeEffect::LspDocumentSymbol => WitEffect::LspDocumentSymbol,
808        NativeEffect::LspWorkspaceSymbol { query } => WitEffect::LspWorkspaceSymbol(query.clone()),
809        NativeEffect::LspIncomingCalls => WitEffect::LspIncomingCalls,
810        NativeEffect::LspOutgoingCalls => WitEffect::LspOutgoingCalls,
811        NativeEffect::LspSupertypes => WitEffect::LspSupertypes,
812        NativeEffect::LspSubtypes => WitEffect::LspSubtypes,
813        NativeEffect::LspMoniker => WitEffect::LspMoniker,
814        NativeEffect::LspCodeLens => WitEffect::LspCodeLens,
815        NativeEffect::LspColorPresentation => WitEffect::LspColorPresentation,
816        // IN.8b: `:format` has no WIT mirror yet -- the plugin
817        // effect surface is versioned, and adding a variant is an API
818        // change that belongs with the next WIT revision rather than
819        // smuggled into an editor slice. A plugin cannot request it
820        // until then; nothing existing regresses.
821        NativeEffect::Format => {
822            return Err("Effect::Format has no WIT mirror yet (IN.8b)".to_string());
823        }
824        NativeEffect::LspFormat => WitEffect::LspFormat,
825        NativeEffect::LspFormatRange => WitEffect::LspFormatRange,
826        NativeEffect::LspSignatureHelp => WitEffect::LspSignatureHelp,
827        NativeEffect::LspComplete => WitEffect::LspComplete,
828        NativeEffect::LspRename { new_name } => WitEffect::LspRename(new_name.clone()),
829        NativeEffect::LspCodeAction => WitEffect::LspCodeAction,
830        NativeEffect::ExpandSnippet { replace_range } => {
831            WitEffect::ExpandSnippet(replace_range.to_wit()?)
832        }
833        NativeEffect::ReloadSnippets => WitEffect::ReloadSnippets,
834        NativeEffect::DescribeEvents => WitEffect::DescribeEvents,
835        NativeEffect::DescribeDiff => WitEffect::DescribeDiff,
836        NativeEffect::DiffOpen => WitEffect::DiffOpen,
837        NativeEffect::DiffOff { force } => WitEffect::DiffOff(*force),
838        NativeEffect::Diffthis => WitEffect::Diffthis,
839        NativeEffect::Diffsplit { path, remote } => WitEffect::Diffsplit(WitDiffsplitPayload {
840            path: path_to_wit(path)?,
841            remote: opt_path_to_wit(remote)?,
842        }),
843        NativeEffect::DiffGetCmd { target } => WitEffect::DiffGetCmd(*target),
844        NativeEffect::DiffPutCmd { target } => WitEffect::DiffPutCmd(*target),
845        NativeEffect::DiffAccept => WitEffect::DiffAccept,
846        NativeEffect::DiffReject => WitEffect::DiffReject,
847        NativeEffect::DiffAcceptAll => WitEffect::DiffAcceptAll,
848        NativeEffect::DiffRejectAll => WitEffect::DiffRejectAll,
849        NativeEffect::CloseSessionDiffs {
850            origin_session,
851            tab_name,
852        } => WitEffect::CloseSessionDiffs(WitCloseSessionDiffsPayload {
853            origin_session: *origin_session,
854            tab_name: tab_name.clone(),
855        }),
856        NativeEffect::CloseAllSessionDiffs { origin_session } => {
857            WitEffect::CloseAllSessionDiffs(*origin_session)
858        }
859        NativeEffect::NextHunk => WitEffect::NextHunk,
860        NativeEffect::PrevHunk => WitEffect::PrevHunk,
861        NativeEffect::DescribeEvent { name } => WitEffect::DescribeEvent(name.clone()),
862        NativeEffect::ListModes => WitEffect::ListModes,
863        NativeEffect::DescribeMode { name } => WitEffect::DescribeMode(name.clone()),
864        NativeEffect::DescribeActiveModes => WitEffect::DescribeActiveModes,
865        NativeEffect::DescribeActiveBindings => WitEffect::DescribeActiveBindings,
866        NativeEffect::DescribeOptionResolution { name } => {
867            WitEffect::DescribeOptionResolution(name.clone())
868        }
869        NativeEffect::Customize { name } => WitEffect::Customize(name.clone()),
870        NativeEffect::Tutor { lesson } => WitEffect::Tutor(*lesson),
871        NativeEffect::ToggleMode { mode_name } => WitEffect::ToggleMode(mode_name.clone()),
872        // PH7.3b2: the AppEffect mirror landed — AppAction now crosses (a
873        // NarrowTrigger-carrying AppEffect still propagates its typed error).
874        NativeEffect::AppAction(app) => WitEffect::AppAction(app.to_wit()?),
875        NativeEffect::RecordJump => WitEffect::RecordJump,
876        // Host-only ex-commands (no WIT mirror; plugins never see them).
877        // CM.8: `:clist` — the error picker is a host-only surface.
878        // IX.3: `confirm` has a mirror now — it no longer joins the
879        // silently-dropped group below.
880        NativeEffect::Confirm {
881            prompt,
882            yes_action,
883            args,
884        } => WitEffect::Confirm(WitConfirmPayload {
885            prompt: prompt.clone(),
886            yes_action: yes_action.clone(),
887            args: args.to_wit()?,
888        }),
889        // IX.4: no WIT mirror yet — a **typed error**, not `None`.
890        //
891        // `None` is a lie with the same shape as success: the effect
892        // arrives as "do nothing" and no one is told. `Effect::Global`
893        // set the precedent of failing loudly instead, and these follow
894        // it. `open-transient` and `open-prompt` are the two that most
895        // want mirrors (a plugin that cannot prompt cannot collect
896        // input at all) — IX.5 / IX.6.
897        NativeEffect::OpenPrompt {
898            prompt,
899            initial,
900            on_submit_action,
901            buffer_name,
902        } => WitEffect::OpenPrompt(WitOpenPromptPayload {
903            prompt: prompt.clone(),
904            initial: initial.clone(),
905            on_submit_action: on_submit_action.clone(),
906            buffer_name: buffer_name.clone(),
907        }),
908        NativeEffect::OpenTransient { source, args } => {
909            WitEffect::OpenTransient(WitOpenTransientPayload {
910                source: source.clone(),
911                args: args.to_wit()?,
912            })
913        }
914        // Host-only surfaces: `:cd` / `:pwd` act on the editor process,
915        // and `:clist`'s error picker is a host-owned view. They are not
916        // blocked on a mirror so much as unmapped by intent; the error
917        // still names them rather than pretending they crossed.
918        NativeEffect::ChangeDir(_) => {
919            return Err(
920                "Effect::ChangeDir is host-only (`:cd` acts on the editor process)".to_string(),
921            );
922        }
923        NativeEffect::PrintWorkingDir => {
924            return Err("Effect::PrintWorkingDir is host-only (`:pwd`)".to_string());
925        }
926        // PR.2: host-only by intent, not pending a mirror. A plugin that
927        // wants the project root reads it directly through the
928        // `project` import (PR.6) rather than echoing it into the
929        // host's message line.
930        NativeEffect::PrintProjectRoot => {
931            return Err("Effect::PrintProjectRoot is host-only (`:project-root`)".to_string());
932        }
933        NativeEffect::ListErrors => {
934            return Err(
935                "Effect::ListErrors is host-only (`:clist` opens a host-owned picker)".to_string(),
936            );
937        }
938        NativeEffect::OpenAiLog { session } => WitEffect::OpenAiLog(session.clone()),
939        NativeEffect::OpenSyntheticBuffer { name, mode_id, .. } => {
940            WitEffect::OpenSyntheticBuffer(WitOpenSyntheticBufferPayload {
941                name: name.clone(),
942                mode_id: mode_id.clone(),
943                // OC.7a: host→guest carries only what a guest could act on.
944                // These three are what a GUEST sends; a native emitter's
945                // values have no guest-side meaning, so they cross as `none`
946                // rather than as a shape the guest cannot use.
947                content: None,
948                cursor: None,
949                activate_minor: None,
950            })
951        }
952        // MG.50: no WIT mirror yet. Native-only, like `Global` /
953        // `AppAction` — magit is the only emitter and it is a native
954        // crate. A plugin that wants "open a synthetic buffer at a
955        // line" gets a typed error naming the gap rather than a silently
956        // dropped position, which is the failure mode the mirror-less
957        // variants are deliberately loud about.
958        NativeEffect::OpenSyntheticBufferAt { .. } => {
959            return Err("Effect::OpenSyntheticBufferAt has no WIT mirror yet \
960                        (native emitters only; add a payload to wit/types.wit \
961                        when a plugin needs it)"
962                .to_string());
963        }
964        // LM.0: host/peer-only by intent. Opening in a split/vsplit/tab is
965        // pane geometry the host owns; a plugin that wants it is a deliberate
966        // future WIT addition, not a silent drop. Loud like the other
967        // mirror-less variants.
968        NativeEffect::OpenInTarget { .. } => {
969            return Err("Effect::OpenInTarget has no WIT mirror yet \
970                        (host-only pane targeting; add a payload to wit/types.wit \
971                        when a plugin needs to open in a split)"
972                .to_string());
973        }
974        // LM.2: host-only in-place listing re-list / toggle. A plugin
975        // that wants to drive an oil re-list or a tree toggle is a
976        // deliberate future WIT addition, not a silent drop.
977        NativeEffect::OilNavigate { .. } => {
978            return Err("Effect::OilNavigate has no WIT mirror yet \
979                        (host-only oil re-list)"
980                .to_string());
981        }
982        NativeEffect::FileTreeToggle { .. } => {
983            return Err("Effect::FileTreeToggle has no WIT mirror yet \
984                        (host-only file-tree expand/collapse)"
985                .to_string());
986        }
987        NativeEffect::Many(_) => {
988            return Err(
989                "Effect::Many is flattened to list<effect> at the boundary and must not \
990                        reach effect_to_wit"
991                    .to_string(),
992            );
993        }
994    })
995}
996
997/// Map one WIT `effect` mirror back to native. There is no `Many` arm (it is a
998/// list-level concept), so this is a flat, total mapping over the ~101 arms.
999/// XF.4: `FileAnchor` crosses as data with no host state behind it, so the
1000/// two directions are a plain mapping rather than a `WitBoundary` impl.
1001fn anchor_to_wit(a: lattice_grammar::FileAnchor) -> WitFileAnchor {
1002    match a {
1003        lattice_grammar::FileAnchor::End => WitFileAnchor::End,
1004        lattice_grammar::FileAnchor::Start => WitFileAnchor::Start,
1005        lattice_grammar::FileAnchor::Line(n) => WitFileAnchor::Line(n),
1006    }
1007}
1008
1009fn anchor_from_wit(a: WitFileAnchor) -> lattice_grammar::FileAnchor {
1010    match a {
1011        WitFileAnchor::End => lattice_grammar::FileAnchor::End,
1012        WitFileAnchor::Start => lattice_grammar::FileAnchor::Start,
1013        WitFileAnchor::Line(n) => lattice_grammar::FileAnchor::Line(n),
1014    }
1015}
1016
1017fn effect_from_wit(w: WitEffect) -> Result<NativeEffect, String> {
1018    Ok(match w {
1019        WitEffect::None => NativeEffect::None,
1020        WitEffect::Declined => NativeEffect::Declined,
1021        WitEffect::Edits(edits) => NativeEffect::Edits(
1022            edits
1023                .into_iter()
1024                .map(NativeAppliedEdit::from_wit)
1025                .collect::<Result<Vec<_>, _>>()?,
1026        ),
1027        // XF.4: a guest-returned cross-file write. Converted here; AUTHORISED
1028        // by `EffectAuthorizer` in the trampoline, which is the only place
1029        // that still knows which plugin this came from.
1030        WitEffect::WriteToFile(p) => NativeEffect::WriteToFile {
1031            path: std::path::PathBuf::from(p.path),
1032            anchor: anchor_from_wit(p.anchor),
1033            text: p.text,
1034            cut: p.cut.map(NativeRange::from_wit).transpose()?,
1035            create_parents: p.create_parents,
1036            save: p.save,
1037        },
1038        WitEffect::ApplyEdit(p) => NativeEffect::ApplyEdit {
1039            target: BufferId(p.target),
1040            edit: NativeEdit::from_wit(p.edit)?,
1041            cursor: p.cursor.map(NativePosition::from_wit).transpose()?,
1042        },
1043        WitEffect::SelectionChange(set) => {
1044            NativeEffect::SelectionChange(NativeSelectionSet::from_wit(set)?)
1045        }
1046        WitEffect::CursorMove(pos) => NativeEffect::CursorMove(NativePosition::from_wit(pos)?),
1047        // IX.3: a guest asks the user a yes/no question. The host
1048        // resolves `yes_action` through the command registry when the
1049        // answer comes back, so a plugin confirms its own registered
1050        // action by name exactly as a native mode does.
1051        WitEffect::Confirm(p) => NativeEffect::Confirm {
1052            prompt: p.prompt,
1053            yes_action: p.yes_action,
1054            args: NativeArgs::from_wit(p.args)?,
1055        },
1056        // IX.5: a guest asks for a line of text. The submitted value
1057        // reaches the action through its context's `prompt_value`, so
1058        // nothing about the payload needs to carry it back.
1059        WitEffect::OpenTransient(p) => NativeEffect::OpenTransient {
1060            source: p.source,
1061            args: lattice_grammar::Args::from_wit(p.args)?,
1062        },
1063        WitEffect::OpenPrompt(p) => NativeEffect::OpenPrompt {
1064            prompt: p.prompt,
1065            initial: p.initial,
1066            on_submit_action: p.on_submit_action,
1067            buffer_name: p.buffer_name,
1068        },
1069        WitEffect::Yank(p) => NativeEffect::Yank {
1070            register: NativeRegister::from_wit(p.register)?,
1071            content: p.content,
1072            kind: NativeYankKind::from_wit(p.kind)?,
1073            explicit_yank: p.explicit_yank,
1074        },
1075        WitEffect::EnterMode(state) => NativeEffect::EnterMode(NativeModalState::from_wit(state)?),
1076        WitEffect::SaveBuffer(path) => NativeEffect::SaveBuffer {
1077            path: opt_path_from_wit(path),
1078        },
1079        WitEffect::QuitEditor(p) => NativeEffect::QuitEditor {
1080            force: p.force,
1081            scope: NativeQuitScope::from_wit(p.scope)?,
1082        },
1083        WitEffect::OpenBuffer(p) => NativeEffect::OpenBuffer {
1084            path: opt_path_from_wit(p.path),
1085            force: p.force,
1086        },
1087        WitEffect::OpenBufferAt(p) => NativeEffect::OpenBufferAt {
1088            path: opt_path_from_wit(p.path),
1089            position: NativePosition::from_wit(p.position)?,
1090            force: p.force,
1091            // Guest→host: these fields ARE the feature. Defaulting either
1092            // here would hand the guest an empty buffer with no chords.
1093            content: p.content,
1094            activate_minor: p.activate_minor,
1095        },
1096        WitEffect::OpenExternalUri(uri) => NativeEffect::OpenExternalUri { uri },
1097        WitEffect::OpenBufferAtColumn(p) => NativeEffect::OpenBufferAtColumn {
1098            path: opt_path_from_wit(p.path),
1099            column: p.column.map(NativeUtf16Pos::from_wit).transpose()?,
1100            force: p.force,
1101        },
1102        WitEffect::SpawnTerminal(p) => NativeEffect::SpawnTerminal {
1103            cmd_line: p.cmd_line,
1104            env: p.env,
1105            activate_minor: p.activate_minor,
1106            cwd: opt_path_from_wit(p.cwd),
1107        },
1108        WitEffect::TerminalInput(bytes) => NativeEffect::TerminalInput(bytes),
1109        WitEffect::SetOption(spec) => NativeEffect::SetOption { spec },
1110        WitEffect::SetLocalOption(spec) => NativeEffect::SetLocalOption { spec },
1111        WitEffect::SetGlobalOption(spec) => NativeEffect::SetGlobalOption { spec },
1112        WitEffect::ClearSearchHighlight => NativeEffect::ClearSearchHighlight,
1113        WitEffect::SetColorscheme(name) => NativeEffect::SetColorscheme(name),
1114        WitEffect::Echo(p) => NativeEffect::Echo {
1115            level: NativeEchoLevel::from_wit(p.level)?,
1116            text: p.text,
1117        },
1118        WitEffect::ShowDiagnosticsPopup(lines) => NativeEffect::ShowDiagnosticsPopup { lines },
1119        WitEffect::Lsp(req) => NativeEffect::Lsp(NativeLspRequest::from_wit(req)?),
1120        WitEffect::EchoRegisters => NativeEffect::EchoRegisters,
1121        WitEffect::EchoMarks => NativeEffect::EchoMarks,
1122        WitEffect::Substitute(p) => NativeEffect::Substitute {
1123            scope: NativeSubstituteScope::from_wit(p.scope)?,
1124            pattern: p.pattern,
1125            replacement: p.replacement,
1126            global: p.global,
1127        },
1128        WitEffect::DeleteCurrentLine => NativeEffect::DeleteCurrentLine,
1129        WitEffect::DescribeCommand(p) => NativeEffect::DescribeCommand {
1130            name: p.name,
1131            anchor: p.anchor,
1132        },
1133        WitEffect::DescribeBuffer => NativeEffect::DescribeBuffer,
1134        WitEffect::Apropos(pattern) => NativeEffect::Apropos { pattern },
1135        WitEffect::DescribeKey(chord) => NativeEffect::DescribeKey { chord },
1136        WitEffect::ListKeymap => NativeEffect::ListKeymap,
1137        WitEffect::BufferNext => NativeEffect::BufferNext,
1138        WitEffect::BufferPrev => NativeEffect::BufferPrev,
1139        WitEffect::ListBuffers => NativeEffect::ListBuffers,
1140        WitEffect::OpenBufferPicker => NativeEffect::OpenBufferPicker,
1141        WitEffect::OpenPicker(p) => NativeEffect::OpenPicker {
1142            source: p.source,
1143            args: p.args,
1144            root: opt_path_from_wit(p.root),
1145            // PC.11: carried, not dropped. This is the guest→host direction,
1146            // so this field IS the feature — a `None` here would leave the
1147            // guest naming an action that never receives anything, which is
1148            // the shape `InvokeCommand`'s `args` had for two releases
1149            // (declared, populated, destructured away) and which two comments
1150            // recorded as "a dead end" rather than fixing.
1151            fill_action: p.fill_action,
1152            query: p.query,
1153        },
1154        WitEffect::BufferDelete(force) => NativeEffect::BufferDelete { force },
1155        WitEffect::FocusBuffer(id) => NativeEffect::FocusBuffer(id),
1156        WitEffect::InvokeCommand(c) => NativeEffect::InvokeCommand {
1157            id: c.id,
1158            args: NativeArgs::from_wit(c.args)?,
1159        },
1160        WitEffect::OpenFileTree(root) => NativeEffect::OpenFileTree {
1161            root: opt_path_from_wit(root),
1162        },
1163        WitEffect::CloseFileTree => NativeEffect::CloseFileTree,
1164        WitEffect::OpenOil(dir) => NativeEffect::OpenOil {
1165            dir: opt_path_from_wit(dir),
1166        },
1167        WitEffect::DescribeOption(name) => NativeEffect::DescribeOption { name },
1168        WitEffect::DescribeElement(name) => NativeEffect::DescribeElement { name },
1169        WitEffect::ListOptions => NativeEffect::ListOptions,
1170        WitEffect::DescribePluginApi(seam) => NativeEffect::DescribePluginApi { seam },
1171        WitEffect::ListPluginApis => NativeEffect::ListPluginApis,
1172        WitEffect::ExportPluginApi(format) => NativeEffect::ExportPluginApi { format },
1173        WitEffect::ListCommands => NativeEffect::ListCommands,
1174        WitEffect::DescribePlugin(name) => NativeEffect::DescribePlugin { name },
1175        WitEffect::ListPlugins => NativeEffect::ListPlugins,
1176        WitEffect::OpenHover(markdown) => NativeEffect::OpenHover { markdown },
1177        WitEffect::DismissPopup => NativeEffect::DismissPopup,
1178        WitEffect::DismissPopupNamed(name) => NativeEffect::DismissPopupNamed { name },
1179        WitEffect::OpenPopup(p) => NativeEffect::OpenPopup {
1180            name: p.name,
1181            mode_id: p.mode_id,
1182            placement: match p.placement {
1183                WitPopupPlacement::Centered => NativePopupPlacement::Centered,
1184                WitPopupPlacement::CursorAnchored => NativePopupPlacement::CursorAnchored,
1185                WitPopupPlacement::MinibufferBand => NativePopupPlacement::MinibufferBand,
1186            },
1187            focus: match p.focus {
1188                WitPopupFocus::Steal => NativePopupFocus::Steal,
1189                WitPopupFocus::Passive => NativePopupFocus::Passive,
1190            },
1191        },
1192        WitEffect::OpenHelpTopic(topic) => NativeEffect::OpenHelpTopic { topic },
1193        WitEffect::ListDiagnostics => NativeEffect::ListDiagnostics,
1194        WitEffect::NextDiagnostic => NativeEffect::NextDiagnostic,
1195        WitEffect::PrevDiagnostic => NativeEffect::PrevDiagnostic,
1196        WitEffect::OpenLspLog(server_id) => NativeEffect::OpenLspLog { server_id },
1197        WitEffect::OpenMessages => NativeEffect::OpenMessages,
1198        WitEffect::OpenDashboard => NativeEffect::OpenDashboard,
1199        WitEffect::ToggleLspTrace(server_id) => NativeEffect::ToggleLspTrace { server_id },
1200        WitEffect::OpenLspTraceLog(server_id) => NativeEffect::OpenLspTraceLog { server_id },
1201        WitEffect::LspStatus => NativeEffect::LspStatus,
1202        WitEffect::LspServerLogListing => NativeEffect::LspServerLogListing,
1203        WitEffect::LspRestart(server_id) => NativeEffect::LspRestart { server_id },
1204        WitEffect::LspProgressCancel(server_id) => NativeEffect::LspProgressCancel { server_id },
1205        WitEffect::LspExpandRegion => NativeEffect::LspExpandRegion,
1206        WitEffect::LspShrinkRegion => NativeEffect::LspShrinkRegion,
1207        WitEffect::SetLspLogLevel(p) => NativeEffect::SetLspLogLevel {
1208            server_id: p.server_id,
1209            level: p.level,
1210        },
1211        WitEffect::LspLogClear(server_id) => NativeEffect::LspLogClear { server_id },
1212        WitEffect::LspDocumentSymbol => NativeEffect::LspDocumentSymbol,
1213        WitEffect::LspWorkspaceSymbol(query) => NativeEffect::LspWorkspaceSymbol { query },
1214        WitEffect::LspIncomingCalls => NativeEffect::LspIncomingCalls,
1215        WitEffect::LspOutgoingCalls => NativeEffect::LspOutgoingCalls,
1216        WitEffect::LspSupertypes => NativeEffect::LspSupertypes,
1217        WitEffect::LspSubtypes => NativeEffect::LspSubtypes,
1218        WitEffect::LspMoniker => NativeEffect::LspMoniker,
1219        WitEffect::LspCodeLens => NativeEffect::LspCodeLens,
1220        WitEffect::LspColorPresentation => NativeEffect::LspColorPresentation,
1221        WitEffect::LspFormat => NativeEffect::LspFormat,
1222        WitEffect::LspFormatRange => NativeEffect::LspFormatRange,
1223        WitEffect::LspSignatureHelp => NativeEffect::LspSignatureHelp,
1224        WitEffect::LspComplete => NativeEffect::LspComplete,
1225        WitEffect::LspRename(new_name) => NativeEffect::LspRename { new_name },
1226        WitEffect::LspCodeAction => NativeEffect::LspCodeAction,
1227        WitEffect::ExpandSnippet(range) => NativeEffect::ExpandSnippet {
1228            replace_range: NativeRange::from_wit(range)?,
1229        },
1230        WitEffect::ReloadSnippets => NativeEffect::ReloadSnippets,
1231        WitEffect::DescribeEvents => NativeEffect::DescribeEvents,
1232        WitEffect::DescribeDiff => NativeEffect::DescribeDiff,
1233        WitEffect::DiffOpen => NativeEffect::DiffOpen,
1234        WitEffect::DiffOff(force) => NativeEffect::DiffOff { force },
1235        WitEffect::Diffthis => NativeEffect::Diffthis,
1236        WitEffect::Diffsplit(p) => NativeEffect::Diffsplit {
1237            path: PathBuf::from(p.path),
1238            remote: opt_path_from_wit(p.remote),
1239        },
1240        WitEffect::DiffGetCmd(target) => NativeEffect::DiffGetCmd { target },
1241        WitEffect::DiffPutCmd(target) => NativeEffect::DiffPutCmd { target },
1242        WitEffect::DiffAccept => NativeEffect::DiffAccept,
1243        WitEffect::DiffReject => NativeEffect::DiffReject,
1244        WitEffect::DiffAcceptAll => NativeEffect::DiffAcceptAll,
1245        WitEffect::DiffRejectAll => NativeEffect::DiffRejectAll,
1246        WitEffect::CloseSessionDiffs(p) => NativeEffect::CloseSessionDiffs {
1247            origin_session: p.origin_session,
1248            tab_name: p.tab_name,
1249        },
1250        WitEffect::CloseAllSessionDiffs(origin_session) => {
1251            NativeEffect::CloseAllSessionDiffs { origin_session }
1252        }
1253        WitEffect::NextHunk => NativeEffect::NextHunk,
1254        WitEffect::PrevHunk => NativeEffect::PrevHunk,
1255        WitEffect::DescribeEvent(name) => NativeEffect::DescribeEvent { name },
1256        WitEffect::ListModes => NativeEffect::ListModes,
1257        WitEffect::DescribeMode(name) => NativeEffect::DescribeMode { name },
1258        WitEffect::DescribeActiveModes => NativeEffect::DescribeActiveModes,
1259        WitEffect::DescribeActiveBindings => NativeEffect::DescribeActiveBindings,
1260        WitEffect::DescribeOptionResolution(name) => {
1261            NativeEffect::DescribeOptionResolution { name }
1262        }
1263        WitEffect::Customize(name) => NativeEffect::Customize { name },
1264        WitEffect::Tutor(lesson) => NativeEffect::Tutor { lesson },
1265        WitEffect::ToggleMode(mode_name) => NativeEffect::ToggleMode { mode_name },
1266        WitEffect::AppAction(app) => NativeEffect::AppAction(NativeAppEffect::from_wit(app)?),
1267        WitEffect::RecordJump => NativeEffect::RecordJump,
1268        WitEffect::OpenAiLog(session) => NativeEffect::OpenAiLog { session },
1269        // OC.7a: the direction that matters. Guest→host carries the seed
1270        // text, the caret and the minor, because a plugin mode has no
1271        // `on_activate` to do any of it — the `modes` seam is
1272        // declaration-only, so without this a guest opens a buffer it can
1273        // never write into.
1274        WitEffect::OpenSyntheticBuffer(p) => NativeEffect::OpenSyntheticBuffer {
1275            name: p.name,
1276            mode_id: p.mode_id,
1277            content: p.content,
1278            cursor: p.cursor.map(NativePosition::from_wit).transpose()?,
1279            activate_minor: p.activate_minor,
1280        },
1281    })
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286    #![allow(clippy::unwrap_used, clippy::panic)]
1287
1288    use super::*;
1289
1290    fn pos(line: u32, byte: u32) -> NativePosition {
1291        NativePosition { line, byte }
1292    }
1293
1294    /// IX.4: an effect with no mirror fails loudly, naming itself.
1295    ///
1296    /// `WitEffect::None` was the previous answer, and it is a lie with
1297    /// the same shape as success — the effect arrives as "do nothing"
1298    /// and nobody is told. The error text has to name the culprit,
1299    /// because the reader is a plugin author with no view of host
1300    /// internals.
1301    #[test]
1302    fn an_effect_with_no_mirror_fails_loudly_instead_of_becoming_none() {
1303        // `OpenPrompt` / `OpenTransient` were here until IX.5 / IX.6
1304        // gave them mirrors — the remaining three are host-only by
1305        // intent rather than pending one.
1306        let unmirrored = vec![
1307            (NativeEffect::PrintWorkingDir, "PrintWorkingDir"),
1308            (NativeEffect::PrintProjectRoot, "PrintProjectRoot"),
1309            (NativeEffect::ListErrors, "ListErrors"),
1310            (
1311                NativeEffect::ChangeDir(Some("/tmp".to_string())),
1312                "ChangeDir",
1313            ),
1314        ];
1315        for (effect, name) in unmirrored {
1316            let err = effect_to_wit(&effect)
1317                .expect_err("an unmirrored effect must not silently become `none`");
1318            assert!(
1319                err.contains(name),
1320                "the error must name the culprit for a plugin author who \
1321                 cannot see host internals: got {err:?}"
1322            );
1323        }
1324    }
1325
1326    /// IX.3: a plugin can ask the user a yes/no question.
1327    ///
1328    /// Before this, `confirm` was mapped to `WitEffect::None` — a plugin
1329    /// returning one got no dialog and no error, which is the silent
1330    /// swallow the boundary is supposed to make impossible. The round
1331    /// trip is what proves the mirror is real in both directions.
1332    #[test]
1333    fn confirm_crosses_the_boundary_in_both_directions() {
1334        let native = NativeEffect::Confirm {
1335            prompt: "Delete src/main.rs?".to_string(),
1336            yes_action: "action:my-plugin-delete".to_string(),
1337            args: NativeArgs::List(vec![lattice_grammar::args::ArgValue::String(
1338                "src/main.rs".to_string(),
1339            )]),
1340        };
1341        let back = effect_from_wit(effect_to_wit(&native).unwrap()).unwrap();
1342        match back {
1343            NativeEffect::Confirm {
1344                prompt,
1345                yes_action,
1346                args,
1347            } => {
1348                assert_eq!(prompt, "Delete src/main.rs?");
1349                assert_eq!(yes_action, "action:my-plugin-delete");
1350                // The carried target is the whole point: a confirm that
1351                // lost its args on the way across would ask about one
1352                // thing and act on another.
1353                let list = args.as_list().expect("the target survived");
1354                assert!(matches!(
1355                    &list[0],
1356                    lattice_grammar::args::ArgValue::String(p) if p == "src/main.rs"
1357                ));
1358            }
1359            other => panic!("expected Confirm, got {other:?}"),
1360        }
1361    }
1362
1363    /// A confirm carrying nothing is still a confirm — the common case
1364    /// for a plugin that re-derives its own target.
1365    #[test]
1366    fn a_confirm_with_no_args_crosses_too() {
1367        let native = NativeEffect::Confirm {
1368            prompt: "Proceed?".to_string(),
1369            yes_action: "action:my-plugin-go".to_string(),
1370            args: NativeArgs::None,
1371        };
1372        let back = effect_from_wit(effect_to_wit(&native).unwrap()).unwrap();
1373        assert!(matches!(
1374            back,
1375            NativeEffect::Confirm {
1376                args: NativeArgs::None,
1377                ..
1378            }
1379        ));
1380    }
1381
1382    // ── XF.4: the cross-file write crosses ──────────────────────────────
1383
1384    fn write_effect(cut: Option<NativeRange>) -> NativeEffect {
1385        NativeEffect::WriteToFile {
1386            path: std::path::PathBuf::from("/tmp/archive.org"),
1387            anchor: lattice_grammar::FileAnchor::End,
1388            text: "* Archived\n".to_string(),
1389            cut,
1390            create_parents: false,
1391            save: false,
1392        }
1393    }
1394
1395    /// CD.1: the id survives both directions unchanged — it is the only
1396    /// handle a guest has on the buffer it wants shown.
1397    #[test]
1398    fn focus_buffer_round_trips() {
1399        let wit = effect_to_wit(&NativeEffect::FocusBuffer(42)).unwrap();
1400        assert!(matches!(wit, WitEffect::FocusBuffer(42)));
1401        let back = effect_from_wit(wit).unwrap();
1402        assert!(matches!(back, NativeEffect::FocusBuffer(42)));
1403    }
1404
1405    /// CD.3d: the name and the typed args survive both directions.
1406    #[test]
1407    fn invoke_command_round_trips() {
1408        let native = NativeEffect::InvokeCommand {
1409            id: "org-capture-cleanup".to_string(),
1410            args: NativeArgs::String("a3f9c1".to_string()),
1411        };
1412        match effect_from_wit(effect_to_wit(&native).unwrap()).unwrap() {
1413            NativeEffect::InvokeCommand { id, args } => {
1414                assert_eq!(id, "org-capture-cleanup");
1415                assert_eq!(args, NativeArgs::String("a3f9c1".to_string()));
1416            }
1417            other => panic!("expected InvokeCommand, got {other:?}"),
1418        }
1419    }
1420
1421    #[test]
1422    fn write_to_file_round_trips() {
1423        let back = effect_from_wit(effect_to_wit(&write_effect(None)).unwrap()).unwrap();
1424        match back {
1425            NativeEffect::WriteToFile {
1426                path,
1427                anchor,
1428                text,
1429                cut,
1430                ..
1431            } => {
1432                assert_eq!(path, std::path::PathBuf::from("/tmp/archive.org"));
1433                assert_eq!(anchor, lattice_grammar::FileAnchor::End);
1434                assert_eq!(text, "* Archived\n");
1435                assert!(cut.is_none());
1436            }
1437            other => panic!("expected WriteToFile, got {other:?}"),
1438        }
1439    }
1440
1441    /// The `cut` is what turns a copy into a move. Losing it across the
1442    /// boundary would leave the text in both places and look like a working
1443    /// archive until someone noticed the original was still there.
1444    #[test]
1445    fn the_cut_range_survives_the_crossing() {
1446        let range = NativeRange {
1447            start: pos(2, 0),
1448            end: pos(5, 0),
1449        };
1450        let back = effect_from_wit(effect_to_wit(&write_effect(Some(range))).unwrap()).unwrap();
1451        match back {
1452            NativeEffect::WriteToFile { cut: Some(got), .. } => assert_eq!(got, range),
1453            other => panic!("expected a cut, got {other:?}"),
1454        }
1455    }
1456
1457    /// Every anchor, because a variant silently collapsing to `End` would
1458    /// file every capture at the bottom regardless of what the guest asked.
1459    #[test]
1460    fn every_anchor_round_trips() {
1461        for anchor in [
1462            lattice_grammar::FileAnchor::End,
1463            lattice_grammar::FileAnchor::Start,
1464            lattice_grammar::FileAnchor::Line(0),
1465            lattice_grammar::FileAnchor::Line(42),
1466        ] {
1467            let native = NativeEffect::WriteToFile {
1468                path: std::path::PathBuf::from("/tmp/a.org"),
1469                anchor,
1470                text: "x".to_string(),
1471                cut: None,
1472                create_parents: false,
1473                save: false,
1474            };
1475            let back = effect_from_wit(effect_to_wit(&native).unwrap()).unwrap();
1476            match back {
1477                NativeEffect::WriteToFile { anchor: got, .. } => assert_eq!(got, anchor),
1478                other => panic!("expected WriteToFile, got {other:?}"),
1479            }
1480        }
1481    }
1482
1483    /// A non-UTF-8 path cannot cross a WIT `string`. Refused rather than
1484    /// lossily converted — a mangled path names a *different file*, and this
1485    /// effect writes to it.
1486    #[test]
1487    fn a_non_utf8_path_is_refused_rather_than_mangled() {
1488        #[cfg(unix)]
1489        {
1490            use std::os::unix::ffi::OsStrExt;
1491            let bad = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b"/tmp/\xff.org"));
1492            let native = NativeEffect::WriteToFile {
1493                path: bad,
1494                anchor: lattice_grammar::FileAnchor::End,
1495                text: "x".to_string(),
1496                cut: None,
1497                create_parents: false,
1498                save: false,
1499            };
1500            let err = effect_to_wit(&native).expect_err("must refuse");
1501            assert!(err.contains("not UTF-8"), "got {err}");
1502        }
1503    }
1504
1505    #[test]
1506    fn position_and_range_round_trip() {
1507        let p = pos(3, 7);
1508        assert_eq!(p, NativePosition::from_wit(p.to_wit().unwrap()).unwrap());
1509        let r = NativeRange {
1510            start: pos(1, 0),
1511            end: pos(2, 5),
1512        };
1513        assert_eq!(r, NativeRange::from_wit(r.to_wit().unwrap()).unwrap());
1514    }
1515
1516    #[test]
1517    fn plugin_api_help_effects_round_trip() {
1518        // PI.2: the `:describe-plugin-api` / `:list-plugin-apis` effects cross
1519        // the WIT boundary losslessly (the whole-enum mirror, §4.4). `Effect`
1520        // has no `PartialEq`, so match on the variant.
1521        let rt = |e: &NativeEffect| effect_from_wit(effect_to_wit(e).unwrap()).unwrap();
1522        match rt(&NativeEffect::DescribePluginApi {
1523            seam: Some("host-services".into()),
1524        }) {
1525            NativeEffect::DescribePluginApi { seam } => {
1526                assert_eq!(seam.as_deref(), Some("host-services"))
1527            }
1528            other => panic!("unexpected: {other:?}"),
1529        }
1530        match rt(&NativeEffect::DescribePluginApi { seam: None }) {
1531            NativeEffect::DescribePluginApi { seam } => assert!(seam.is_none()),
1532            other => panic!("unexpected: {other:?}"),
1533        }
1534        assert!(matches!(
1535            rt(&NativeEffect::ListPluginApis),
1536            NativeEffect::ListPluginApis
1537        ));
1538        match rt(&NativeEffect::ExportPluginApi {
1539            format: Some("json".into()),
1540        }) {
1541            NativeEffect::ExportPluginApi { format } => assert_eq!(format.as_deref(), Some("json")),
1542            other => panic!("unexpected: {other:?}"),
1543        }
1544        match rt(&NativeEffect::ExportPluginApi { format: None }) {
1545            NativeEffect::ExportPluginApi { format } => assert!(format.is_none()),
1546            other => panic!("unexpected: {other:?}"),
1547        }
1548        assert!(matches!(
1549            rt(&NativeEffect::ListCommands),
1550            NativeEffect::ListCommands
1551        ));
1552        match rt(&NativeEffect::DescribePlugin {
1553            name: "git-gutter".into(),
1554        }) {
1555            NativeEffect::DescribePlugin { name } => assert_eq!(name, "git-gutter"),
1556            other => panic!("unexpected: {other:?}"),
1557        }
1558        assert!(matches!(
1559            rt(&NativeEffect::ListPlugins),
1560            NativeEffect::ListPlugins
1561        ));
1562    }
1563
1564    #[test]
1565    fn edit_and_applied_edit_round_trip() {
1566        let edit = NativeEdit {
1567            range: NativeRange {
1568                start: pos(0, 0),
1569                end: pos(0, 3),
1570            },
1571            kind: NativeEditKind::Replace { text: "abc".into() },
1572        };
1573        assert_eq!(edit, NativeEdit::from_wit(edit.to_wit().unwrap()).unwrap());
1574
1575        let applied = NativeAppliedEdit {
1576            original_range: NativeRange {
1577                start: pos(0, 0),
1578                end: pos(0, 1),
1579            },
1580            inserted_range: NativeRange {
1581                start: pos(0, 0),
1582                end: pos(0, 3),
1583            },
1584            replaced_text: "x".into(),
1585            inserted_text: "abc".into(),
1586            delta: NativeEditDelta {
1587                start_byte: 0,
1588                old_end_byte: 1,
1589                new_end_byte: 3,
1590                start_position: pos(0, 0),
1591                old_end_position: pos(0, 1),
1592                new_end_position: pos(0, 3),
1593            },
1594        };
1595        // `AppliedEdit` is not `PartialEq`; assert round-trip fidelity at the
1596        // WIT level (bindgen derives `Debug`): to_wit(from_wit(to_wit(x))) must
1597        // equal to_wit(x).
1598        let wit = applied.to_wit().unwrap();
1599        let back = NativeAppliedEdit::from_wit(wit.clone()).unwrap();
1600        assert_eq!(format!("{:?}", back.to_wit().unwrap()), format!("{wit:?}"));
1601    }
1602
1603    #[test]
1604    fn selection_set_round_trips_single_and_multi() {
1605        let single = NativeSelectionSet::single(NativeSelection {
1606            anchor: pos(0, 0),
1607            head: pos(0, 4),
1608            visual: Some(NativeVisualMode::Charwise),
1609        });
1610        assert_eq!(
1611            single,
1612            NativeSelectionSet::from_wit(single.to_wit().unwrap()).unwrap()
1613        );
1614
1615        let multi = NativeSelectionSet::from_parts(
1616            vec![
1617                NativeSelection {
1618                    anchor: pos(0, 0),
1619                    head: pos(0, 1),
1620                    visual: None,
1621                },
1622                NativeSelection {
1623                    anchor: pos(2, 0),
1624                    head: pos(2, 3),
1625                    visual: Some(NativeVisualMode::Linewise),
1626                },
1627            ],
1628            1,
1629        );
1630        let back = NativeSelectionSet::from_wit(multi.to_wit().unwrap()).unwrap();
1631        assert_eq!(multi, back);
1632        assert_eq!(back.primary_index(), 1);
1633    }
1634
1635    #[test]
1636    fn modal_state_round_trips_every_variant() {
1637        for s in [
1638            NativeModalState::Normal,
1639            NativeModalState::Insert,
1640            NativeModalState::Visual(NativeVisualKind::Blockwise),
1641            NativeModalState::Select(NativeVisualKind::Charwise),
1642            NativeModalState::OperatorPending,
1643            NativeModalState::Command,
1644            NativeModalState::Search(NativeSearchDirection::Backward),
1645            NativeModalState::Replace,
1646        ] {
1647            assert_eq!(s, NativeModalState::from_wit(s.to_wit().unwrap()).unwrap());
1648        }
1649    }
1650
1651    #[test]
1652    fn register_round_trips_every_variant() {
1653        for r in [
1654            NativeRegister::Unnamed,
1655            NativeRegister::Named('a'),
1656            NativeRegister::System,
1657            NativeRegister::BlackHole,
1658            NativeRegister::Expression,
1659            NativeRegister::ReadOnly('%'),
1660            NativeRegister::Numbered(3),
1661        ] {
1662            assert_eq!(r, NativeRegister::from_wit(r.to_wit().unwrap()).unwrap());
1663        }
1664    }
1665
1666    #[test]
1667    fn yank_kind_round_trips() {
1668        for k in [
1669            NativeYankKind::Charwise,
1670            NativeYankKind::Linewise,
1671            NativeYankKind::Blockwise,
1672        ] {
1673            assert_eq!(k, NativeYankKind::from_wit(k.to_wit().unwrap()).unwrap());
1674        }
1675    }
1676
1677    // ---- Effect (PH7.3b1b) ----
1678
1679    /// `Effect` is not `PartialEq`; compare structural `Debug`. Round-trip is
1680    /// native → `list<effect>` → native.
1681    fn assert_effect_round_trips(native: NativeEffect) {
1682        let wit = native.to_wit().expect("to_wit");
1683        let back = NativeEffect::from_wit(wit).expect("from_wit");
1684        assert_eq!(format!("{native:?}"), format!("{back:?}"));
1685    }
1686
1687    fn sample_applied_edit() -> NativeAppliedEdit {
1688        NativeAppliedEdit {
1689            original_range: NativeRange {
1690                start: pos(0, 0),
1691                end: pos(0, 1),
1692            },
1693            inserted_range: NativeRange {
1694                start: pos(0, 0),
1695                end: pos(0, 3),
1696            },
1697            replaced_text: "x".into(),
1698            inserted_text: "abc".into(),
1699            delta: NativeEditDelta {
1700                start_byte: 0,
1701                old_end_byte: 1,
1702                new_end_byte: 3,
1703                start_position: pos(0, 0),
1704                old_end_position: pos(0, 1),
1705                new_end_position: pos(0, 3),
1706            },
1707        }
1708    }
1709
1710    fn sample_edit() -> NativeEdit {
1711        NativeEdit {
1712            range: NativeRange {
1713                start: pos(1, 2),
1714                end: pos(1, 5),
1715            },
1716            kind: NativeEditKind::Replace { text: "yo".into() },
1717        }
1718    }
1719
1720    /// Every payload-bearing arm (exercises all 14 payload records, the 5
1721    /// helper enums, the path/option/list arms). The unit arms are covered
1722    /// separately; the compiler already enforces that `effect_to_wit` /
1723    /// `effect_from_wit` handle *every* variant (both matches are exhaustive),
1724    /// so a new `Effect` arm cannot land without a mapping — that is the real
1725    /// exhaustiveness guarantee this test complements.
1726    #[test]
1727    fn effect_payload_arms_round_trip() {
1728        let cases = vec![
1729            NativeEffect::None,
1730            NativeEffect::Edits(vec![sample_applied_edit()]),
1731            NativeEffect::ApplyEdit {
1732                target: BufferId(2),
1733                edit: sample_edit(),
1734                cursor: Some(NativePosition::new(3, 0)),
1735            },
1736            NativeEffect::ApplyEdit {
1737                target: BufferId(0),
1738                edit: sample_edit(),
1739                cursor: None,
1740            },
1741            NativeEffect::SelectionChange(NativeSelectionSet::single(NativeSelection {
1742                anchor: pos(0, 0),
1743                head: pos(0, 4),
1744                visual: Some(NativeVisualMode::Charwise),
1745            })),
1746            NativeEffect::Yank {
1747                register: NativeRegister::Named('a'),
1748                content: "text".into(),
1749                kind: NativeYankKind::Linewise,
1750                explicit_yank: true,
1751            },
1752            NativeEffect::EnterMode(NativeModalState::Visual(NativeVisualKind::Blockwise)),
1753            NativeEffect::SaveBuffer {
1754                path: Some(PathBuf::from("/a/b.rs")),
1755            },
1756            NativeEffect::SaveBuffer { path: None },
1757            NativeEffect::QuitEditor {
1758                force: true,
1759                scope: NativeQuitScope::All,
1760            },
1761            NativeEffect::OpenBuffer {
1762                path: Some(PathBuf::from("/a/b.rs")),
1763                force: false,
1764            },
1765            NativeEffect::OpenBufferAt {
1766                path: None,
1767                position: pos(10, 4),
1768                force: true,
1769                content: None,
1770                activate_minor: None,
1771            },
1772            NativeEffect::OpenExternalUri {
1773                uri: "https://example.com".into(),
1774            },
1775            NativeEffect::OpenBufferAtColumn {
1776                path: Some(PathBuf::from("/a/b.rs")),
1777                column: Some(NativeUtf16Pos { line: 2, col: 8 }),
1778                force: false,
1779            },
1780            NativeEffect::OpenBufferAtColumn {
1781                path: None,
1782                column: None,
1783                force: false,
1784            },
1785            NativeEffect::SpawnTerminal {
1786                cmd_line: Some("claude".into()),
1787                env: vec![("CLAUDE_CODE_SSE_PORT".into(), "9000".into())],
1788                activate_minor: Some("claude-code-mode".into()),
1789                cwd: None,
1790            },
1791            // PC.2: a POPULATED cwd, not only the `None` above. A field that is
1792            // only ever round-tripped empty round-trips fine even when the
1793            // boundary drops it.
1794            NativeEffect::SpawnTerminal {
1795                cmd_line: None,
1796                env: Vec::new(),
1797                activate_minor: None,
1798                cwd: Some("/work/api".into()),
1799            },
1800            NativeEffect::TerminalInput(vec![0x1b]),
1801            NativeEffect::SetOption {
1802                spec: "wrap".into(),
1803            },
1804            NativeEffect::SetLocalOption {
1805                spec: "number".into(),
1806            },
1807            NativeEffect::SetGlobalOption {
1808                spec: "hlsearch".into(),
1809            },
1810            NativeEffect::SetColorscheme("nord".into()),
1811            NativeEffect::Echo {
1812                level: NativeEchoLevel::Warn,
1813                text: "careful".into(),
1814            },
1815            NativeEffect::ShowDiagnosticsPopup {
1816                lines: vec![("E0308: mismatched types".into(), 0), ("hint".into(), 3)],
1817            },
1818            NativeEffect::Lsp(NativeLspRequest::Definition),
1819            NativeEffect::Lsp(NativeLspRequest::FollowLink),
1820            NativeEffect::Substitute {
1821                scope: NativeSubstituteScope::Whole,
1822                pattern: "foo".into(),
1823                replacement: "bar".into(),
1824                global: true,
1825            },
1826            NativeEffect::DescribeCommand {
1827                name: "write".into(),
1828                anchor: Some("arg:path".into()),
1829            },
1830            NativeEffect::Apropos {
1831                pattern: "buf".into(),
1832            },
1833            NativeEffect::DescribeKey { chord: "gd".into() },
1834            NativeEffect::OpenPicker {
1835                source: "files".into(),
1836                args: vec!["src".into(), "*.rs".into()],
1837                root: None,
1838                fill_action: None,
1839                query: None,
1840            },
1841            // PC.1: a POPULATED root, for the reason above — the `None` case
1842            // alone cannot tell a carried field from a dropped one.
1843            NativeEffect::OpenPicker {
1844                source: "grep".into(),
1845                args: vec!["needle".into()],
1846                root: Some("/work/api".into()),
1847                fill_action: None,
1848                query: None,
1849            },
1850            // PC.11: a POPULATED fill action, for the same reason one line up
1851            // — and it is not hypothetical here. The mechanical pass that
1852            // added this field to every construction site put `None` on the
1853            // wit→native arm too, which is the direction where this field IS
1854            // the feature: the guest names an action, and a `None` there
1855            // would leave it naming one that never receives anything. That is
1856            // the shape `InvokeCommand`'s `args` had for two releases —
1857            // declared, populated, destructured away — with two comments
1858            // recording it as "a dead end" rather than fixing it.
1859            NativeEffect::OpenPicker {
1860                source: "dir-pick".into(),
1861                args: vec!["~".into()],
1862                root: None,
1863                fill_action: Some("project-remember-and-switch".into()),
1864                query: None,
1865            },
1866            // CD.6a: a POPULATED seed, for the same reason as the two above.
1867            NativeEffect::OpenPicker {
1868                source: "org-roam-insert".into(),
1869                args: Vec::new(),
1870                root: None,
1871                fill_action: None,
1872                query: Some("rust async".into()),
1873            },
1874            NativeEffect::BufferDelete { force: true },
1875            NativeEffect::OpenFileTree {
1876                root: Some(PathBuf::from("/proj")),
1877            },
1878            NativeEffect::OpenOil { dir: None },
1879            NativeEffect::DescribeOption {
1880                name: "wrap".into(),
1881            },
1882            NativeEffect::DescribeElement {
1883                name: "modeline.normal".into(),
1884            },
1885            NativeEffect::OpenHover {
1886                markdown: "**hi**".into(),
1887            },
1888            NativeEffect::OpenHelpTopic {
1889                topic: Some("motions".into()),
1890            },
1891            NativeEffect::OpenLspLog {
1892                server_id: Some("rust-analyzer".into()),
1893            },
1894            NativeEffect::ToggleLspTrace {
1895                server_id: "rust-analyzer".into(),
1896            },
1897            NativeEffect::OpenLspTraceLog { server_id: None },
1898            NativeEffect::LspRestart {
1899                server_id: "rust-analyzer".into(),
1900            },
1901            NativeEffect::LspProgressCancel { server_id: None },
1902            NativeEffect::SetLspLogLevel {
1903                server_id: Some("rust-analyzer".into()),
1904                level: "debug".into(),
1905            },
1906            NativeEffect::LspLogClear { server_id: None },
1907            NativeEffect::LspWorkspaceSymbol {
1908                query: "Foo".into(),
1909            },
1910            NativeEffect::LspRename {
1911                new_name: "renamed".into(),
1912            },
1913            NativeEffect::ExpandSnippet {
1914                replace_range: NativeRange {
1915                    start: pos(0, 0),
1916                    end: pos(0, 2),
1917                },
1918            },
1919            NativeEffect::DiffOff { force: true },
1920            NativeEffect::Diffsplit {
1921                path: PathBuf::from("/a/base.rs"),
1922                remote: Some(PathBuf::from("/a/remote.rs")),
1923            },
1924            NativeEffect::DiffGetCmd { target: Some(2) },
1925            NativeEffect::DiffPutCmd { target: None },
1926            NativeEffect::CloseSessionDiffs {
1927                origin_session: 7,
1928                tab_name: "/a/b.rs".into(),
1929            },
1930            NativeEffect::CloseAllSessionDiffs { origin_session: 7 },
1931            NativeEffect::DescribeEvent {
1932                name: "document-changed".into(),
1933            },
1934            NativeEffect::DescribeMode {
1935                name: "lsp-mode".into(),
1936            },
1937            NativeEffect::DescribeOptionResolution {
1938                name: "wrap".into(),
1939            },
1940            NativeEffect::Customize { name: None },
1941            NativeEffect::Tutor { lesson: Some(3) },
1942            NativeEffect::ToggleMode {
1943                mode_name: "diff-mode".into(),
1944            },
1945        ];
1946        for case in cases {
1947            assert_effect_round_trips(case);
1948        }
1949    }
1950
1951    /// A representative sample of the unit (no-payload) arms.
1952    #[test]
1953    fn effect_unit_arms_round_trip() {
1954        for e in [
1955            NativeEffect::ClearSearchHighlight,
1956            NativeEffect::EchoRegisters,
1957            NativeEffect::EchoMarks,
1958            NativeEffect::DeleteCurrentLine,
1959            NativeEffect::DescribeBuffer,
1960            NativeEffect::ListKeymap,
1961            NativeEffect::BufferNext,
1962            NativeEffect::BufferPrev,
1963            NativeEffect::ListBuffers,
1964            NativeEffect::OpenBufferPicker,
1965            NativeEffect::CloseFileTree,
1966            NativeEffect::ListOptions,
1967            NativeEffect::DismissPopup,
1968            // WK.11: populated, not defaulted — the name IS the feature, and a
1969            // round-trip that carried an empty string would pass while losing
1970            // the only field that distinguishes this from `DismissPopup`.
1971            NativeEffect::DismissPopupNamed {
1972                name: "*which-key*".to_string(),
1973            },
1974            NativeEffect::OpenPopup {
1975                name: "*ai-permission*".to_string(),
1976                mode_id: "ai-permission-mode".to_string(),
1977                placement: NativePopupPlacement::Centered,
1978                focus: NativePopupFocus::Steal,
1979            },
1980            NativeEffect::OpenPopup {
1981                name: "hover".to_string(),
1982                mode_id: "hover-mode".to_string(),
1983                placement: NativePopupPlacement::CursorAnchored,
1984                focus: NativePopupFocus::Passive,
1985            },
1986            NativeEffect::ListDiagnostics,
1987            NativeEffect::NextDiagnostic,
1988            NativeEffect::PrevDiagnostic,
1989            NativeEffect::OpenMessages,
1990            NativeEffect::OpenDashboard,
1991            NativeEffect::LspStatus,
1992            NativeEffect::LspServerLogListing,
1993            NativeEffect::LspExpandRegion,
1994            NativeEffect::LspShrinkRegion,
1995            NativeEffect::LspDocumentSymbol,
1996            NativeEffect::LspIncomingCalls,
1997            NativeEffect::LspOutgoingCalls,
1998            NativeEffect::LspSupertypes,
1999            NativeEffect::LspSubtypes,
2000            NativeEffect::LspMoniker,
2001            NativeEffect::LspCodeLens,
2002            NativeEffect::LspColorPresentation,
2003            NativeEffect::LspFormat,
2004            NativeEffect::LspFormatRange,
2005            NativeEffect::LspSignatureHelp,
2006            NativeEffect::LspComplete,
2007            NativeEffect::LspCodeAction,
2008            NativeEffect::ReloadSnippets,
2009            NativeEffect::DescribeEvents,
2010            NativeEffect::DescribeDiff,
2011            NativeEffect::DiffOpen,
2012            NativeEffect::Diffthis,
2013            NativeEffect::DiffAccept,
2014            NativeEffect::DiffReject,
2015            NativeEffect::DiffAcceptAll,
2016            NativeEffect::DiffRejectAll,
2017            NativeEffect::NextHunk,
2018            NativeEffect::PrevHunk,
2019            NativeEffect::ListModes,
2020            NativeEffect::RecordJump,
2021        ] {
2022            assert_effect_round_trips(e);
2023        }
2024    }
2025
2026    /// The 5 `effect`-only helper enums round-trip every variant.
2027    #[test]
2028    fn effect_helper_enums_round_trip() {
2029        for s in [NativeQuitScope::Pane, NativeQuitScope::All] {
2030            assert_eq!(s, NativeQuitScope::from_wit(s.to_wit().unwrap()).unwrap());
2031        }
2032        for l in [
2033            NativeEchoLevel::Trace,
2034            NativeEchoLevel::Debug,
2035            NativeEchoLevel::Info,
2036            NativeEchoLevel::Warn,
2037            NativeEchoLevel::Error,
2038        ] {
2039            assert_eq!(l, NativeEchoLevel::from_wit(l.to_wit().unwrap()).unwrap());
2040        }
2041        for s in [
2042            NativeSubstituteScope::CurrentLine,
2043            NativeSubstituteScope::Whole,
2044        ] {
2045            assert_eq!(
2046                s,
2047                NativeSubstituteScope::from_wit(s.to_wit().unwrap()).unwrap()
2048            );
2049        }
2050        let u = NativeUtf16Pos { line: 4, col: 9 };
2051        assert_eq!(u, NativeUtf16Pos::from_wit(u.to_wit().unwrap()).unwrap());
2052        for r in [
2053            NativeLspRequest::Hover,
2054            NativeLspRequest::Definition,
2055            NativeLspRequest::Declaration,
2056            NativeLspRequest::TypeDefinition,
2057            NativeLspRequest::Implementation,
2058            NativeLspRequest::References,
2059            NativeLspRequest::FollowLink,
2060        ] {
2061            assert_eq!(r, NativeLspRequest::from_wit(r.to_wit().unwrap()).unwrap());
2062        }
2063    }
2064
2065    /// `Many` crosses as `list<effect>`: `to_wit` flattens, `from_wit` rebuilds
2066    /// `Many` when the list has >1 element. Nested `Many` flattens too
2067    /// (associativity), so `Many([a, Many([b, c])])` normalises to
2068    /// `Many([a, b, c])` — a semantic identity.
2069    #[test]
2070    fn many_flattens_and_rebuilds() {
2071        let many = NativeEffect::Many(vec![
2072            NativeEffect::RecordJump,
2073            NativeEffect::Many(vec![
2074                NativeEffect::BufferNext,
2075                NativeEffect::ClearSearchHighlight,
2076            ]),
2077        ]);
2078        let wit = many.to_wit().unwrap();
2079        assert_eq!(wit.len(), 3, "nested Many flattens to 3 atoms");
2080        let back = NativeEffect::from_wit(wit).unwrap();
2081        match back {
2082            NativeEffect::Many(list) => {
2083                assert_eq!(list.len(), 3);
2084                assert!(matches!(list[0], NativeEffect::RecordJump));
2085                assert!(matches!(list[1], NativeEffect::BufferNext));
2086                assert!(matches!(list[2], NativeEffect::ClearSearchHighlight));
2087            }
2088            other => panic!("expected Many, got {other:?}"),
2089        }
2090    }
2091
2092    /// A single-element list reconstructs the atom (not a `Many`); an empty
2093    /// list is `Effect::None`.
2094    #[test]
2095    fn single_element_and_empty_list_normalise() {
2096        let one = NativeEffect::from_wit(vec![WitEffect::BufferNext]).unwrap();
2097        assert!(matches!(one, NativeEffect::BufferNext));
2098        let empty = NativeEffect::from_wit(Vec::new()).unwrap();
2099        assert!(matches!(empty, NativeEffect::None));
2100    }
2101
2102    /// `Global` cannot cross yet (§4.1); it surfaces as a typed error, never a
2103    /// panic or a lossy encoding — even nested inside a `Many`, the error
2104    /// propagates out of the whole conversion.
2105    #[test]
2106    fn global_is_a_typed_error() {
2107        use lattice_grammar::CommandId;
2108        use lattice_grammar::command::CommandInvocation;
2109
2110        let global = NativeEffect::Global {
2111            pattern: "TODO".into(),
2112            inverted: false,
2113            body: Box::new(CommandInvocation::of(CommandId::new(0))),
2114        };
2115        let err = global.to_wit().expect_err("Global must not cross yet");
2116        assert!(err.contains("Global"), "error names the culprit: {err}");
2117
2118        // Nested inside a Many, the error still propagates.
2119        let nested = NativeEffect::Many(vec![NativeEffect::RecordJump, global]);
2120        assert!(
2121            nested.to_wit().is_err(),
2122            "nested Global fails the whole list"
2123        );
2124    }
2125
2126    /// PH7.3b2: `AppAction` now crosses (the `AppEffect` mirror landed). A
2127    /// representable `AppEffect` round-trips through the `effect` boundary; an
2128    /// `AppEffect::NarrowTrigger` (recursive `Range`) still propagates its typed
2129    /// error out of the `AppAction` arm.
2130    #[test]
2131    fn app_action_crosses_and_narrow_trigger_still_errors() {
2132        use lattice_grammar::app_effect::AppEffect;
2133
2134        let app = NativeEffect::AppAction(AppEffect::SplitPaneVertical);
2135        assert_effect_round_trips(app);
2136
2137        // A whole Many of AppActions round-trips too.
2138        assert_effect_round_trips(NativeEffect::Many(vec![
2139            NativeEffect::AppAction(AppEffect::Quit),
2140            NativeEffect::RecordJump,
2141            NativeEffect::AppAction(AppEffect::NextTab),
2142        ]));
2143
2144        let narrow = NativeEffect::AppAction(AppEffect::NarrowTrigger { range: None });
2145        let err = narrow
2146            .to_wit()
2147            .expect_err("NarrowTrigger-carrying AppAction must not cross yet");
2148        assert!(
2149            err.contains("NarrowTrigger"),
2150            "error names the culprit: {err}"
2151        );
2152    }
2153
2154    /// **OC.7a: the guest's seed text, caret and minor reach the native
2155    /// effect.**
2156    ///
2157    /// The direction that matters, and the one the feature is impossible
2158    /// without: a plugin mode has no `on_activate` (the `modes` seam exports
2159    /// `register-modes` and nothing else), so if these three do not cross, a
2160    /// guest opens a buffer it can never put a character into.
2161    ///
2162    /// Asserted per field rather than as a round-trip, because the reverse
2163    /// direction deliberately drops them — a native emitter's values have no
2164    /// guest-side meaning — so an equality test would be asserting the wrong
2165    /// thing.
2166    #[test]
2167    fn oc7a_guest_seed_reaches_the_native_open() {
2168        let wit = WitEffect::OpenSyntheticBuffer(WitOpenSyntheticBufferPayload {
2169            name: "*org-capture*".to_string(),
2170            mode_id: "org-mode".to_string(),
2171            content: Some("* TODO \n  body".to_string()),
2172            cursor: Some(WitPosition { line: 0, byte: 7 }),
2173            activate_minor: Some("org-capture-mode".to_string()),
2174        });
2175        match effect_from_wit(wit).expect("from_wit") {
2176            NativeEffect::OpenSyntheticBuffer {
2177                name,
2178                mode_id,
2179                content,
2180                cursor,
2181                activate_minor,
2182            } => {
2183                assert_eq!(name, "*org-capture*");
2184                assert_eq!(mode_id, "org-mode", "the capture buffer IS an org buffer");
2185                assert_eq!(content.as_deref(), Some("* TODO \n  body"));
2186                assert_eq!(cursor, Some(pos(0, 7)), "the `%?` point");
2187                assert_eq!(
2188                    activate_minor.as_deref(),
2189                    Some("org-capture-mode"),
2190                    "the minor carries C-c C-c / C-c C-k without putting them \
2191                     on every org buffer"
2192                );
2193            }
2194            other => panic!("wrong variant: {other:?}"),
2195        }
2196    }
2197
2198    /// A guest that seeds nothing gets the pre-OC.7a effect exactly — the
2199    /// overwhelmingly common case must not have moved.
2200    #[test]
2201    fn oc7a_an_unseeded_open_is_unchanged() {
2202        let wit = WitEffect::OpenSyntheticBuffer(WitOpenSyntheticBufferPayload {
2203            name: "*plugins*".to_string(),
2204            mode_id: "plugin-manager-mode".to_string(),
2205            content: None,
2206            cursor: None,
2207            activate_minor: None,
2208        });
2209        match effect_from_wit(wit).expect("from_wit") {
2210            NativeEffect::OpenSyntheticBuffer {
2211                content,
2212                cursor,
2213                activate_minor,
2214                ..
2215            } => {
2216                assert!(content.is_none() && cursor.is_none() && activate_minor.is_none());
2217            }
2218            other => panic!("wrong variant: {other:?}"),
2219        }
2220    }
2221}