3291 lines
126 KiB
Rust
3291 lines
126 KiB
Rust
// protocol.rs --- Frontend ↔ Instance message protocol.
|
||
|
||
//! Frontend ↔ Instance typed message protocol (T M5.1).
|
||
//!
|
||
//! Spec §sec:m5-remote, §sec:v01-remote-scope deliverable 1.
|
||
//!
|
||
//! The TUI of v0.1 is a frontend talking to its instance over an in-process
|
||
//! channel. The remote case (M5.7) adds nothing to the instance side; it adds
|
||
//! a network transport on the frontend side. The protocol shape is symmetric
|
||
//! over transports.
|
||
//!
|
||
//! # Module surface
|
||
//!
|
||
//! - [`FrontendId`]: opaque per-frontend identity. v0.1 uses
|
||
//! [`FrontendId::LOCAL`] for the single attached frontend.
|
||
//! - [`Key`] + [`Modifiers`] + [`KeyEvent`]: pmacs-native key encoding.
|
||
//! Independent of any specific terminal protocol so the wire is stable
|
||
//! when M5.7 ships SSH.
|
||
//! - [`MouseEvent`] + [`MouseKind`] + [`MouseButton`]: pmacs-native mouse
|
||
//! encoding.
|
||
//! - [`FrontendEvent`]: input from frontend to instance.
|
||
//! - [`InstanceMessage`]: rendering and signals from instance to frontend.
|
||
//! - [`AttachTarget`]: addressing for remote attachment. Two variants
|
||
//! (`LocalSocket`, `Ssh`) are v0.1; `Tls` and `Custom` are reserved
|
||
//! and return [`AttachError::NotImplementedInV01`] when invoked.
|
||
//!
|
||
//! # Wire stability
|
||
//!
|
||
//! These types must remain backwards-compatible across v0.1 patch
|
||
//! releases. Adding variants requires explicit consideration of the
|
||
//! v0.3 multi-frontend generalization (cf. spec §sec:remote). New
|
||
//! fields prefer optional extension over breaking change. The
|
||
//! `Unknown` keycode variant exists so that future terminal protocols
|
||
//! that surface unrecognized keycodes do not require a protocol break.
|
||
//!
|
||
//! # Translation layer
|
||
//!
|
||
//! [`crossterm_translate`] converts the TUI's `crossterm::event` types
|
||
//! into the protocol types. It is the only place in the protocol module
|
||
//! that touches `crossterm`. SSH transports do not use this submodule;
|
||
//! they decode the wire directly into [`KeyEvent`] / [`MouseEvent`].
|
||
|
||
use crate::cell::{Cell, CellCoord, CellSize, DiffSpan};
|
||
use std::path::PathBuf;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Frontend identity
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Opaque identifier for a frontend attached to an instance.
|
||
///
|
||
/// Every input event carries a `FrontendId`. v0.1 uses one ID per
|
||
/// instance ([`FrontendId::LOCAL`]); v0.3 generalizes to multi-frontend
|
||
/// (multi-window, multi-user) without a protocol break.
|
||
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)]
|
||
pub struct FrontendId(pub u64);
|
||
|
||
impl FrontendId {
|
||
/// The single frontend used in v0.1's local-attach mode.
|
||
///
|
||
/// Future multi-frontend deployments allocate IDs from a counter
|
||
/// starting after this value; the constant is reserved.
|
||
pub const LOCAL: FrontendId = FrontendId(1);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Key encoding
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Key code, normalized away from any specific terminal protocol.
|
||
///
|
||
/// `Char` covers printable input. The named variants cover the keys
|
||
/// terminals report distinctly (arrows, function keys, etc.). `Unknown`
|
||
/// is the escape hatch: a key the protocol layer cannot encode in
|
||
/// any of the named variants is preserved as a u32 sentinel so it
|
||
/// can round-trip through serialization without becoming an error.
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
|
||
pub enum Key {
|
||
/// A printable character. The character is the user-visible
|
||
/// codepoint after layout / IME processing.
|
||
Char(char),
|
||
/// A function key. `n` is 1-based: `F(1)` is F1.
|
||
F(u8),
|
||
/// Backspace / `^H`.
|
||
Backspace,
|
||
/// Enter / Return / `^M`.
|
||
Enter,
|
||
/// Left arrow.
|
||
Left,
|
||
/// Right arrow.
|
||
Right,
|
||
/// Up arrow.
|
||
Up,
|
||
/// Down arrow.
|
||
Down,
|
||
/// Home key.
|
||
Home,
|
||
/// End key.
|
||
End,
|
||
/// Page Up.
|
||
PageUp,
|
||
/// Page Down.
|
||
PageDown,
|
||
/// Tab.
|
||
Tab,
|
||
/// Shift-Tab.
|
||
BackTab,
|
||
/// Forward delete.
|
||
Delete,
|
||
/// Insert.
|
||
Insert,
|
||
/// Escape.
|
||
Escape,
|
||
/// Caps Lock.
|
||
CapsLock,
|
||
/// Scroll Lock.
|
||
ScrollLock,
|
||
/// Num Lock.
|
||
NumLock,
|
||
/// Print Screen.
|
||
PrintScreen,
|
||
/// Pause / Break.
|
||
Pause,
|
||
/// Menu / context-menu key.
|
||
Menu,
|
||
/// Numeric-keypad center key.
|
||
KeypadBegin,
|
||
/// The "null" keycode (terminal-protocol artifact).
|
||
Null,
|
||
/// A key the protocol layer does not recognize. The `u32`
|
||
/// preserves whatever sentinel value the upstream layer attached
|
||
/// (e.g. a media-key code from kitty's keyboard protocol). Round-trips
|
||
/// through serialization but is not actionable by commands.
|
||
Unknown(u32),
|
||
}
|
||
|
||
/// Modifier-key set. Bit-flag encoding for compact wire shape.
|
||
///
|
||
/// `META` corresponds to the "logo" / "super" key on most keyboards.
|
||
/// `HYPER` is reserved for the rare keyboards that distinguish it
|
||
/// from `META` (kitty's keyboard protocol surfaces both).
|
||
#[derive(
|
||
Copy, Clone, Eq, PartialEq, Hash, Debug, Default, serde::Serialize, serde::Deserialize,
|
||
)]
|
||
pub struct Modifiers(u8);
|
||
|
||
impl Modifiers {
|
||
/// Empty set: no modifiers held.
|
||
pub const NONE: Modifiers = Modifiers(0);
|
||
/// Shift.
|
||
pub const SHIFT: Modifiers = Modifiers(1 << 0);
|
||
/// Control.
|
||
pub const CTRL: Modifiers = Modifiers(1 << 1);
|
||
/// Alt / Option.
|
||
pub const ALT: Modifiers = Modifiers(1 << 2);
|
||
/// Meta / Super / Logo / Command.
|
||
pub const META: Modifiers = Modifiers(1 << 3);
|
||
/// Hyper. Distinguished from `META` only on keyboards that
|
||
/// surface both (kitty's keyboard protocol).
|
||
pub const HYPER: Modifiers = Modifiers(1 << 4);
|
||
|
||
/// Construct from a raw bit set. Bits outside the defined range
|
||
/// are silently masked off so a future-extended wire cannot smuggle
|
||
/// undefined bits past current decoders.
|
||
#[must_use]
|
||
pub const fn from_bits_truncate(bits: u8) -> Self {
|
||
Self(bits & 0b0001_1111)
|
||
}
|
||
|
||
/// Raw bit set.
|
||
#[must_use]
|
||
pub const fn bits(self) -> u8 {
|
||
self.0
|
||
}
|
||
|
||
/// Whether `self` includes every bit set in `other`.
|
||
#[must_use]
|
||
pub const fn contains(self, other: Modifiers) -> bool {
|
||
(self.0 & other.0) == other.0
|
||
}
|
||
|
||
/// Whether no modifiers are held.
|
||
#[must_use]
|
||
pub const fn is_empty(self) -> bool {
|
||
self.0 == 0
|
||
}
|
||
}
|
||
|
||
impl std::ops::BitOr for Modifiers {
|
||
type Output = Modifiers;
|
||
fn bitor(self, rhs: Modifiers) -> Modifiers {
|
||
Modifiers(self.0 | rhs.0)
|
||
}
|
||
}
|
||
|
||
impl std::ops::BitOrAssign for Modifiers {
|
||
fn bitor_assign(&mut self, rhs: Modifiers) {
|
||
self.0 |= rhs.0;
|
||
}
|
||
}
|
||
|
||
/// A keyboard event.
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct KeyEvent {
|
||
/// Frontend that produced the event.
|
||
pub frontend_id: FrontendId,
|
||
/// The key code.
|
||
pub key: Key,
|
||
/// Modifier set held when the key was pressed.
|
||
pub mods: Modifiers,
|
||
/// Monotonic timestamp at which the frontend captured the event.
|
||
/// Zero means "no timestamp available" (e.g. test-synthesized
|
||
/// events).
|
||
pub timestamp_ns: u64,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Mouse encoding
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Mouse button.
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum MouseButton {
|
||
/// Left button.
|
||
Left,
|
||
/// Right button.
|
||
Right,
|
||
/// Middle button.
|
||
Middle,
|
||
}
|
||
|
||
/// Kind of mouse interaction.
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum MouseKind {
|
||
/// Button pressed.
|
||
Down(MouseButton),
|
||
/// Button released.
|
||
Up(MouseButton),
|
||
/// Drag with the named button held.
|
||
Drag(MouseButton),
|
||
/// Pointer moved with no button held.
|
||
Move,
|
||
/// Wheel scrolled up.
|
||
ScrollUp,
|
||
/// Wheel scrolled down.
|
||
ScrollDown,
|
||
/// Wheel scrolled left.
|
||
ScrollLeft,
|
||
/// Wheel scrolled right.
|
||
ScrollRight,
|
||
}
|
||
|
||
/// A mouse event.
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct MouseEvent {
|
||
/// Frontend that produced the event.
|
||
pub frontend_id: FrontendId,
|
||
/// Kind of mouse interaction.
|
||
pub kind: MouseKind,
|
||
/// Cell-grid coordinate of the pointer at the moment of the event.
|
||
pub coord: CellCoord,
|
||
/// Modifiers held during the event.
|
||
pub mods: Modifiers,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Frontend → Instance events
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Input event from frontend to instance.
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum FrontendEvent {
|
||
/// A key event.
|
||
Key(KeyEvent),
|
||
/// A mouse event.
|
||
Mouse(MouseEvent),
|
||
/// Frontend's terminal resized.
|
||
Resize {
|
||
/// Frontend that resized.
|
||
frontend_id: FrontendId,
|
||
/// New size, in cells.
|
||
size: CellSize,
|
||
},
|
||
/// Bracketed-paste payload from the frontend.
|
||
Paste {
|
||
/// Frontend that produced the paste.
|
||
frontend_id: FrontendId,
|
||
/// Raw bytes pasted (the instance decodes as UTF-8 if relevant).
|
||
data: Vec<u8>,
|
||
},
|
||
/// Frontend gained input focus.
|
||
FocusGained(FrontendId),
|
||
/// Frontend lost input focus.
|
||
FocusLost(FrontendId),
|
||
/// Frontend is going away. Instance treats this as immediate
|
||
/// detach; no acknowledgement required.
|
||
Detach(FrontendId),
|
||
/// T M10.5: CRDT operation produced by this frontend's local
|
||
/// edit, sent to the instance for broadcast to the other
|
||
/// attached frontends. The actual flow that produces these
|
||
/// (frontend maintaining a local CRDT state, applying edits
|
||
/// optimistically, sending the resulting op) is wired in M10.8
|
||
/// + M10.10; M10.5 declares the wire shape so the protocol
|
||
/// version bump (1 → 2) covers it.
|
||
///
|
||
/// Only sent by v1.0 frontends (`protocol_version = 2`); v0.1
|
||
/// frontends never emit this variant. Sessions negotiated at
|
||
/// protocol version 1 must NOT receive this on the
|
||
/// instance-side dispatcher (the daemon filters per-session;
|
||
/// the editor-core treats it as an unknown frontend event if
|
||
/// it ever arrives from a v1 session, which it shouldn't).
|
||
CrdtOp {
|
||
/// Which attached frontend produced this op. The instance
|
||
/// uses this to avoid echoing the op back to its sender.
|
||
frontend_id: FrontendId,
|
||
/// Which buffer this op affects. The instance routes the
|
||
/// op to that buffer's CRDT state.
|
||
buffer_id: crate::buffer::BufferId,
|
||
/// The CRDT operation payload — `peer_id` + opaque wire bytes
|
||
/// loro's `import_updates` decodes.
|
||
op: crate::rope::CrdtOp,
|
||
},
|
||
}
|
||
|
||
impl FrontendEvent {
|
||
/// The frontend that produced this event.
|
||
#[must_use]
|
||
pub fn frontend_id(&self) -> FrontendId {
|
||
match self {
|
||
Self::Key(e) => e.frontend_id,
|
||
Self::Mouse(e) => e.frontend_id,
|
||
Self::Resize { frontend_id, .. }
|
||
| Self::Paste { frontend_id, .. }
|
||
| Self::FocusGained(frontend_id)
|
||
| Self::FocusLost(frontend_id)
|
||
| Self::Detach(frontend_id)
|
||
| Self::CrdtOp { frontend_id, .. } => *frontend_id,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Instance → Frontend messages
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Cursor position and visibility.
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct CursorState {
|
||
/// Cell where the cursor should be drawn.
|
||
pub coord: CellCoord,
|
||
/// Whether the cursor is visible at all.
|
||
pub visible: bool,
|
||
}
|
||
|
||
/// Instance-level signal that is not a render message.
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum InstanceSignal {
|
||
/// Terminal bell.
|
||
Bell,
|
||
/// Window-title change request.
|
||
Title(String),
|
||
/// Clipboard set request (OSC 52).
|
||
Clipboard(Vec<u8>),
|
||
}
|
||
|
||
/// Reason an instance terminates an attachment.
|
||
///
|
||
/// Only the four variants the v0.1 daemon actually emits or rejects on.
|
||
/// `Evicted` (multi-frontend takeover) and similar will land alongside
|
||
/// the v0.3 multi-frontend work; until then `AlreadyAttached` covers
|
||
/// the single-slot equivalent.
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum GoodbyeReason {
|
||
/// Instance is shutting down (SIGTERM / SIGINT or clean exit).
|
||
ShuttingDown,
|
||
/// Frontend's `protocol_version` does not match the instance's.
|
||
/// The handshake fails before any further messages.
|
||
VersionMismatch {
|
||
/// The instance's `PROTOCOL_VERSION`.
|
||
server: u32,
|
||
/// The version the frontend announced in its `AttachRequest`.
|
||
client: u32,
|
||
},
|
||
/// Another frontend is currently attached. v0.1 rejects concurrent
|
||
/// attaches; v0.3 will replace this with eviction or multiplexing.
|
||
AlreadyAttached,
|
||
/// Frontend sent a malformed message or otherwise violated the
|
||
/// protocol. The connection is closed without further dialogue.
|
||
ProtocolError,
|
||
/// T M10.7: frontend declared one or more negotiated capability
|
||
/// bits that the instance cannot honor. The handshake fails after
|
||
/// the version check but before any further messages.
|
||
///
|
||
/// `missing` lists the capability *field names* (e.g.,
|
||
/// `"multi_frontend"`, `"crdt_replica"`) the frontend requested
|
||
/// (`true`) that the instance reports as `false`. These strings
|
||
/// are stable wire-format identifiers: they are exactly the
|
||
/// `FrontendCapabilities` / `InstanceCapabilities` field names,
|
||
/// not human-readable descriptions. The frontend translates them
|
||
/// for display via [`AttachError`]'s formatting. Renaming a
|
||
/// capability bit requires changing both the field name AND the
|
||
/// missing-string emission in `negotiate_capabilities` in
|
||
/// lockstep — see the M10.7 audit's wire-format-stability
|
||
/// section.
|
||
CapabilityMismatch {
|
||
/// The capability bit names the frontend asked for that the
|
||
/// instance does not support. Each entry is a verbatim
|
||
/// `FrontendCapabilities` field name.
|
||
missing: Vec<String>,
|
||
},
|
||
}
|
||
|
||
/// Rendering and signals from instance to frontend.
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum InstanceMessage {
|
||
/// Cell deltas. `full_grid = true` is the initial sync sent on
|
||
/// fresh attach (or after a resize where the previous grid is no
|
||
/// longer applicable); `full_grid = false` is a differential
|
||
/// frame.
|
||
CellDelta {
|
||
/// One run of changed cells per `DiffSpan`.
|
||
spans: Vec<DiffSpan>,
|
||
/// Whether `spans` represents a full-grid resync (true on
|
||
/// fresh attach or post-resize) versus an incremental frame.
|
||
full_grid: bool,
|
||
},
|
||
/// Cursor position and visibility update.
|
||
Cursor(Option<CursorState>),
|
||
/// Modeline cells. Reserved for v0.3 GUI use; v0.1 ships modeline
|
||
/// inside [`InstanceMessage::CellDelta`]. The variant exists in
|
||
/// the protocol from day one so adding the discrete channel later
|
||
/// is not a breaking change.
|
||
ModeLine(Vec<Cell>),
|
||
/// Side-channel signal (bell, title, clipboard).
|
||
Signal(InstanceSignal),
|
||
/// Instance is terminating the attachment.
|
||
Goodbye(GoodbyeReason),
|
||
/// T M10.5: CRDT operation broadcast from the instance to all
|
||
/// attached frontends. The originating frontend produced this op
|
||
/// (via `FrontendEvent::CrdtOp` or via a local editor-core edit
|
||
/// that synthesizes one); the instance fans it out so every
|
||
/// attached frontend can apply the op to its local CRDT state.
|
||
///
|
||
/// Only sent to v1.0 frontends — sessions negotiated at
|
||
/// `protocol_version = 1` never receive this variant, per
|
||
/// `§sec:m10-backward-compat`. The daemon filters at the
|
||
/// outgoing-message path; this variant simply existing in the
|
||
/// enum is not a wire-compat issue for v1 sessions because the
|
||
/// daemon never emits it to them.
|
||
///
|
||
/// M10.5 declares the wire shape. M10.8 wires the editor-core →
|
||
/// daemon → frontend flow that actually emits these.
|
||
CrdtOp {
|
||
/// Which buffer this op affects. v1.0 frontends maintain
|
||
/// a per-buffer local CRDT state; this routes to the right
|
||
/// one.
|
||
buffer_id: crate::buffer::BufferId,
|
||
/// The CRDT operation payload — `peer_id` + opaque wire bytes
|
||
/// loro's `import_updates` decodes.
|
||
op: crate::rope::CrdtOp,
|
||
},
|
||
/// T M10.6: cursor + selection state of one attached frontend,
|
||
/// broadcast to the other v1.0 frontends so they can render
|
||
/// peer-presence overlays. Coalesced at the daemon: rapid cursor
|
||
/// movement produces one `PresenceUpdate` per tick per source
|
||
/// frontend, carrying the *final* state, not intermediate values.
|
||
///
|
||
/// Sender exclusion: the source frontend never receives its own
|
||
/// `PresenceUpdate`. v0.1 sessions (negotiated `protocol_version =
|
||
/// 1`) are filtered out at the daemon's outgoing-message path.
|
||
///
|
||
/// M10.6 declares the wire shape AND wires the daemon-side
|
||
/// sweep with per-session filter. In single-frontend deployments
|
||
/// the recipient list is structurally empty (sender exclusion
|
||
/// with no other v2 sessions); M10.8 enables the multi-frontend
|
||
/// case where this message actually crosses the wire. The
|
||
/// frontend's renderer for peer-cursor overlays is also M10.8.
|
||
PresenceUpdate {
|
||
/// Which attached frontend this presence belongs to. v1.0
|
||
/// frontends use this to label the peer-cursor overlay
|
||
/// ("user 4 is editing here").
|
||
frontend_id: FrontendId,
|
||
/// Which buffer the source frontend's cursor is in.
|
||
buffer_id: crate::buffer::BufferId,
|
||
/// Byte offset of the source frontend's cursor within
|
||
/// `buffer_id`. Frontends convert to line/column at render
|
||
/// time via the rope's coord-mapping; the wire carries the
|
||
/// canonical byte offset to avoid encoding-vs-rendering
|
||
/// drift across frontends.
|
||
cursor: crate::rope::Position,
|
||
/// Active selection range, if any.
|
||
selection: Option<SelectionSnapshot>,
|
||
},
|
||
/// T M10.10: bootstrap a frontend's local CRDT replica with the
|
||
/// instance's current authoritative state. Sent once per active
|
||
/// buffer at `SessionEstablished` time (and on subsequent
|
||
/// buffer-creation events) to frontends that negotiated
|
||
/// `crdt_replica: true`. Frontends that didn't negotiate the
|
||
/// capability never receive this variant — the daemon's
|
||
/// outgoing-message filter gates the send on
|
||
/// `NegotiatedCapabilities::crdt_replica`.
|
||
///
|
||
/// `crdt_snapshot` carries loro's run-encoded snapshot
|
||
/// (`CrdtState::export_snapshot()`) — the CRDT-internal state
|
||
/// including peer IDs, version vectors, and op-history structure.
|
||
/// Raw byte contents are insufficient because a fresh CRDT replica
|
||
/// initialized from bytes alone diverges on the first concurrent
|
||
/// edit.
|
||
///
|
||
/// Cursor position is intentionally absent: cursor is per-frontend
|
||
/// window state (M10.8 `FrontendView`), not per-buffer CRDT
|
||
/// state. The same buffer can appear in multiple windows on one
|
||
/// frontend with different cursors; coupling cursor to
|
||
/// `BufferSnapshot` would break this model.
|
||
BufferSnapshot {
|
||
/// Which buffer's CRDT state this snapshot represents.
|
||
buffer_id: crate::buffer::BufferId,
|
||
/// `loro::LoroDoc::export(ExportMode::Snapshot)` output. Applied
|
||
/// to a fresh `CrdtState::new(peer_id_from_frontend(my_id))`
|
||
/// via `import_snapshot(bytes)` on the receiving frontend.
|
||
crdt_snapshot: Vec<u8>,
|
||
},
|
||
/// T M10.10: the active buffer for a replica frontend, with the
|
||
/// cursor position within it.
|
||
///
|
||
/// # Semantics (Day 3 step 3b composition-check broadened
|
||
/// contract)
|
||
///
|
||
/// `CursorByte` represents "the active buffer for this frontend
|
||
/// is `buffer_id`; the cursor in that buffer is at `byte_pos`."
|
||
/// Not just "the cursor moved." This contract matters: a narrow
|
||
/// "cursor moved" emission would miss active-buffer-changed-
|
||
/// without-cursor-motion events (Lua-driven buffer switch
|
||
/// landing at the same byte position), and the frontend's
|
||
/// active-buffer tracking would go stale.
|
||
///
|
||
/// Daemon emits `CursorByte` on every per-tick render frame for
|
||
/// replica frontends, derived fresh from `active_window_for(fid)`.
|
||
/// Cursor move, active-buffer change, and active-window change
|
||
/// all produce a new emission carrying the current `(buffer_id,
|
||
/// byte_pos)`. The per-tick rate (16ms at 60Hz) is the same as
|
||
/// `Cursor`'s grid-coord variant.
|
||
///
|
||
/// # Why a separate variant from `Cursor`
|
||
///
|
||
/// `Cursor` carries grid coordinates (row/col cells) — the
|
||
/// frontend uses them to paint the cursor. The optimistic-apply
|
||
/// path needs byte position (CRDT insert/delete is byte-indexed),
|
||
/// which the grid coordinate can't recover without duplicating
|
||
/// the daemon's view-layout logic (tab expansion, line wrap,
|
||
/// double-width chars, viewport offset). `CursorByte` is the
|
||
/// authoritative byte position for the active buffer.
|
||
///
|
||
/// # Atomicity with `Cursor`
|
||
///
|
||
/// The daemon emits `Cursor` and `CursorByte` together for
|
||
/// replica frontends — both derived from the same render-frame
|
||
/// iteration so they describe the cursor in the same instant in
|
||
/// two reference frames. Non-replica frontends receive only
|
||
/// `Cursor` (existing behavior). The replica frontend that sees
|
||
/// `Cursor` without a paired `CursorByte` would interpret stale
|
||
/// byte position; the daemon guarantees both emit together by
|
||
/// derivation, not by message-protocol atomicity.
|
||
///
|
||
/// # Wire-format compatibility
|
||
///
|
||
/// New variant in v2; receivers without M10.10 hard-error on
|
||
/// decode (postcard does not gracefully degrade unknown variants,
|
||
/// per M10.10-FRAMING.md Refinement 3). Capability-gated: daemon
|
||
/// sends only to frontends that negotiated `crdt_replica: true`.
|
||
/// `PROTOCOL_VERSION` stays at 2.
|
||
CursorByte {
|
||
/// The buffer the cursor is in. A replica frontend tracks
|
||
/// per-buffer cursors; this routes the update to the right
|
||
/// entry.
|
||
buffer_id: crate::buffer::BufferId,
|
||
/// Byte offset of the cursor within `buffer_id`. Source of
|
||
/// truth for the optimistic-apply path's insert / delete
|
||
/// position arguments. Wire type matches
|
||
/// `PresenceUpdate::cursor` (`u64`) for consistency; frontend
|
||
/// converts to `usize` for the loro API.
|
||
byte_pos: crate::rope::Position,
|
||
},
|
||
}
|
||
|
||
/// Flat selection state for the wire.
|
||
///
|
||
/// Mirrors [`crate::window::Selection`] but as a self-contained pair
|
||
/// of byte offsets — `anchor` is where the selection began,
|
||
/// `active` is the current selection cursor. Either may be the
|
||
/// numerically larger value; callers wanting `(lo, hi)` order
|
||
/// compute it locally.
|
||
///
|
||
/// Kept flat (no nested types) so [`PartialEq`] equality is exactly
|
||
/// wire-representation equality: two `SelectionSnapshot`s compare
|
||
/// equal iff they serialize to identical bytes. The presence-diff
|
||
/// sweep relies on this — see [`crate::presence::SessionRegistry`].
|
||
#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct SelectionSnapshot {
|
||
/// Where the selection began.
|
||
pub anchor: crate::rope::Position,
|
||
/// The active end (typically the cursor at the moment of the
|
||
/// snapshot).
|
||
pub active: crate::rope::Position,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Attachment
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Where to attach. v0.1 implements `LocalSocket` and `Ssh`; `Tls` and
|
||
/// `Custom` are reserved and return [`AttachError::NotImplementedInV01`]
|
||
/// when validated via [`AttachTarget::check_v01`].
|
||
///
|
||
/// # String form
|
||
///
|
||
/// [`AttachTarget::parse`] accepts a human-readable string of the form
|
||
/// `kind:body`, with these grammars:
|
||
///
|
||
/// - `local:<path>`
|
||
/// - `ssh:[user@]host[/instance_name]`
|
||
/// - `tls:<endpoint>#<cert_path>`
|
||
/// - `custom:<argv space-split>`
|
||
///
|
||
/// [`Display`](std::fmt::Display) round-trips through [`parse`](Self::parse).
|
||
///
|
||
/// # Validation
|
||
///
|
||
/// [`validate`](Self::validate) catches semantic problems (empty fields,
|
||
/// embedded null bytes, invalid characters in usernames or instance
|
||
/// names) regardless of how the target was constructed. [`parse`](Self::parse)
|
||
/// runs validation as its final step, so any target that exits parsing
|
||
/// is locally well-formed. Lua kwargs callers that build the variants
|
||
/// directly must call `validate` before storing.
|
||
#[derive(Clone, Debug, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
|
||
pub enum AttachTarget {
|
||
/// Local Unix-socket transport: attach to a daemonized local instance.
|
||
LocalSocket(PathBuf),
|
||
/// SSH transport: spawn `ssh <host> pmacs --daemon-attach` and bridge
|
||
/// its stdio to the local frontend.
|
||
Ssh {
|
||
/// Host alias or address; resolved through `~/.ssh/config`.
|
||
host: String,
|
||
/// Optional explicit username override.
|
||
user: Option<String>,
|
||
/// Optional named instance on the far side (defaults to
|
||
/// `default`, mapping to the per-user default daemon).
|
||
instance_name: Option<String>,
|
||
},
|
||
/// TLS transport. **Reserved** — returns
|
||
/// [`AttachError::NotImplementedInV01`] in v0.1.
|
||
Tls {
|
||
/// `host:port` endpoint to connect to.
|
||
endpoint: String,
|
||
/// Path to a pre-shared certificate.
|
||
cert: PathBuf,
|
||
},
|
||
/// Escape hatch for non-SSH transports (`docker exec`, `kubectl
|
||
/// exec`, `nsenter`, `flatpak-spawn`). **Reserved** — returns
|
||
/// [`AttachError::NotImplementedInV01`] in v0.1.
|
||
Custom {
|
||
/// Argv for the bridging process.
|
||
command: Vec<String>,
|
||
},
|
||
}
|
||
|
||
impl AttachTarget {
|
||
/// Reject the v0.3-only variants up front so the rest of the
|
||
/// attach machinery can assume an implementable target.
|
||
pub fn check_v01(&self) -> Result<(), AttachError> {
|
||
match self {
|
||
Self::LocalSocket(_) | Self::Ssh { .. } => Ok(()),
|
||
Self::Tls { .. } => Err(AttachError::NotImplementedInV01("TLS")),
|
||
Self::Custom { .. } => Err(AttachError::NotImplementedInV01("Custom")),
|
||
}
|
||
}
|
||
|
||
/// Short tag used in diagnostic messages.
|
||
#[must_use]
|
||
pub fn kind_name(&self) -> &'static str {
|
||
match self {
|
||
Self::LocalSocket(_) => "local",
|
||
Self::Ssh { .. } => "ssh",
|
||
Self::Tls { .. } => "tls",
|
||
Self::Custom { .. } => "custom",
|
||
}
|
||
}
|
||
|
||
/// Parse the human-readable string form (`kind:body`).
|
||
///
|
||
/// On success, returns a target that has already been [`validate`](Self::validate)d
|
||
/// — the caller does not need to revalidate. Round-trips with
|
||
/// [`Display`](std::fmt::Display) for every successfully parsed target.
|
||
pub fn parse(s: &str) -> Result<Self, AttachTargetError> {
|
||
let (kind, body) = s.split_once(':').ok_or(AttachTargetError::Parse(
|
||
AttachTargetParseError::MissingColon,
|
||
))?;
|
||
let target = match kind {
|
||
"local" => parse_local_body(body)?,
|
||
"ssh" => parse_ssh_body(body)?,
|
||
"tls" => parse_tls_body(body)?,
|
||
"custom" => parse_custom_body(body)?,
|
||
other => {
|
||
return Err(AttachTargetError::Parse(
|
||
AttachTargetParseError::UnknownKind(other.to_string()),
|
||
));
|
||
}
|
||
};
|
||
target.validate().map_err(AttachTargetError::Validate)?;
|
||
Ok(target)
|
||
}
|
||
|
||
/// Local structural validation. Catches empty required fields,
|
||
/// embedded null bytes, non-UTF-8 paths, and invalid characters in
|
||
/// fields with structural meaning (e.g. `@` in a username, `/` in
|
||
/// an instance name). Does not perform any I/O.
|
||
pub fn validate(&self) -> Result<(), AttachTargetValidationError> {
|
||
match self {
|
||
Self::LocalSocket(p) => {
|
||
let s = p
|
||
.to_str()
|
||
.ok_or(AttachTargetValidationError::NonUtf8Path("path"))?;
|
||
if s.is_empty() {
|
||
return Err(AttachTargetValidationError::EmptyPath);
|
||
}
|
||
if s.contains('\0') {
|
||
return Err(AttachTargetValidationError::NullByte("path"));
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Ssh {
|
||
host,
|
||
user,
|
||
instance_name,
|
||
} => {
|
||
if host.is_empty() {
|
||
return Err(AttachTargetValidationError::EmptyHost);
|
||
}
|
||
if host.contains('\0') {
|
||
return Err(AttachTargetValidationError::NullByte("host"));
|
||
}
|
||
if let Some(u) = user {
|
||
if u.is_empty() {
|
||
return Err(AttachTargetValidationError::EmptyUser);
|
||
}
|
||
if u.contains('\0') {
|
||
return Err(AttachTargetValidationError::NullByte("user"));
|
||
}
|
||
if u.contains('@') {
|
||
return Err(AttachTargetValidationError::InvalidUser(
|
||
"must not contain '@'",
|
||
));
|
||
}
|
||
}
|
||
if let Some(n) = instance_name {
|
||
if n.is_empty() {
|
||
return Err(AttachTargetValidationError::EmptyInstanceName);
|
||
}
|
||
if n.contains('\0') {
|
||
return Err(AttachTargetValidationError::NullByte("instance_name"));
|
||
}
|
||
if n.contains('/') {
|
||
return Err(AttachTargetValidationError::InvalidInstanceName(
|
||
"must not contain '/'",
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Tls { endpoint, cert } => {
|
||
if endpoint.is_empty() {
|
||
return Err(AttachTargetValidationError::EmptyEndpoint);
|
||
}
|
||
if endpoint.contains('\0') {
|
||
return Err(AttachTargetValidationError::NullByte("endpoint"));
|
||
}
|
||
let cert_s = cert
|
||
.to_str()
|
||
.ok_or(AttachTargetValidationError::NonUtf8Path("cert"))?;
|
||
if cert_s.is_empty() {
|
||
return Err(AttachTargetValidationError::EmptyPath);
|
||
}
|
||
if cert_s.contains('\0') {
|
||
return Err(AttachTargetValidationError::NullByte("cert"));
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Custom { command } => {
|
||
if command.is_empty() {
|
||
return Err(AttachTargetValidationError::EmptyCommand);
|
||
}
|
||
for arg in command {
|
||
if arg.contains('\0') {
|
||
return Err(AttachTargetValidationError::NullByte("command"));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn parse_local_body(body: &str) -> Result<AttachTarget, AttachTargetError> {
|
||
if body.is_empty() {
|
||
return Err(AttachTargetError::Parse(AttachTargetParseError::EmptyBody(
|
||
"local",
|
||
)));
|
||
}
|
||
Ok(AttachTarget::LocalSocket(PathBuf::from(body)))
|
||
}
|
||
|
||
fn parse_ssh_body(body: &str) -> Result<AttachTarget, AttachTargetError> {
|
||
if body.is_empty() {
|
||
return Err(AttachTargetError::Parse(AttachTargetParseError::EmptyBody(
|
||
"ssh",
|
||
)));
|
||
}
|
||
let (user_host, instance_name) = match body.split_once('/') {
|
||
Some((uh, n)) => (uh, Some(n.to_string())),
|
||
None => (body, None),
|
||
};
|
||
let (user, host) = match user_host.split_once('@') {
|
||
Some((u, h)) => (Some(u.to_string()), h.to_string()),
|
||
None => (None, user_host.to_string()),
|
||
};
|
||
if host.is_empty() {
|
||
return Err(AttachTargetError::Parse(
|
||
AttachTargetParseError::SshMissingHost,
|
||
));
|
||
}
|
||
Ok(AttachTarget::Ssh {
|
||
host,
|
||
user,
|
||
instance_name,
|
||
})
|
||
}
|
||
|
||
fn parse_tls_body(body: &str) -> Result<AttachTarget, AttachTargetError> {
|
||
if body.is_empty() {
|
||
return Err(AttachTargetError::Parse(AttachTargetParseError::EmptyBody(
|
||
"tls",
|
||
)));
|
||
}
|
||
let (endpoint, cert) = body.split_once('#').ok_or(AttachTargetError::Parse(
|
||
AttachTargetParseError::TlsMissingHash,
|
||
))?;
|
||
Ok(AttachTarget::Tls {
|
||
endpoint: endpoint.to_string(),
|
||
cert: PathBuf::from(cert),
|
||
})
|
||
}
|
||
|
||
fn parse_custom_body(body: &str) -> Result<AttachTarget, AttachTargetError> {
|
||
let command: Vec<String> = body.split_whitespace().map(String::from).collect();
|
||
if command.is_empty() {
|
||
return Err(AttachTargetError::Parse(
|
||
AttachTargetParseError::CustomEmptyCommand,
|
||
));
|
||
}
|
||
Ok(AttachTarget::Custom { command })
|
||
}
|
||
|
||
impl std::fmt::Display for AttachTarget {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::LocalSocket(p) => write!(f, "local:{}", p.display()),
|
||
Self::Ssh {
|
||
host,
|
||
user,
|
||
instance_name,
|
||
} => {
|
||
write!(f, "ssh:")?;
|
||
if let Some(u) = user {
|
||
write!(f, "{u}@")?;
|
||
}
|
||
write!(f, "{host}")?;
|
||
if let Some(n) = instance_name {
|
||
write!(f, "/{n}")?;
|
||
}
|
||
Ok(())
|
||
}
|
||
Self::Tls { endpoint, cert } => write!(f, "tls:{endpoint}#{}", cert.display()),
|
||
Self::Custom { command } => write!(f, "custom:{}", command.join(" ")),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Error returned when an attach attempt fails.
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub enum AttachError {
|
||
/// The target is reserved for a post-v0.1 release.
|
||
NotImplementedInV01(&'static str),
|
||
/// Transport-level I/O failure during attach.
|
||
Io(String),
|
||
/// The frontend already has an active attachment.
|
||
AlreadyAttached,
|
||
/// No instance was reachable at the requested target.
|
||
NotFound(String),
|
||
}
|
||
|
||
impl std::fmt::Display for AttachError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::NotImplementedInV01(name) => {
|
||
write!(
|
||
f,
|
||
"{name} transport not yet implemented (planned for v0.2 / milestone M5.7)"
|
||
)
|
||
}
|
||
Self::Io(msg) => write!(f, "attach I/O error: {msg}"),
|
||
Self::AlreadyAttached => write!(f, "frontend is already attached"),
|
||
Self::NotFound(t) => write!(f, "attach target not found: {t}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for AttachError {}
|
||
|
||
/// Syntactic problems with the [`AttachTarget`] string form.
|
||
///
|
||
/// Distinct from [`AttachTargetValidationError`] because Lua callers
|
||
/// can construct [`AttachTarget`] from kwargs (skipping the parser);
|
||
/// they only encounter validation errors, not parse errors.
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub enum AttachTargetParseError {
|
||
/// Input did not contain a `kind:body` separator.
|
||
MissingColon,
|
||
/// The `kind` prefix was not one of `local`, `ssh`, `tls`, `custom`.
|
||
UnknownKind(String),
|
||
/// The body after `kind:` was empty.
|
||
EmptyBody(&'static str),
|
||
/// SSH form was given without a host (`ssh:user@`, `ssh:/instance`).
|
||
SshMissingHost,
|
||
/// TLS form was missing the `endpoint#cert` separator.
|
||
TlsMissingHash,
|
||
/// Custom form had no argv tokens after whitespace splitting.
|
||
CustomEmptyCommand,
|
||
}
|
||
|
||
impl std::fmt::Display for AttachTargetParseError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::MissingColon => write!(
|
||
f,
|
||
"attach target must be of the form 'kind:body' (e.g. 'local:/path/to.sock', 'ssh:host')"
|
||
),
|
||
Self::UnknownKind(k) => write!(
|
||
f,
|
||
"unknown attach target kind '{k}' (expected one of: local, ssh, tls, custom)"
|
||
),
|
||
Self::EmptyBody(kind) => {
|
||
write!(f, "attach target '{kind}:' requires a body after the colon")
|
||
}
|
||
Self::SshMissingHost => write!(
|
||
f,
|
||
"ssh attach target requires a host (e.g. 'ssh:hostname' or 'ssh:user@hostname')"
|
||
),
|
||
Self::TlsMissingHash => write!(
|
||
f,
|
||
"tls attach target requires the form 'tls:endpoint#cert_path'"
|
||
),
|
||
Self::CustomEmptyCommand => write!(
|
||
f,
|
||
"custom attach target requires at least one command word (e.g. 'custom:docker exec ...')"
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for AttachTargetParseError {}
|
||
|
||
/// Semantic problems with an [`AttachTarget`] regardless of how it was
|
||
/// constructed. The string in each variant names the offending field
|
||
/// for diagnostic clarity.
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub enum AttachTargetValidationError {
|
||
/// A path field was empty.
|
||
EmptyPath,
|
||
/// A field contained an embedded null byte. Names the field.
|
||
NullByte(&'static str),
|
||
/// SSH host was empty.
|
||
EmptyHost,
|
||
/// SSH user override was an empty string. Callers should omit the
|
||
/// field instead of passing `""`.
|
||
EmptyUser,
|
||
/// SSH user override contained an invalid character. The string
|
||
/// names the constraint that was violated.
|
||
InvalidUser(&'static str),
|
||
/// SSH instance name override was an empty string.
|
||
EmptyInstanceName,
|
||
/// SSH instance name override contained an invalid character.
|
||
InvalidInstanceName(&'static str),
|
||
/// TLS endpoint was empty.
|
||
EmptyEndpoint,
|
||
/// A path field was not valid UTF-8. Names the field.
|
||
NonUtf8Path(&'static str),
|
||
/// Custom command had no argv tokens.
|
||
EmptyCommand,
|
||
}
|
||
|
||
impl std::fmt::Display for AttachTargetValidationError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::EmptyPath => write!(f, "attach target path must not be empty"),
|
||
Self::NullByte(field) => write!(
|
||
f,
|
||
"attach target field '{field}' must not contain a null byte"
|
||
),
|
||
Self::EmptyHost => write!(f, "ssh attach target host must not be empty"),
|
||
Self::EmptyUser => write!(
|
||
f,
|
||
"ssh attach target user must not be empty (omit it instead of passing \"\")"
|
||
),
|
||
Self::InvalidUser(reason) => {
|
||
write!(f, "ssh attach target user is invalid: {reason}")
|
||
}
|
||
Self::EmptyInstanceName => write!(
|
||
f,
|
||
"ssh attach target instance name must not be empty (omit it instead of passing \"\")"
|
||
),
|
||
Self::InvalidInstanceName(reason) => {
|
||
write!(f, "ssh attach target instance name is invalid: {reason}")
|
||
}
|
||
Self::EmptyEndpoint => write!(f, "tls attach target endpoint must not be empty"),
|
||
Self::NonUtf8Path(field) => {
|
||
write!(f, "attach target field '{field}' is not valid UTF-8")
|
||
}
|
||
Self::EmptyCommand => write!(
|
||
f,
|
||
"custom attach target command must have at least one argument"
|
||
),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for AttachTargetValidationError {}
|
||
|
||
/// Wrapper combining the two failure modes of [`AttachTarget::parse`].
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub enum AttachTargetError {
|
||
/// Syntactic parse failure.
|
||
Parse(AttachTargetParseError),
|
||
/// Semantic validation failure.
|
||
Validate(AttachTargetValidationError),
|
||
}
|
||
|
||
impl std::fmt::Display for AttachTargetError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
Self::Parse(e) => write!(f, "{e}"),
|
||
Self::Validate(e) => write!(f, "{e}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for AttachTargetError {}
|
||
|
||
/// Introspection token describing an active attachment.
|
||
///
|
||
/// Returned by the Lua getter `pmacs.current_attachment()`. The handle
|
||
/// surfaces the three facts a caller might want to inspect: which
|
||
/// `FrontendId` the instance assigned during the handshake, the
|
||
/// instance's self-description, and the target the frontend is
|
||
/// connected to.
|
||
///
|
||
/// # Lifecycle
|
||
///
|
||
/// v0.1 has no `pmacs.detach(handle)` operation — the only way to drop
|
||
/// an attachment is to exit the frontend. The handle is therefore
|
||
/// purely an introspection token, not a lifecycle resource. It carries
|
||
/// no Drop side-effects.
|
||
///
|
||
/// # Stability
|
||
///
|
||
/// Callers should not cache the handle across operations. v0.1 makes no
|
||
/// guarantee that two calls to `current_attachment()` return identical
|
||
/// handles even when nothing has changed (e.g. `uptime_secs` advances
|
||
/// monotonically inside `identity`). Treat each handle as a snapshot.
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
pub struct AttachmentHandle {
|
||
/// Frontend ID the instance assigned in [`Hello::assigned_frontend_id`].
|
||
pub frontend_id: FrontendId,
|
||
/// Instance self-description from [`Hello::instance_identity`].
|
||
pub identity: InstanceIdentity,
|
||
/// The target the frontend is connected to.
|
||
pub target: AttachTarget,
|
||
}
|
||
|
||
impl AttachmentHandle {
|
||
/// Construct a handle from its three components.
|
||
#[must_use]
|
||
pub fn new(frontend_id: FrontendId, identity: InstanceIdentity, target: AttachTarget) -> Self {
|
||
Self {
|
||
frontend_id,
|
||
identity,
|
||
target,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Handshake — version, identity, capabilities
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Wire-protocol version. Bumped on any breaking change to the
|
||
/// `Hello` / `AttachRequest` / event-message shapes.
|
||
///
|
||
/// The handshake compares against [`SUPPORTED_PROTOCOL_VERSIONS`];
|
||
/// mismatches close the connection with
|
||
/// [`GoodbyeReason::VersionMismatch`]. v1.0 servers and clients accept
|
||
/// either the v0.1 wire (version 1) or the v1.0 wire (version 2) per
|
||
/// `§sec:m10-backward-compat` — both directions of the version
|
||
/// asymmetry need symmetric relaxation so v0.1-era binaries connect
|
||
/// to v1.0-era binaries (and vice versa) once both have shipped.
|
||
///
|
||
/// T M10.5: bumped from 1 to 2. The v0.1 wire (version 1) remains
|
||
/// accepted by v1.0 binaries; CRDT-only message variants
|
||
/// (`InstanceMessage::CrdtOp`, `FrontendEvent::CrdtOp`) are filtered
|
||
/// per-session for v1 negotiated sessions.
|
||
pub const PROTOCOL_VERSION: u32 = 2;
|
||
|
||
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
|
||
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
|
||
/// `[1, 2]` so the version asymmetry the §sec:m10-backward-compat
|
||
/// spec section describes is handled symmetrically on both sides.
|
||
///
|
||
/// The handshake check is "is the peer's `protocol_version` present in
|
||
/// this slice?" — not strict equality on `PROTOCOL_VERSION`. The
|
||
/// session's negotiated version (the peer's) is recorded for
|
||
/// downstream filtering: v1 sessions don't receive
|
||
/// `InstanceMessage::CrdtOp` / `PresenceUpdate` messages even from
|
||
/// a v2 daemon.
|
||
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[1, 2];
|
||
|
||
/// T M10.5: predicate for the handshake check. Returns `true` if
|
||
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
|
||
#[must_use]
|
||
pub fn is_supported_protocol_version(peer_version: u32) -> bool {
|
||
SUPPORTED_PROTOCOL_VERSIONS.contains(&peer_version)
|
||
}
|
||
|
||
/// Identifies an instance for client-side display.
|
||
///
|
||
/// Sent inside [`Hello`] from instance to frontend. Use of `uptime_secs`
|
||
/// instead of an absolute start time is deliberate: instance and
|
||
/// frontend may run on machines whose clocks disagree, so the frontend
|
||
/// computes "instance has been running N seconds" using only the
|
||
/// instance's view of time.
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct InstanceIdentity {
|
||
/// Pmacs version string (`env!("CARGO_PKG_VERSION")`).
|
||
pub pmacs_version: String,
|
||
/// Short git hash if the build embedded one. `None` for releases or
|
||
/// source-tarball builds where no git checkout was available.
|
||
pub build_hash: Option<String>,
|
||
/// The name the instance was launched under (`--socket NAME`).
|
||
/// `None` for the default daemon (no `--socket` argument).
|
||
pub instance_name: Option<String>,
|
||
/// Seconds since the instance started, from the instance's clock.
|
||
/// Frontend displays "running 47m" by interpreting this against
|
||
/// its own notion of "now," avoiding cross-machine clock skew.
|
||
pub uptime_secs: u64,
|
||
/// Working directory the instance is running in. Encoded as a
|
||
/// UTF-8 string; non-UTF-8 paths are rejected at the boundary.
|
||
pub working_directory: String,
|
||
}
|
||
|
||
impl InstanceIdentity {
|
||
/// Build an identity for the running pmacs process.
|
||
///
|
||
/// `instance_name` is the user-facing name (typically the
|
||
/// `--socket NAME` value for the daemon path; `None` for the
|
||
/// in-process Local mode and the unnamed default daemon).
|
||
/// `started` is the wall-clock anchor used to compute
|
||
/// [`Self::uptime_secs`]; the elapsed seconds are evaluated at the
|
||
/// call site, so calling twice on different days surfaces different
|
||
/// uptimes from the same anchor.
|
||
///
|
||
/// The version comes from `CARGO_PKG_VERSION` and the build hash
|
||
/// from the optional `PMACS_GIT_HASH` environment variable populated
|
||
/// by the build script.
|
||
#[must_use]
|
||
pub fn for_running_process(instance_name: Option<String>, started: std::time::Instant) -> Self {
|
||
Self {
|
||
pmacs_version: env!("CARGO_PKG_VERSION").into(),
|
||
build_hash: option_env!("PMACS_GIT_HASH").map(String::from),
|
||
instance_name,
|
||
uptime_secs: started.elapsed().as_secs(),
|
||
working_directory: std::env::current_dir()
|
||
.ok()
|
||
.map(|p| p.to_string_lossy().into_owned())
|
||
.unwrap_or_default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Capabilities the instance advertises to attaching frontends.
|
||
///
|
||
/// Empty for v0.1; the type exists so that adding capabilities in v0.2+
|
||
/// is not a breaking-change. Symmetric with [`FrontendCapabilities`].
|
||
///
|
||
/// T M10.5: added `multi_frontend` and `crdt_replica` bits with
|
||
/// `#[serde(default)]` so v1 wire bytes still deserialize. The
|
||
/// negotiation logic (which side advertises what, and what the
|
||
/// instance does with mismatches) is M10.7 scope; M10.5 just makes
|
||
/// the bit positions stable in the wire format.
|
||
///
|
||
/// T M10.5/8: bit defaults evolve with the substrate.
|
||
///
|
||
/// - M10.5 declared the bits with `#[serde(default)]` so v1 wire
|
||
/// bytes deserialize forward-compatibly. M10.5–M10.7 set both bits
|
||
/// to `false` so a frontend declaring `multi_frontend: true` got
|
||
/// `Goodbye(CapabilityMismatch)` — the multi-frontend path
|
||
/// wasn't actually wired yet.
|
||
/// - **T M10.8 Day 4 flip**: the instance's `multi_frontend` and
|
||
/// `crdt_replica` defaults flip to `true`. This is the "M10.8 enables
|
||
/// multi-frontend" moment — the underlying dispatcher (Day 3) and
|
||
/// broadcast routing (Day 4) support both capabilities, so the
|
||
/// instance advertises them.
|
||
///
|
||
/// The frontend-side defaults remain `false` (a frontend that omits
|
||
/// the field is conservatively treated as not supporting the
|
||
/// capability; matches v0.1 wire-format semantics).
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct InstanceCapabilities {
|
||
/// T M10.5: instance can host multi-frontend sessions on the
|
||
/// same buffer (per `§sec:m10-collab`). T M10.8 Day 4: default
|
||
/// flipped to `true` — the dispatcher supports multiple
|
||
/// attached frontends.
|
||
#[serde(default = "default_true")]
|
||
pub multi_frontend: bool,
|
||
/// T M10.5: instance can broadcast `InstanceMessage::CrdtOp`
|
||
/// messages. T M10.8 Day 4: default flipped to `true` — the
|
||
/// broadcast routing for CRDT ops wires up in this milestone.
|
||
#[serde(default = "default_true")]
|
||
pub crdt_replica: bool,
|
||
}
|
||
|
||
// Clippy in non-CRDT builds notes that `cfg!(feature = "crdt")`
|
||
// evaluates to `false`, making this impl derivable. In CRDT builds
|
||
// the values are `true`, so the impl is genuinely manual. Allow.
|
||
#[allow(clippy::derivable_impls)]
|
||
impl Default for InstanceCapabilities {
|
||
fn default() -> Self {
|
||
// T M10.10 — the `crdt_replica` default tracks the `crdt`
|
||
// Cargo feature. A daemon built without the `crdt` feature
|
||
// can't honor a `crdt_replica: true` negotiation (the
|
||
// CRDT-handling code paths are conditionally compiled out
|
||
// — `send_buffer_snapshots`, `apply_remote_crdt_op`, the
|
||
// dispatcher's CursorByte emit). Advertising `true`
|
||
// unconditionally would be wire-protocol false advertising.
|
||
//
|
||
// `multi_frontend` is conceptually independent of CRDT but
|
||
// in M10.10's architecture every multi-frontend participant
|
||
// is also a CRDT replica; gating both on the same feature
|
||
// keeps the daemon's advertised capabilities consistent
|
||
// with what it can actually do.
|
||
Self {
|
||
multi_frontend: cfg!(feature = "crdt"),
|
||
crdt_replica: cfg!(feature = "crdt"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::missing_const_for_fn)]
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
|
||
/// Capabilities the frontend advertises to the instance.
|
||
///
|
||
/// All bools default to `false` so a frontend that omits a field via an
|
||
/// older `AttachRequest` is conservatively treated as not supporting
|
||
/// the capability. New capabilities added in v0.2+ get
|
||
/// `#[serde(default)]` so old wire bytes still deserialize.
|
||
// A capability set is exactly the case `struct_excessive_bools` warns
|
||
// against — but each flag is independent and the alternative (an enum
|
||
// or bitset) loses the per-field `#[serde(default)]` semantics that
|
||
// make schema evolution work.
|
||
#[allow(clippy::struct_excessive_bools)]
|
||
#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct FrontendCapabilities {
|
||
/// Frontend understands DEC 2026 `BeginSynchronizedUpdate` /
|
||
/// `EndSynchronizedUpdate` markers. Instance strips them when false.
|
||
#[serde(default)]
|
||
pub synchronized_output: bool,
|
||
/// Frontend can render Unicode beyond the Basic Multilingual Plane.
|
||
/// Instance can substitute a fallback glyph when false.
|
||
#[serde(default)]
|
||
pub unicode_smp: bool,
|
||
/// Frontend supports 24-bit color (truecolor SGR sequences).
|
||
/// Instance maps to the 256-color palette when false.
|
||
#[serde(default)]
|
||
pub true_color: bool,
|
||
/// Frontend captures and forwards mouse events.
|
||
#[serde(default)]
|
||
pub mouse: bool,
|
||
/// Frontend supports bracketed paste — distinguishes pasted bytes
|
||
/// from typed bytes. Instance treats all input as keystrokes when false.
|
||
#[serde(default)]
|
||
pub bracketed_paste: bool,
|
||
/// Optional human-readable terminal identifier for logs and
|
||
/// debugging only. The instance does not branch on this value;
|
||
/// branching is done on the explicit capability bits above.
|
||
#[serde(default)]
|
||
pub terminal_kind: Option<String>,
|
||
/// T M10.5: frontend can participate in multi-frontend sessions
|
||
/// (per `§sec:m10-collab`). false for v0.1 frontends — they
|
||
/// attach as single-frontend and never receive `CrdtOp` /
|
||
/// `PresenceUpdate` broadcasts. v1.0 frontends opt in via M10.7's
|
||
/// negotiation handshake. M10.5 declares the bit position; M10.7
|
||
/// wires the negotiation.
|
||
///
|
||
/// Default is `false` — v1 frontends are treated as not
|
||
/// supporting this feature, which matches reality (v1 frontends
|
||
/// have no local CRDT state). A `true` default would have v1
|
||
/// frontends claimed to support features they don't.
|
||
#[serde(default)]
|
||
pub multi_frontend: bool,
|
||
/// T M10.5: frontend can apply incoming `CrdtOp` messages to a
|
||
/// local CRDT state. false for v0.1; v1.0 opts in. M10.7 wires
|
||
/// negotiation; M10.5 declares the bit position.
|
||
#[serde(default)]
|
||
pub crdt_replica: bool,
|
||
}
|
||
|
||
/// T M10.7 — the negotiated capability bits for one attached session.
|
||
///
|
||
/// Computed by [`negotiate_capabilities`] from the frontend's
|
||
/// [`FrontendCapabilities`] and the instance's [`InstanceCapabilities`].
|
||
/// Each negotiated bit is the AND of the two declared bits. Fields
|
||
/// added here in future milestones append at the end with sensible
|
||
/// defaults so existing call sites stay valid.
|
||
///
|
||
/// This is a daemon-internal struct (not on the wire); the
|
||
/// negotiation result is communicated to the frontend via the
|
||
/// success of the handshake (no capability-mismatch `Goodbye`) and
|
||
/// the instance's behavior thereafter.
|
||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
|
||
pub struct NegotiatedCapabilities {
|
||
/// Session is eligible for multi-frontend operation. True iff
|
||
/// both the frontend and the instance declared `multi_frontend =
|
||
/// true`. v0.1 frontends always end up here as `false` (the v0.1
|
||
/// wire format does not carry the field; `#[serde(default)]`
|
||
/// makes the deserialized value `false`).
|
||
pub multi_frontend: bool,
|
||
/// Session can produce/consume `InstanceMessage::CrdtOp` /
|
||
/// `FrontendEvent::CrdtOp`. True iff both sides declared
|
||
/// `crdt_replica = true`. The daemon's outgoing-message filter for
|
||
/// `CrdtOp` consults this in M10.8.
|
||
pub crdt_replica: bool,
|
||
}
|
||
|
||
/// T M10.7 — pure-function capability negotiation.
|
||
///
|
||
/// For each negotiated bit (`multi_frontend`, `crdt_replica`):
|
||
///
|
||
/// | Frontend wants | Instance has | Result |
|
||
/// |----------------|--------------|--------|
|
||
/// | `false` | `false` | bit `false`, no error |
|
||
/// | `false` | `true` | bit `false`, no error |
|
||
/// | `true` | `true` | bit `true`, no error |
|
||
/// | `true` | `false` | bit appears in `missing` |
|
||
///
|
||
/// If any bit ends up in `missing`, the negotiation fails as a whole
|
||
/// (returns `Err`). Otherwise the negotiated bits are returned as
|
||
/// [`NegotiatedCapabilities`]. The `Err` form gathers ALL missing
|
||
/// bits into one `CapabilityMismatch` — one round-trip carries the
|
||
/// complete picture rather than serial rejections.
|
||
///
|
||
/// # Wire-format stability
|
||
///
|
||
/// The strings emitted into `missing` are exactly the
|
||
/// `FrontendCapabilities` field names (`"multi_frontend"`,
|
||
/// `"crdt_replica"`). These are stable wire-format identifiers, not
|
||
/// human-readable descriptions. User-facing translation is the
|
||
/// frontend's responsibility (see [`AttachError`]'s `Display` impl).
|
||
/// Renaming a capability bit requires updating both the field name
|
||
/// and the missing-string emission here in lockstep.
|
||
pub fn negotiate_capabilities(
|
||
frontend: &FrontendCapabilities,
|
||
instance: &InstanceCapabilities,
|
||
) -> Result<NegotiatedCapabilities, GoodbyeReason> {
|
||
let mut missing = Vec::new();
|
||
let multi_frontend = match (frontend.multi_frontend, instance.multi_frontend) {
|
||
(true, false) => {
|
||
missing.push("multi_frontend".to_string());
|
||
false
|
||
}
|
||
(a, b) => a && b,
|
||
};
|
||
let crdt_replica = match (frontend.crdt_replica, instance.crdt_replica) {
|
||
(true, false) => {
|
||
missing.push("crdt_replica".to_string());
|
||
false
|
||
}
|
||
(a, b) => a && b,
|
||
};
|
||
if missing.is_empty() {
|
||
Ok(NegotiatedCapabilities {
|
||
multi_frontend,
|
||
crdt_replica,
|
||
})
|
||
} else {
|
||
Err(GoodbyeReason::CapabilityMismatch { missing })
|
||
}
|
||
}
|
||
|
||
/// First message sent by the instance to a freshly-attached frontend.
|
||
///
|
||
/// Sent immediately after the connection is accepted, before reading
|
||
/// the frontend's [`AttachRequest`]. The frontend uses
|
||
/// `instance_identity` for status display and `protocol_version` /
|
||
/// `instance_capabilities` for compatibility decisions.
|
||
///
|
||
/// The instance also stamps the `assigned_frontend_id` which the
|
||
/// frontend will use as the `FrontendId` on every event it sends.
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct Hello {
|
||
/// The instance's `PROTOCOL_VERSION`.
|
||
pub protocol_version: u32,
|
||
/// `FrontendId` assigned to this attachment by the instance. The
|
||
/// frontend stamps this onto subsequent events. v0.1 daemons start
|
||
/// allocation at `FrontendId(2)` (1 reserved for the in-process TUI).
|
||
pub assigned_frontend_id: FrontendId,
|
||
/// Instance self-identification (version, name, uptime, cwd).
|
||
pub instance_identity: InstanceIdentity,
|
||
/// Instance capabilities. Empty for v0.1.
|
||
pub instance_capabilities: InstanceCapabilities,
|
||
}
|
||
|
||
/// First message sent by a frontend after receiving [`Hello`].
|
||
///
|
||
/// Carries the frontend's view of the protocol version, the
|
||
/// capabilities it can support, and its initial terminal size. On
|
||
/// version mismatch the instance closes with
|
||
/// [`GoodbyeReason::VersionMismatch`] and no further messages flow.
|
||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct AttachRequest {
|
||
/// The frontend's `PROTOCOL_VERSION`.
|
||
pub protocol_version: u32,
|
||
/// Frontend capabilities. Defaults to all-false if omitted.
|
||
#[serde(default)]
|
||
pub frontend_capabilities: FrontendCapabilities,
|
||
/// The frontend's terminal size at attach time. Authoritative
|
||
/// until the frontend sends a [`FrontendEvent::Resize`]. The
|
||
/// instance uses this for the initial full-grid render.
|
||
pub initial_size: CellSize,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Crossterm translation (the only crossterm seam in this module)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Translation from `crossterm::event` types to the protocol types.
|
||
///
|
||
/// This submodule is the single place where `crossterm` types touch
|
||
/// the protocol. The TUI frontend converts at the input boundary;
|
||
/// network transports decode the wire directly into protocol types
|
||
/// without going through this layer.
|
||
pub mod crossterm_translate {
|
||
use super::{CellCoord, FrontendId};
|
||
use super::{Key, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseKind};
|
||
use crossterm::event::{
|
||
KeyCode, KeyEvent as CtKeyEvent, KeyModifiers as CtMods, MediaKeyCode, ModifierKeyCode,
|
||
MouseButton as CtMouseButton, MouseEvent as CtMouseEvent, MouseEventKind,
|
||
};
|
||
|
||
/// Translate a `crossterm::event::KeyEvent` into a protocol
|
||
/// [`KeyEvent`].
|
||
#[must_use]
|
||
pub fn key_from_crossterm(
|
||
ev: &CtKeyEvent,
|
||
frontend_id: FrontendId,
|
||
timestamp_ns: u64,
|
||
) -> KeyEvent {
|
||
KeyEvent {
|
||
frontend_id,
|
||
key: keycode_from_crossterm(ev.code),
|
||
mods: mods_from_crossterm(ev.modifiers),
|
||
timestamp_ns,
|
||
}
|
||
}
|
||
|
||
/// Translate a `crossterm::event::KeyCode` into a protocol [`Key`].
|
||
///
|
||
/// Media and modifier-only keycodes map to [`Key::Unknown`]: they
|
||
/// are not actionable as commands but round-trip through
|
||
/// serialization without being an error.
|
||
#[must_use]
|
||
pub fn keycode_from_crossterm(code: KeyCode) -> Key {
|
||
match code {
|
||
KeyCode::Char(c) => Key::Char(c),
|
||
KeyCode::F(n) => Key::F(n),
|
||
KeyCode::Backspace => Key::Backspace,
|
||
KeyCode::Enter => Key::Enter,
|
||
KeyCode::Left => Key::Left,
|
||
KeyCode::Right => Key::Right,
|
||
KeyCode::Up => Key::Up,
|
||
KeyCode::Down => Key::Down,
|
||
KeyCode::Home => Key::Home,
|
||
KeyCode::End => Key::End,
|
||
KeyCode::PageUp => Key::PageUp,
|
||
KeyCode::PageDown => Key::PageDown,
|
||
KeyCode::Tab => Key::Tab,
|
||
KeyCode::BackTab => Key::BackTab,
|
||
KeyCode::Delete => Key::Delete,
|
||
KeyCode::Insert => Key::Insert,
|
||
KeyCode::Esc => Key::Escape,
|
||
KeyCode::Null => Key::Null,
|
||
KeyCode::CapsLock => Key::CapsLock,
|
||
KeyCode::ScrollLock => Key::ScrollLock,
|
||
KeyCode::NumLock => Key::NumLock,
|
||
KeyCode::PrintScreen => Key::PrintScreen,
|
||
KeyCode::Pause => Key::Pause,
|
||
KeyCode::Menu => Key::Menu,
|
||
KeyCode::KeypadBegin => Key::KeypadBegin,
|
||
KeyCode::Media(m) => Key::Unknown(media_sentinel(m)),
|
||
KeyCode::Modifier(m) => Key::Unknown(modifier_sentinel(m)),
|
||
}
|
||
}
|
||
|
||
/// Reverse translation: protocol [`Key`] back to a
|
||
/// `crossterm::event::KeyCode`. Returns `None` for variants that
|
||
/// have no crossterm equivalent ([`Key::Unknown`]).
|
||
///
|
||
/// Used in the round-trip property test to confirm losslessness.
|
||
#[must_use]
|
||
pub fn keycode_to_crossterm(key: Key) -> Option<KeyCode> {
|
||
Some(match key {
|
||
Key::Char(c) => KeyCode::Char(c),
|
||
Key::F(n) => KeyCode::F(n),
|
||
Key::Backspace => KeyCode::Backspace,
|
||
Key::Enter => KeyCode::Enter,
|
||
Key::Left => KeyCode::Left,
|
||
Key::Right => KeyCode::Right,
|
||
Key::Up => KeyCode::Up,
|
||
Key::Down => KeyCode::Down,
|
||
Key::Home => KeyCode::Home,
|
||
Key::End => KeyCode::End,
|
||
Key::PageUp => KeyCode::PageUp,
|
||
Key::PageDown => KeyCode::PageDown,
|
||
Key::Tab => KeyCode::Tab,
|
||
Key::BackTab => KeyCode::BackTab,
|
||
Key::Delete => KeyCode::Delete,
|
||
Key::Insert => KeyCode::Insert,
|
||
Key::Escape => KeyCode::Esc,
|
||
Key::Null => KeyCode::Null,
|
||
Key::CapsLock => KeyCode::CapsLock,
|
||
Key::ScrollLock => KeyCode::ScrollLock,
|
||
Key::NumLock => KeyCode::NumLock,
|
||
Key::PrintScreen => KeyCode::PrintScreen,
|
||
Key::Pause => KeyCode::Pause,
|
||
Key::Menu => KeyCode::Menu,
|
||
Key::KeypadBegin => KeyCode::KeypadBegin,
|
||
Key::Unknown(_) => return None,
|
||
})
|
||
}
|
||
|
||
/// Translate a `crossterm::event::KeyModifiers` into protocol [`Modifiers`].
|
||
#[must_use]
|
||
pub fn mods_from_crossterm(m: CtMods) -> Modifiers {
|
||
let mut out = Modifiers::NONE;
|
||
if m.contains(CtMods::SHIFT) {
|
||
out |= Modifiers::SHIFT;
|
||
}
|
||
if m.contains(CtMods::CONTROL) {
|
||
out |= Modifiers::CTRL;
|
||
}
|
||
if m.contains(CtMods::ALT) {
|
||
out |= Modifiers::ALT;
|
||
}
|
||
if m.contains(CtMods::SUPER) {
|
||
out |= Modifiers::META;
|
||
}
|
||
if m.contains(CtMods::HYPER) {
|
||
out |= Modifiers::HYPER;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Translate protocol [`Modifiers`] back to `crossterm::event::KeyModifiers`.
|
||
#[must_use]
|
||
pub fn mods_to_crossterm(m: Modifiers) -> CtMods {
|
||
let mut out = CtMods::empty();
|
||
if m.contains(Modifiers::SHIFT) {
|
||
out |= CtMods::SHIFT;
|
||
}
|
||
if m.contains(Modifiers::CTRL) {
|
||
out |= CtMods::CONTROL;
|
||
}
|
||
if m.contains(Modifiers::ALT) {
|
||
out |= CtMods::ALT;
|
||
}
|
||
if m.contains(Modifiers::META) {
|
||
out |= CtMods::SUPER;
|
||
}
|
||
if m.contains(Modifiers::HYPER) {
|
||
out |= CtMods::HYPER;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Translate a `crossterm::event::MouseEvent` into a protocol [`MouseEvent`].
|
||
#[must_use]
|
||
pub fn mouse_from_crossterm(ev: &CtMouseEvent, frontend_id: FrontendId) -> MouseEvent {
|
||
let kind = match ev.kind {
|
||
MouseEventKind::Down(b) => MouseKind::Down(button_from(b)),
|
||
MouseEventKind::Up(b) => MouseKind::Up(button_from(b)),
|
||
MouseEventKind::Drag(b) => MouseKind::Drag(button_from(b)),
|
||
MouseEventKind::Moved => MouseKind::Move,
|
||
MouseEventKind::ScrollUp => MouseKind::ScrollUp,
|
||
MouseEventKind::ScrollDown => MouseKind::ScrollDown,
|
||
MouseEventKind::ScrollLeft => MouseKind::ScrollLeft,
|
||
MouseEventKind::ScrollRight => MouseKind::ScrollRight,
|
||
};
|
||
MouseEvent {
|
||
frontend_id,
|
||
kind,
|
||
coord: CellCoord::new(u32::from(ev.row), u32::from(ev.column)),
|
||
mods: mods_from_crossterm(ev.modifiers),
|
||
}
|
||
}
|
||
|
||
fn button_from(b: CtMouseButton) -> MouseButton {
|
||
match b {
|
||
CtMouseButton::Left => MouseButton::Left,
|
||
CtMouseButton::Right => MouseButton::Right,
|
||
CtMouseButton::Middle => MouseButton::Middle,
|
||
}
|
||
}
|
||
|
||
fn button_to(b: MouseButton) -> CtMouseButton {
|
||
match b {
|
||
MouseButton::Left => CtMouseButton::Left,
|
||
MouseButton::Right => CtMouseButton::Right,
|
||
MouseButton::Middle => CtMouseButton::Middle,
|
||
}
|
||
}
|
||
|
||
/// Reverse translation: build a `crossterm::event::KeyEvent` from
|
||
/// a protocol [`KeyEvent`].
|
||
///
|
||
/// Returns `None` when the keycode is [`Key::Unknown`], which has
|
||
/// no native crossterm equivalent. The instance side ignores
|
||
/// unknown keys (they don't actuate commands), so callers can
|
||
/// drop these without further handling.
|
||
///
|
||
/// Used by the daemon's per-attach loop to feed
|
||
/// [`crate::editor::EditorState::dispatch_key`], which still takes
|
||
/// the crossterm shape for compatibility with the in-process TUI.
|
||
#[must_use]
|
||
pub fn key_to_crossterm(ev: &KeyEvent) -> Option<CtKeyEvent> {
|
||
use crossterm::event::{KeyEventKind, KeyEventState};
|
||
Some(CtKeyEvent {
|
||
code: keycode_to_crossterm(ev.key)?,
|
||
modifiers: mods_to_crossterm(ev.mods),
|
||
kind: KeyEventKind::Press,
|
||
state: KeyEventState::empty(),
|
||
})
|
||
}
|
||
|
||
/// Reverse translation: build a `crossterm::event::MouseEvent`
|
||
/// from a protocol [`MouseEvent`].
|
||
///
|
||
/// Coordinates are clamped into `u16` (crossterm's representation);
|
||
/// terminal sizes don't realistically exceed `u16::MAX` cells in
|
||
/// either dimension, but we clamp rather than panic to be safe
|
||
/// against a misbehaving frontend.
|
||
#[must_use]
|
||
pub fn mouse_to_crossterm(ev: &MouseEvent) -> CtMouseEvent {
|
||
let kind = match ev.kind {
|
||
MouseKind::Down(b) => MouseEventKind::Down(button_to(b)),
|
||
MouseKind::Up(b) => MouseEventKind::Up(button_to(b)),
|
||
MouseKind::Drag(b) => MouseEventKind::Drag(button_to(b)),
|
||
MouseKind::Move => MouseEventKind::Moved,
|
||
MouseKind::ScrollUp => MouseEventKind::ScrollUp,
|
||
MouseKind::ScrollDown => MouseEventKind::ScrollDown,
|
||
MouseKind::ScrollLeft => MouseEventKind::ScrollLeft,
|
||
MouseKind::ScrollRight => MouseEventKind::ScrollRight,
|
||
};
|
||
CtMouseEvent {
|
||
kind,
|
||
row: u16::try_from(ev.coord.row).unwrap_or(u16::MAX),
|
||
column: u16::try_from(ev.coord.col).unwrap_or(u16::MAX),
|
||
modifiers: mods_to_crossterm(ev.mods),
|
||
}
|
||
}
|
||
|
||
/// Stable sentinel for media keycodes so they round-trip through
|
||
/// the [`Key::Unknown`] variant.
|
||
const fn media_sentinel(m: MediaKeyCode) -> u32 {
|
||
// Encode as `0x01XX` so the namespace is distinguishable from
|
||
// modifier-only keys (0x02XX) and any future class.
|
||
0x0100
|
||
| match m {
|
||
MediaKeyCode::Play => 0x01,
|
||
MediaKeyCode::Pause => 0x02,
|
||
MediaKeyCode::PlayPause => 0x03,
|
||
MediaKeyCode::Reverse => 0x04,
|
||
MediaKeyCode::Stop => 0x05,
|
||
MediaKeyCode::FastForward => 0x06,
|
||
MediaKeyCode::Rewind => 0x07,
|
||
MediaKeyCode::TrackNext => 0x08,
|
||
MediaKeyCode::TrackPrevious => 0x09,
|
||
MediaKeyCode::Record => 0x0A,
|
||
MediaKeyCode::LowerVolume => 0x0B,
|
||
MediaKeyCode::RaiseVolume => 0x0C,
|
||
MediaKeyCode::MuteVolume => 0x0D,
|
||
}
|
||
}
|
||
|
||
const fn modifier_sentinel(m: ModifierKeyCode) -> u32 {
|
||
0x0200
|
||
| match m {
|
||
ModifierKeyCode::LeftShift => 0x01,
|
||
ModifierKeyCode::LeftControl => 0x02,
|
||
ModifierKeyCode::LeftAlt => 0x03,
|
||
ModifierKeyCode::LeftSuper => 0x04,
|
||
ModifierKeyCode::LeftHyper => 0x05,
|
||
ModifierKeyCode::LeftMeta => 0x06,
|
||
ModifierKeyCode::RightShift => 0x07,
|
||
ModifierKeyCode::RightControl => 0x08,
|
||
ModifierKeyCode::RightAlt => 0x09,
|
||
ModifierKeyCode::RightSuper => 0x0A,
|
||
ModifierKeyCode::RightHyper => 0x0B,
|
||
ModifierKeyCode::RightMeta => 0x0C,
|
||
ModifierKeyCode::IsoLevel3Shift => 0x0D,
|
||
ModifierKeyCode::IsoLevel5Shift => 0x0E,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
// Acceptance home for T M5.1 (Frontend protocol skeleton). The M5.1
|
||
// spec criteria — typed FrontendEvent / InstanceMessage / FrontendId,
|
||
// pmacs-native Key / Modifiers, lossless crossterm round-trip,
|
||
// NotImplementedInV01 paths — are exercised by the lib tests in
|
||
// this module rather than a separate tests/m5_1_acceptance.rs file.
|
||
// See tests/INDEX.md for the full M5.x → coverage map.
|
||
|
||
use super::*;
|
||
use crossterm::event::{
|
||
KeyCode, KeyEvent as CtKeyEvent, KeyEventKind, KeyEventState, KeyModifiers as CtMods,
|
||
MouseButton as CtMouseButton, MouseEvent as CtMouseEvent, MouseEventKind,
|
||
};
|
||
|
||
#[test]
|
||
fn frontend_id_local_is_one() {
|
||
// The constant is load-bearing — Lua bindings and tests
|
||
// hard-code this. Pin it so we notice if it ever drifts.
|
||
assert_eq!(FrontendId::LOCAL, FrontendId(1));
|
||
}
|
||
|
||
#[test]
|
||
fn modifiers_compose() {
|
||
let m = Modifiers::SHIFT | Modifiers::CTRL;
|
||
assert!(m.contains(Modifiers::SHIFT));
|
||
assert!(m.contains(Modifiers::CTRL));
|
||
assert!(!m.contains(Modifiers::ALT));
|
||
assert!(!m.is_empty());
|
||
assert_eq!(m.bits(), 0b0000_0011);
|
||
}
|
||
|
||
#[test]
|
||
fn modifiers_truncate_unknown_bits() {
|
||
let raw = Modifiers::from_bits_truncate(0b1111_1111);
|
||
// Only the five defined bits survive.
|
||
assert_eq!(raw.bits(), 0b0001_1111);
|
||
}
|
||
|
||
#[test]
|
||
fn frontend_event_id_extraction() {
|
||
let id = FrontendId(42);
|
||
let ev = FrontendEvent::Key(KeyEvent {
|
||
frontend_id: id,
|
||
key: Key::Char('a'),
|
||
mods: Modifiers::NONE,
|
||
timestamp_ns: 0,
|
||
});
|
||
assert_eq!(ev.frontend_id(), id);
|
||
assert_eq!(FrontendEvent::Detach(id).frontend_id(), id);
|
||
assert_eq!(
|
||
FrontendEvent::Resize {
|
||
frontend_id: id,
|
||
size: CellSize::new(24, 80),
|
||
}
|
||
.frontend_id(),
|
||
id
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn attach_target_check_v01_accepts_implemented() {
|
||
assert!(
|
||
AttachTarget::LocalSocket(PathBuf::from("/run/pmacs.sock"))
|
||
.check_v01()
|
||
.is_ok()
|
||
);
|
||
assert!(
|
||
AttachTarget::Ssh {
|
||
host: "example".into(),
|
||
user: None,
|
||
instance_name: None
|
||
}
|
||
.check_v01()
|
||
.is_ok()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn attach_target_check_v01_rejects_tls_and_custom() {
|
||
let tls = AttachTarget::Tls {
|
||
endpoint: "example:9999".into(),
|
||
cert: PathBuf::from("/etc/pmacs.crt"),
|
||
};
|
||
match tls.check_v01() {
|
||
Err(AttachError::NotImplementedInV01("TLS")) => {}
|
||
other => panic!("expected NotImplementedInV01(\"TLS\"), got {other:?}"),
|
||
}
|
||
|
||
let custom = AttachTarget::Custom {
|
||
command: vec!["docker".into(), "exec".into()],
|
||
};
|
||
match custom.check_v01() {
|
||
Err(AttachError::NotImplementedInV01("Custom")) => {}
|
||
other => panic!("expected NotImplementedInV01(\"Custom\"), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn attach_error_display_points_at_target_milestone() {
|
||
// The not-implemented message names the milestone that ships
|
||
// the implementation, so users have a planning anchor and the
|
||
// error tells them what to do (wait / upgrade) rather than
|
||
// characterizing their action as misuse.
|
||
let e = AttachError::NotImplementedInV01("SSH");
|
||
assert_eq!(
|
||
e.to_string(),
|
||
"SSH transport not yet implemented (planned for v0.2 / milestone M5.7)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn kind_name_stable_across_variants() {
|
||
assert_eq!(
|
||
AttachTarget::LocalSocket(PathBuf::new()).kind_name(),
|
||
"local"
|
||
);
|
||
assert_eq!(
|
||
AttachTarget::Ssh {
|
||
host: String::new(),
|
||
user: None,
|
||
instance_name: None
|
||
}
|
||
.kind_name(),
|
||
"ssh"
|
||
);
|
||
assert_eq!(
|
||
AttachTarget::Tls {
|
||
endpoint: String::new(),
|
||
cert: PathBuf::new()
|
||
}
|
||
.kind_name(),
|
||
"tls"
|
||
);
|
||
assert_eq!(
|
||
AttachTarget::Custom { command: vec![] }.kind_name(),
|
||
"custom"
|
||
);
|
||
}
|
||
|
||
// --- M5.6a: parse, validate, Display ---
|
||
|
||
#[test]
|
||
fn parse_local_socket_simple_path() {
|
||
let t = AttachTarget::parse("local:/run/user/1000/pmacs/default.sock")
|
||
.expect("local with valid path");
|
||
match t {
|
||
AttachTarget::LocalSocket(p) => {
|
||
assert_eq!(p, PathBuf::from("/run/user/1000/pmacs/default.sock"));
|
||
}
|
||
other => panic!("expected LocalSocket, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_local_path_with_internal_colon_preserved() {
|
||
// split_once(':') only splits on the first colon — paths with
|
||
// colons in them (e.g. Windows-style or weird mount points) are
|
||
// preserved verbatim in the body.
|
||
let t = AttachTarget::parse("local:/foo:bar/baz.sock").expect("colon in path");
|
||
match t {
|
||
AttachTarget::LocalSocket(p) => assert_eq!(p, PathBuf::from("/foo:bar/baz.sock")),
|
||
other => panic!("expected LocalSocket, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ssh_host_only() {
|
||
let t = AttachTarget::parse("ssh:mac-studio").expect("ssh with bare host");
|
||
assert_eq!(
|
||
t,
|
||
AttachTarget::Ssh {
|
||
host: "mac-studio".into(),
|
||
user: None,
|
||
instance_name: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ssh_user_at_host() {
|
||
let t = AttachTarget::parse("ssh:lev@mac-studio").expect("ssh with user");
|
||
assert_eq!(
|
||
t,
|
||
AttachTarget::Ssh {
|
||
host: "mac-studio".into(),
|
||
user: Some("lev".into()),
|
||
instance_name: None,
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ssh_user_host_instance() {
|
||
let t =
|
||
AttachTarget::parse("ssh:lev@mac-studio/research").expect("ssh with user and instance");
|
||
assert_eq!(
|
||
t,
|
||
AttachTarget::Ssh {
|
||
host: "mac-studio".into(),
|
||
user: Some("lev".into()),
|
||
instance_name: Some("research".into()),
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ssh_host_instance_no_user() {
|
||
let t = AttachTarget::parse("ssh:mac-studio/research").expect("ssh with instance, no user");
|
||
assert_eq!(
|
||
t,
|
||
AttachTarget::Ssh {
|
||
host: "mac-studio".into(),
|
||
user: None,
|
||
instance_name: Some("research".into()),
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_tls_endpoint_and_cert() {
|
||
let t = AttachTarget::parse("tls:example.com:9999#/etc/pmacs.crt")
|
||
.expect("tls with endpoint and cert");
|
||
assert_eq!(
|
||
t,
|
||
AttachTarget::Tls {
|
||
endpoint: "example.com:9999".into(),
|
||
cert: PathBuf::from("/etc/pmacs.crt"),
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_custom_argv_split() {
|
||
let t = AttachTarget::parse("custom:docker exec -i pmacs-container pmacs --daemon-attach")
|
||
.expect("custom with argv");
|
||
match t {
|
||
AttachTarget::Custom { command } => {
|
||
assert_eq!(
|
||
command,
|
||
vec![
|
||
"docker",
|
||
"exec",
|
||
"-i",
|
||
"pmacs-container",
|
||
"pmacs",
|
||
"--daemon-attach"
|
||
]
|
||
);
|
||
}
|
||
other => panic!("expected Custom, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_missing_colon() {
|
||
match AttachTarget::parse("local") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::MissingColon)) => {}
|
||
other => panic!("expected MissingColon, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_missing_colon_message_points_at_workaround() {
|
||
// The error message tells the user what shape the input should
|
||
// take, not just that the input was wrong.
|
||
let e = AttachTargetParseError::MissingColon;
|
||
let msg = e.to_string();
|
||
assert!(msg.contains("kind:body"), "{msg}");
|
||
assert!(msg.contains("local:") && msg.contains("ssh:"), "{msg}");
|
||
}
|
||
|
||
#[test]
|
||
fn parse_unknown_kind() {
|
||
match AttachTarget::parse("smtp:host") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::UnknownKind(k))) => {
|
||
assert_eq!(k, "smtp");
|
||
}
|
||
other => panic!("expected UnknownKind, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_unknown_kind_message_lists_valid_kinds() {
|
||
let e = AttachTargetParseError::UnknownKind("smtp".into());
|
||
let msg = e.to_string();
|
||
assert!(msg.contains("smtp"), "{msg}");
|
||
// All four valid kinds named so user knows the menu.
|
||
for k in ["local", "ssh", "tls", "custom"] {
|
||
assert!(msg.contains(k), "{msg} missing {k}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_local_empty_body() {
|
||
match AttachTarget::parse("local:") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::EmptyBody("local"))) => {}
|
||
other => panic!("expected EmptyBody(local), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ssh_empty_body() {
|
||
match AttachTarget::parse("ssh:") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::EmptyBody("ssh"))) => {}
|
||
other => panic!("expected EmptyBody(ssh), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ssh_user_at_empty_host() {
|
||
// `ssh:lev@` parses user-host as `lev@`, splits to user=Some("lev"), host=""
|
||
match AttachTarget::parse("ssh:lev@") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::SshMissingHost)) => {}
|
||
other => panic!("expected SshMissingHost, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_ssh_slash_instance_no_host() {
|
||
// `ssh:/research` splits at `/` first → user_host = "", instance = "research"
|
||
// Then user_host has no `@`, so host = "" → SshMissingHost.
|
||
match AttachTarget::parse("ssh:/research") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::SshMissingHost)) => {}
|
||
other => panic!("expected SshMissingHost, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_tls_missing_hash() {
|
||
match AttachTarget::parse("tls:example.com:9999") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::TlsMissingHash)) => {}
|
||
other => panic!("expected TlsMissingHash, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_custom_only_whitespace_is_empty_command() {
|
||
match AttachTarget::parse("custom: \t ") {
|
||
Err(AttachTargetError::Parse(AttachTargetParseError::CustomEmptyCommand)) => {}
|
||
other => panic!("expected CustomEmptyCommand, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_rejects_null_byte_in_path() {
|
||
// Validation runs as the final step of parse, so embedded
|
||
// nulls surface as a Validate error (not a Parse error).
|
||
let s = "local:/foo\0/bar.sock";
|
||
match AttachTarget::parse(s) {
|
||
Err(AttachTargetError::Validate(AttachTargetValidationError::NullByte("path"))) => {}
|
||
other => panic!("expected NullByte(path) from validate, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_rejects_at_sign_in_user() {
|
||
// `ssh:a@b@host` parses user=Some("a"), host="b@host" — the host
|
||
// contains an `@` which is structurally fine for ssh, but if the
|
||
// user does `ssh:user@@host`, we get user=Some("user"), host="@host".
|
||
// The host having `@` is legal-ish for some configs; we don't
|
||
// reject it. But user containing `@` *is* rejected. Construct
|
||
// the case directly to test the validation:
|
||
let t = AttachTarget::Ssh {
|
||
host: "host".into(),
|
||
user: Some("u@bad".into()),
|
||
instance_name: None,
|
||
};
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::InvalidUser(_)) => {}
|
||
other => panic!("expected InvalidUser, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_local_empty_path() {
|
||
let t = AttachTarget::LocalSocket(PathBuf::new());
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::EmptyPath) => {}
|
||
other => panic!("expected EmptyPath, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_ssh_empty_host() {
|
||
let t = AttachTarget::Ssh {
|
||
host: String::new(),
|
||
user: None,
|
||
instance_name: None,
|
||
};
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::EmptyHost) => {}
|
||
other => panic!("expected EmptyHost, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_ssh_empty_user_string_rejected() {
|
||
// Passing user = Some("") is treated as user error: omit the
|
||
// field instead. This catches a common Lua-side mistake where
|
||
// a missing kwarg becomes an empty string.
|
||
let t = AttachTarget::Ssh {
|
||
host: "host".into(),
|
||
user: Some(String::new()),
|
||
instance_name: None,
|
||
};
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::EmptyUser) => {}
|
||
other => panic!("expected EmptyUser, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_ssh_instance_name_with_slash() {
|
||
let t = AttachTarget::Ssh {
|
||
host: "host".into(),
|
||
user: None,
|
||
instance_name: Some("a/b".into()),
|
||
};
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::InvalidInstanceName(_)) => {}
|
||
other => panic!("expected InvalidInstanceName, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_tls_empty_endpoint() {
|
||
let t = AttachTarget::Tls {
|
||
endpoint: String::new(),
|
||
cert: PathBuf::from("/etc/pmacs.crt"),
|
||
};
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::EmptyEndpoint) => {}
|
||
other => panic!("expected EmptyEndpoint, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_tls_empty_cert() {
|
||
let t = AttachTarget::Tls {
|
||
endpoint: "host:9999".into(),
|
||
cert: PathBuf::new(),
|
||
};
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::EmptyPath) => {}
|
||
other => panic!("expected EmptyPath, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_custom_empty_command() {
|
||
let t = AttachTarget::Custom { command: vec![] };
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::EmptyCommand) => {}
|
||
other => panic!("expected EmptyCommand, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_custom_null_in_arg() {
|
||
let t = AttachTarget::Custom {
|
||
command: vec!["docker".into(), "exec\0".into()],
|
||
};
|
||
match t.validate() {
|
||
Err(AttachTargetValidationError::NullByte("command")) => {}
|
||
other => panic!("expected NullByte(command), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn validate_succeeds_on_well_formed_targets() {
|
||
AttachTarget::LocalSocket(PathBuf::from("/run/p.sock"))
|
||
.validate()
|
||
.expect("local valid");
|
||
AttachTarget::Ssh {
|
||
host: "h".into(),
|
||
user: Some("u".into()),
|
||
instance_name: Some("i".into()),
|
||
}
|
||
.validate()
|
||
.expect("ssh valid");
|
||
AttachTarget::Tls {
|
||
endpoint: "h:9".into(),
|
||
cert: PathBuf::from("/c"),
|
||
}
|
||
.validate()
|
||
.expect("tls valid");
|
||
AttachTarget::Custom {
|
||
command: vec!["a".into(), "b".into()],
|
||
}
|
||
.validate()
|
||
.expect("custom valid");
|
||
}
|
||
|
||
#[test]
|
||
fn display_round_trips_for_all_variants() {
|
||
// Display → parse → Display is a fixed point for every shape
|
||
// the parser accepts.
|
||
let cases = [
|
||
"local:/run/user/1000/pmacs/default.sock",
|
||
"ssh:mac-studio",
|
||
"ssh:lev@mac-studio",
|
||
"ssh:lev@mac-studio/research",
|
||
"ssh:mac-studio/research",
|
||
"tls:example.com:9999#/etc/pmacs.crt",
|
||
"custom:docker exec pmacs",
|
||
];
|
||
for s in cases {
|
||
let parsed = AttachTarget::parse(s).unwrap_or_else(|e| panic!("parse {s:?}: {e}"));
|
||
let displayed = parsed.to_string();
|
||
assert_eq!(displayed, s, "round-trip failed: {s:?} → {displayed:?}");
|
||
// Re-parsing the Display output must also succeed and equal
|
||
// the first parse.
|
||
let reparsed = AttachTarget::parse(&displayed).expect("re-parse Display output");
|
||
assert_eq!(reparsed, parsed);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn parse_then_check_v01_for_unimplemented_passes_parse() {
|
||
// The v0.1 stub posture: TLS / Custom parse and validate
|
||
// successfully, but check_v01 rejects them. This is what lets a
|
||
// user write `pmacs.attach{ target = "ssh:..." }` in init.lua
|
||
// today and have the call only fail at activation time once SSH
|
||
// ships in M5.7.
|
||
let tls = AttachTarget::parse("tls:host:9#/etc/c").expect("tls parses");
|
||
match tls.check_v01() {
|
||
Err(AttachError::NotImplementedInV01("TLS")) => {}
|
||
other => panic!("expected NotImplementedInV01(TLS), got {other:?}"),
|
||
}
|
||
let custom = AttachTarget::parse("custom:docker exec").expect("custom parses");
|
||
match custom.check_v01() {
|
||
Err(AttachError::NotImplementedInV01("Custom")) => {}
|
||
other => panic!("expected NotImplementedInV01(Custom), got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn attach_target_error_display_delegates_to_inner() {
|
||
let p = AttachTargetError::Parse(AttachTargetParseError::SshMissingHost);
|
||
assert!(p.to_string().contains("ssh attach target requires a host"));
|
||
let v = AttachTargetError::Validate(AttachTargetValidationError::EmptyHost);
|
||
assert!(v.to_string().contains("must not be empty"));
|
||
}
|
||
|
||
// --- M5.6b: AttachmentHandle ---
|
||
|
||
fn sample_identity() -> InstanceIdentity {
|
||
InstanceIdentity {
|
||
pmacs_version: "0.1.0".into(),
|
||
build_hash: Some("a3f9c21".into()),
|
||
instance_name: Some("research".into()),
|
||
uptime_secs: 2_847,
|
||
working_directory: "/home/researcher/project".into(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_handle_new_constructs_all_fields() {
|
||
let id = sample_identity();
|
||
let target = AttachTarget::LocalSocket(PathBuf::from("/run/p.sock"));
|
||
let h = AttachmentHandle::new(FrontendId(7), id.clone(), target.clone());
|
||
assert_eq!(h.frontend_id, FrontendId(7));
|
||
assert_eq!(h.identity, id);
|
||
assert_eq!(h.target, target);
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_handle_clone_is_equal() {
|
||
let h = AttachmentHandle::new(
|
||
FrontendId(2),
|
||
sample_identity(),
|
||
AttachTarget::LocalSocket(PathBuf::from("/x")),
|
||
);
|
||
let cloned = h.clone();
|
||
assert_eq!(h, cloned);
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_handle_equality_includes_every_field() {
|
||
// Mutating any single field flips equality. Pin this so a
|
||
// future field addition doesn't silently weaken the comparison.
|
||
let base = AttachmentHandle::new(
|
||
FrontendId(2),
|
||
sample_identity(),
|
||
AttachTarget::LocalSocket(PathBuf::from("/x")),
|
||
);
|
||
|
||
let diff_id = AttachmentHandle {
|
||
frontend_id: FrontendId(3),
|
||
..base.clone()
|
||
};
|
||
assert_ne!(base, diff_id);
|
||
|
||
let diff_identity = AttachmentHandle {
|
||
identity: InstanceIdentity {
|
||
uptime_secs: 999,
|
||
..base.identity.clone()
|
||
},
|
||
..base.clone()
|
||
};
|
||
assert_ne!(base, diff_identity);
|
||
|
||
let diff_target = AttachmentHandle {
|
||
target: AttachTarget::LocalSocket(PathBuf::from("/other")),
|
||
..base.clone()
|
||
};
|
||
assert_ne!(base, diff_target);
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_handle_carries_ssh_target_for_v01_init_lua_use() {
|
||
// A user writes `pmacs.attach{ target = "ssh:host" }` in
|
||
// init.lua. v0.1 errors at activation, but the handle shape
|
||
// must be able to carry an Ssh target so M5.7 can ship without
|
||
// changing AttachmentHandle's surface.
|
||
let h = AttachmentHandle::new(
|
||
FrontendId(2),
|
||
sample_identity(),
|
||
AttachTarget::Ssh {
|
||
host: "mac-studio".into(),
|
||
user: Some("lev".into()),
|
||
instance_name: Some("research".into()),
|
||
},
|
||
);
|
||
assert_eq!(h.target.kind_name(), "ssh");
|
||
}
|
||
|
||
#[test]
|
||
fn attachment_handle_uses_assigned_frontend_id_not_local() {
|
||
// Daemon-attached frontends start at FrontendId(2); the LOCAL
|
||
// constant (FrontendId(1)) is reserved for an in-process TUI.
|
||
// Pin this so we don't accidentally hand back LOCAL from a
|
||
// remote handle.
|
||
let h = AttachmentHandle::new(
|
||
FrontendId(2),
|
||
sample_identity(),
|
||
AttachTarget::LocalSocket(PathBuf::from("/run/p.sock")),
|
||
);
|
||
assert_ne!(h.frontend_id, FrontendId::LOCAL);
|
||
assert_eq!(h.frontend_id, FrontendId(2));
|
||
}
|
||
|
||
// --- Crossterm translation round-trips ---
|
||
|
||
fn ct_key(code: KeyCode, mods: CtMods) -> CtKeyEvent {
|
||
CtKeyEvent {
|
||
code,
|
||
modifiers: mods,
|
||
kind: KeyEventKind::Press,
|
||
state: KeyEventState::empty(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn key_round_trip_for_named_keys() {
|
||
// Every named keycode must translate forward and back without
|
||
// loss. `KeyCode::Char` with every printable char is excessive;
|
||
// a representative sample plus the named variants is enough to
|
||
// catch a missed arm.
|
||
use crossterm_translate::{keycode_from_crossterm, keycode_to_crossterm};
|
||
let cases = [
|
||
KeyCode::Char('a'),
|
||
KeyCode::Char('Z'),
|
||
KeyCode::Char('5'),
|
||
KeyCode::Char(' '),
|
||
KeyCode::Char('é'),
|
||
KeyCode::F(1),
|
||
KeyCode::F(12),
|
||
KeyCode::Backspace,
|
||
KeyCode::Enter,
|
||
KeyCode::Left,
|
||
KeyCode::Right,
|
||
KeyCode::Up,
|
||
KeyCode::Down,
|
||
KeyCode::Home,
|
||
KeyCode::End,
|
||
KeyCode::PageUp,
|
||
KeyCode::PageDown,
|
||
KeyCode::Tab,
|
||
KeyCode::BackTab,
|
||
KeyCode::Delete,
|
||
KeyCode::Insert,
|
||
KeyCode::Esc,
|
||
KeyCode::Null,
|
||
KeyCode::CapsLock,
|
||
KeyCode::ScrollLock,
|
||
KeyCode::NumLock,
|
||
KeyCode::PrintScreen,
|
||
KeyCode::Pause,
|
||
KeyCode::Menu,
|
||
KeyCode::KeypadBegin,
|
||
];
|
||
for code in cases {
|
||
let pmacs_key = keycode_from_crossterm(code);
|
||
let back = keycode_to_crossterm(pmacs_key)
|
||
.unwrap_or_else(|| panic!("no reverse for {pmacs_key:?} (from {code:?})"));
|
||
assert_eq!(
|
||
back, code,
|
||
"round-trip mismatch: {code:?} → {pmacs_key:?} → {back:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn modifiers_round_trip_through_crossterm() {
|
||
use crossterm_translate::{mods_from_crossterm, mods_to_crossterm};
|
||
let pairs = [
|
||
(CtMods::empty(), Modifiers::NONE),
|
||
(CtMods::SHIFT, Modifiers::SHIFT),
|
||
(CtMods::CONTROL, Modifiers::CTRL),
|
||
(CtMods::ALT, Modifiers::ALT),
|
||
(CtMods::SUPER, Modifiers::META),
|
||
(CtMods::HYPER, Modifiers::HYPER),
|
||
(
|
||
CtMods::SHIFT | CtMods::CONTROL,
|
||
Modifiers::SHIFT | Modifiers::CTRL,
|
||
),
|
||
];
|
||
for (ct, pmacs) in pairs {
|
||
let forward = mods_from_crossterm(ct);
|
||
assert_eq!(
|
||
forward, pmacs,
|
||
"from_crossterm({ct:?}) = {forward:?}, expected {pmacs:?}"
|
||
);
|
||
let back = mods_to_crossterm(forward);
|
||
assert_eq!(
|
||
back, ct,
|
||
"to_crossterm({forward:?}) = {back:?}, expected {ct:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn key_event_translation_threads_frontend_id() {
|
||
use crossterm_translate::key_from_crossterm;
|
||
let id = FrontendId(7);
|
||
let ct = ct_key(KeyCode::Char('q'), CtMods::CONTROL);
|
||
let translated = key_from_crossterm(&ct, id, 12345);
|
||
assert_eq!(translated.frontend_id, id);
|
||
assert_eq!(translated.key, Key::Char('q'));
|
||
assert_eq!(translated.mods, Modifiers::CTRL);
|
||
assert_eq!(translated.timestamp_ns, 12345);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_keycode_does_not_round_trip_to_crossterm() {
|
||
use crossterm_translate::keycode_to_crossterm;
|
||
// Unknown is the escape hatch; reverse translation is `None`
|
||
// since there's no native crossterm equivalent.
|
||
assert!(keycode_to_crossterm(Key::Unknown(0x0101)).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn media_keycode_translates_to_unknown_with_stable_sentinel() {
|
||
use crossterm::event::MediaKeyCode;
|
||
use crossterm_translate::keycode_from_crossterm;
|
||
let k = keycode_from_crossterm(KeyCode::Media(MediaKeyCode::PlayPause));
|
||
match k {
|
||
Key::Unknown(n) => assert_eq!(n, 0x0103),
|
||
other => panic!("expected Key::Unknown, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn modifier_only_keycode_translates_to_unknown_with_stable_sentinel() {
|
||
use crossterm::event::ModifierKeyCode;
|
||
use crossterm_translate::keycode_from_crossterm;
|
||
let k = keycode_from_crossterm(KeyCode::Modifier(ModifierKeyCode::LeftShift));
|
||
match k {
|
||
Key::Unknown(n) => assert_eq!(n, 0x0201),
|
||
other => panic!("expected Key::Unknown, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_event_translation() {
|
||
use crossterm_translate::mouse_from_crossterm;
|
||
let id = FrontendId(3);
|
||
let ct = CtMouseEvent {
|
||
kind: MouseEventKind::Down(CtMouseButton::Left),
|
||
row: 5,
|
||
column: 10,
|
||
modifiers: CtMods::SHIFT,
|
||
};
|
||
let m = mouse_from_crossterm(&ct, id);
|
||
assert_eq!(m.frontend_id, id);
|
||
assert_eq!(m.kind, MouseKind::Down(MouseButton::Left));
|
||
assert_eq!(m.coord, CellCoord::new(5, 10));
|
||
assert_eq!(m.mods, Modifiers::SHIFT);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_kinds_cover_all_crossterm_variants() {
|
||
use crossterm_translate::mouse_from_crossterm;
|
||
let id = FrontendId::LOCAL;
|
||
let kinds = [
|
||
(
|
||
MouseEventKind::Down(CtMouseButton::Right),
|
||
MouseKind::Down(MouseButton::Right),
|
||
),
|
||
(
|
||
MouseEventKind::Up(CtMouseButton::Middle),
|
||
MouseKind::Up(MouseButton::Middle),
|
||
),
|
||
(
|
||
MouseEventKind::Drag(CtMouseButton::Left),
|
||
MouseKind::Drag(MouseButton::Left),
|
||
),
|
||
(MouseEventKind::Moved, MouseKind::Move),
|
||
(MouseEventKind::ScrollUp, MouseKind::ScrollUp),
|
||
(MouseEventKind::ScrollDown, MouseKind::ScrollDown),
|
||
(MouseEventKind::ScrollLeft, MouseKind::ScrollLeft),
|
||
(MouseEventKind::ScrollRight, MouseKind::ScrollRight),
|
||
];
|
||
for (ct_kind, expected) in kinds {
|
||
let ct = CtMouseEvent {
|
||
kind: ct_kind,
|
||
row: 0,
|
||
column: 0,
|
||
modifiers: CtMods::empty(),
|
||
};
|
||
let m = mouse_from_crossterm(&ct, id);
|
||
assert_eq!(m.kind, expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn instance_message_cell_delta_carries_full_grid_flag() {
|
||
let m = InstanceMessage::CellDelta {
|
||
spans: vec![],
|
||
full_grid: true,
|
||
};
|
||
match m {
|
||
InstanceMessage::CellDelta { full_grid, .. } => assert!(full_grid),
|
||
_ => unreachable!(),
|
||
}
|
||
}
|
||
|
||
// --- M5.5a handshake & postcard round-trips ---
|
||
|
||
#[test]
|
||
fn protocol_version_is_two_for_v10() {
|
||
// Pin the value: T M10.5 bumped from 1 to 2. The v1.0 wire
|
||
// adds CrdtOp / PresenceUpdate variants; the v1.0 binary
|
||
// serves both v1 and v2 sessions per §sec:m10-backward-compat.
|
||
assert_eq!(PROTOCOL_VERSION, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn supported_protocol_versions_includes_one_and_two() {
|
||
// T M10.5: v1.0 binaries accept both wire versions during the
|
||
// handshake. v0.1 binaries (with their strict-equality check)
|
||
// accepted only v1; this is the symmetric relaxation that
|
||
// makes §sec:m10-backward-compat hold once both binaries ship.
|
||
assert!(is_supported_protocol_version(1));
|
||
assert!(is_supported_protocol_version(2));
|
||
assert!(!is_supported_protocol_version(0));
|
||
assert!(!is_supported_protocol_version(3));
|
||
assert!(!is_supported_protocol_version(u32::MAX));
|
||
}
|
||
|
||
#[test]
|
||
fn hello_round_trips_through_postcard() {
|
||
let h = Hello {
|
||
protocol_version: PROTOCOL_VERSION,
|
||
assigned_frontend_id: FrontendId(7),
|
||
instance_identity: InstanceIdentity {
|
||
pmacs_version: "0.1.0".into(),
|
||
build_hash: Some("a3f9c21".into()),
|
||
instance_name: Some("research".into()),
|
||
uptime_secs: 2_847,
|
||
working_directory: "/home/researcher/project".into(),
|
||
},
|
||
instance_capabilities: InstanceCapabilities::default(),
|
||
};
|
||
let bytes = postcard::to_allocvec(&h).expect("encode");
|
||
let decoded: Hello = postcard::from_bytes(&bytes).expect("decode");
|
||
assert_eq!(decoded, h);
|
||
}
|
||
|
||
#[test]
|
||
fn attach_request_round_trips_through_postcard() {
|
||
let req = AttachRequest {
|
||
protocol_version: PROTOCOL_VERSION,
|
||
frontend_capabilities: FrontendCapabilities {
|
||
synchronized_output: true,
|
||
unicode_smp: true,
|
||
true_color: true,
|
||
mouse: true,
|
||
bracketed_paste: true,
|
||
terminal_kind: Some("xterm-256color".into()),
|
||
multi_frontend: false,
|
||
crdt_replica: false,
|
||
},
|
||
initial_size: CellSize::new(50, 200),
|
||
};
|
||
let bytes = postcard::to_allocvec(&req).expect("encode");
|
||
let decoded: AttachRequest = postcard::from_bytes(&bytes).expect("decode");
|
||
assert_eq!(decoded, req);
|
||
}
|
||
|
||
#[test]
|
||
fn frontend_capabilities_default_is_all_false() {
|
||
// The default-false posture is the protocol-evolution
|
||
// contract: a frontend that omits a capability is treated
|
||
// as not supporting it.
|
||
let c = FrontendCapabilities::default();
|
||
assert!(!c.synchronized_output);
|
||
assert!(!c.unicode_smp);
|
||
assert!(!c.true_color);
|
||
assert!(!c.mouse);
|
||
assert!(!c.bracketed_paste);
|
||
assert!(c.terminal_kind.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn frontend_capabilities_omitted_fields_default_on_decode() {
|
||
// Old-frontend / new-instance scenario: encode an empty
|
||
// postcard struct and decode it as a (potentially future)
|
||
// capability set. With `#[serde(default)]` on every field,
|
||
// missing fields land as their default values rather than
|
||
// a decode error. The wire shape we test here is a struct
|
||
// that postcard serializes as a sequence of its fields; the
|
||
// test fakes the "older wire" by encoding a smaller
|
||
// synthetic type.
|
||
//
|
||
// Concretely: encode a struct with only the bools (no
|
||
// terminal_kind). postcard serializes structs as positional
|
||
// sequences, so this exercises the sequence-shorter-than-struct
|
||
// path that `#[serde(default)]` rescues. A more thorough test
|
||
// would synthesize a fewer-field shadow struct, but for now
|
||
// we verify the all-defaults Default::default() decodes by
|
||
// round-trip.
|
||
let bytes = postcard::to_allocvec(&FrontendCapabilities::default()).expect("encode");
|
||
let decoded: FrontendCapabilities = postcard::from_bytes(&bytes).expect("decode");
|
||
assert_eq!(decoded, FrontendCapabilities::default());
|
||
}
|
||
|
||
#[test]
|
||
fn goodbye_version_mismatch_round_trips() {
|
||
let g = InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch {
|
||
server: PROTOCOL_VERSION,
|
||
client: 999,
|
||
});
|
||
let bytes = postcard::to_allocvec(&g).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client }) => {
|
||
assert_eq!(server, PROTOCOL_VERSION);
|
||
assert_eq!(client, 999);
|
||
}
|
||
other => panic!("expected VersionMismatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn goodbye_other_variants_round_trip() {
|
||
for reason in [
|
||
GoodbyeReason::ShuttingDown,
|
||
GoodbyeReason::AlreadyAttached,
|
||
GoodbyeReason::ProtocolError,
|
||
] {
|
||
let m = InstanceMessage::Goodbye(reason.clone());
|
||
let bytes = postcard::to_allocvec(&m).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
match (decoded, reason) {
|
||
(InstanceMessage::Goodbye(a), b) => assert_eq!(a, b),
|
||
(other, _) => panic!("expected Goodbye, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn frontend_event_detach_round_trips() {
|
||
let ev = FrontendEvent::Detach(FrontendId(42));
|
||
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
FrontendEvent::Detach(id) => assert_eq!(id, FrontendId(42)),
|
||
other => panic!("expected Detach, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn key_event_round_trips_through_postcard() {
|
||
let ev = FrontendEvent::Key(KeyEvent {
|
||
frontend_id: FrontendId(2),
|
||
key: Key::Char('q'),
|
||
mods: Modifiers::CTRL | Modifiers::SHIFT,
|
||
timestamp_ns: 1_700_000_000_000_000_000,
|
||
});
|
||
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
FrontendEvent::Key(k) => {
|
||
assert_eq!(k.frontend_id, FrontendId(2));
|
||
assert_eq!(k.key, Key::Char('q'));
|
||
assert_eq!(k.mods, Modifiers::CTRL | Modifiers::SHIFT);
|
||
assert_eq!(k.timestamp_ns, 1_700_000_000_000_000_000);
|
||
}
|
||
other => panic!("expected Key, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn key_event_to_crossterm_round_trips() {
|
||
// Build a protocol KeyEvent, translate to crossterm, translate
|
||
// back. The frontend_id and timestamp are stripped (crossterm
|
||
// doesn't carry them) but key + mods round-trip.
|
||
use crossterm_translate::{key_from_crossterm, key_to_crossterm};
|
||
let original = KeyEvent {
|
||
frontend_id: FrontendId(7),
|
||
key: Key::Char('x'),
|
||
mods: Modifiers::CTRL | Modifiers::ALT,
|
||
timestamp_ns: 42,
|
||
};
|
||
let ct = key_to_crossterm(&original).expect("translatable");
|
||
let back = key_from_crossterm(&ct, FrontendId(7), 42);
|
||
assert_eq!(back, original);
|
||
}
|
||
|
||
#[test]
|
||
fn key_event_to_crossterm_returns_none_for_unknown() {
|
||
use crossterm_translate::key_to_crossterm;
|
||
let ev = KeyEvent {
|
||
frontend_id: FrontendId::LOCAL,
|
||
key: Key::Unknown(0x0103),
|
||
mods: Modifiers::NONE,
|
||
timestamp_ns: 0,
|
||
};
|
||
assert!(key_to_crossterm(&ev).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_event_to_crossterm_round_trips() {
|
||
use crossterm_translate::{mouse_from_crossterm, mouse_to_crossterm};
|
||
let original = MouseEvent {
|
||
frontend_id: FrontendId(3),
|
||
kind: MouseKind::Drag(MouseButton::Right),
|
||
coord: CellCoord::new(7, 22),
|
||
mods: Modifiers::SHIFT,
|
||
};
|
||
let ct = mouse_to_crossterm(&original);
|
||
let back = mouse_from_crossterm(&ct, FrontendId(3));
|
||
assert_eq!(back, original);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_keycode_round_trips_with_sentinel_preserved() {
|
||
// The Unknown variant carries an opaque u32; round-tripping it
|
||
// through postcard must preserve the exact value so frontends
|
||
// that introduce new keycodes don't lose them in transit.
|
||
let ev = FrontendEvent::Key(KeyEvent {
|
||
frontend_id: FrontendId::LOCAL,
|
||
key: Key::Unknown(0x0103), // Media::PlayPause sentinel
|
||
mods: Modifiers::NONE,
|
||
timestamp_ns: 0,
|
||
});
|
||
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
FrontendEvent::Key(k) => assert_eq!(k.key, Key::Unknown(0x0103)),
|
||
other => panic!("expected Key, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// T M10.5 round-trip tests for the new wire variants.
|
||
// -----------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn instance_message_crdt_op_round_trips_through_postcard() {
|
||
// Synthetic CrdtOp with known peer_id + arbitrary bytes.
|
||
// Verifies the protocol-level serialization shape. The
|
||
// real-loro-bytes variant is in the test below.
|
||
let msg = InstanceMessage::CrdtOp {
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
op: crate::rope::CrdtOp {
|
||
peer_id: 0x1234_5678_9abc_def0,
|
||
bytes: vec![1, 2, 3, 4, 5, 0xFF, 0xFE, 0xFD],
|
||
},
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
InstanceMessage::CrdtOp {
|
||
op: crate::rope::CrdtOp { peer_id, bytes: ob },
|
||
..
|
||
} => {
|
||
assert_eq!(peer_id, 0x1234_5678_9abc_def0);
|
||
assert_eq!(ob, vec![1, 2, 3, 4, 5, 0xFF, 0xFE, 0xFD]);
|
||
}
|
||
other => panic!("expected CrdtOp, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn frontend_event_crdt_op_round_trips_through_postcard() {
|
||
let ev = FrontendEvent::CrdtOp {
|
||
frontend_id: FrontendId(42),
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
op: crate::rope::CrdtOp {
|
||
peer_id: 99,
|
||
bytes: vec![0xAA, 0xBB, 0xCC],
|
||
},
|
||
};
|
||
let bytes = postcard::to_allocvec(&ev).expect("encode");
|
||
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
FrontendEvent::CrdtOp {
|
||
frontend_id, op, ..
|
||
} => {
|
||
assert_eq!(frontend_id, FrontendId(42));
|
||
assert_eq!(op.peer_id, 99);
|
||
assert_eq!(op.bytes, vec![0xAA, 0xBB, 0xCC]);
|
||
}
|
||
other => panic!("expected FrontendEvent::CrdtOp, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "crdt")]
|
||
#[test]
|
||
fn instance_message_crdt_op_round_trips_with_real_loro_bytes() {
|
||
// T M10.5 framing-pass addition: use actual loro-exported
|
||
// bytes (not synthetic) so the test catches surprising
|
||
// interactions between loro's wire format and postcard's
|
||
// encoding. Also logs the per-CrdtOp wire byte size — a
|
||
// reference number M10.8's broadcast-cost reasoning relies on.
|
||
use crate::crdt::CrdtState;
|
||
let state = CrdtState::new(7).expect("CRDT state");
|
||
let pre_version = state.version();
|
||
state.insert(0, "hello world").expect("insert");
|
||
let real_bytes = state.export_updates_since(&pre_version).expect("export");
|
||
let real_bytes_len = real_bytes.len();
|
||
let msg = InstanceMessage::CrdtOp {
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
op: crate::rope::CrdtOp {
|
||
peer_id: 7,
|
||
bytes: real_bytes.clone(),
|
||
},
|
||
};
|
||
let postcard_bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let postcard_len = postcard_bytes.len();
|
||
eprintln!(
|
||
"[T M10.5 wire-size] real-loro CrdtOp for `hello world` insert:\n \
|
||
loro export bytes: {} B\n \
|
||
postcard-encoded InstanceMessage::CrdtOp: {} B\n \
|
||
protocol overhead: {} B (BufferId + peer_id + framing)",
|
||
real_bytes_len,
|
||
postcard_len,
|
||
postcard_len.saturating_sub(real_bytes_len)
|
||
);
|
||
let decoded: InstanceMessage = postcard::from_bytes(&postcard_bytes).expect("decode");
|
||
match decoded {
|
||
InstanceMessage::CrdtOp { op, .. } => {
|
||
assert_eq!(op.peer_id, 7);
|
||
assert_eq!(
|
||
op.bytes, real_bytes,
|
||
"loro bytes must round-trip identically"
|
||
);
|
||
// Verify the round-tripped bytes apply on a remote
|
||
// CrdtState and produce the originating state's
|
||
// projection — the property M10.5's wire codec must
|
||
// preserve for M10.8's broadcast path to work.
|
||
let receiver = CrdtState::new(99).expect("receiver");
|
||
receiver.import_updates(&op.bytes).expect("import");
|
||
assert_eq!(receiver.materialize_string(), "hello world");
|
||
}
|
||
other => panic!("expected CrdtOp, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// T M10.5 — backward-compat handshake matrix tests.
|
||
//
|
||
// Four cases per the framing-pass handshake matrix:
|
||
// 1. v1 daemon ↔ v1 frontend: pre-existing behavior; not retested.
|
||
// 2. v1 daemon ↔ v2 frontend: rejected with VersionMismatch.
|
||
// 3. v2 daemon ↔ v1 frontend: success; v1 session.
|
||
// 4. v2 daemon ↔ v2 frontend: success; v2 session.
|
||
//
|
||
// These tests exercise `is_supported_protocol_version` directly
|
||
// since the full daemon-attach path requires socket setup that's
|
||
// in m5_5_acceptance.rs. The version-check predicate is the
|
||
// load-bearing piece; daemon-level integration tests are in the
|
||
// separate integration test file.
|
||
// -----------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn m10_5_handshake_matrix_v2_daemon_accepts_v1_frontend() {
|
||
// The relaxation that makes §sec:m10-backward-compat hold.
|
||
assert!(
|
||
is_supported_protocol_version(1),
|
||
"v2 daemon must accept v1 frontend per §sec:m10-backward-compat"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn m10_5_handshake_matrix_v2_daemon_accepts_v2_frontend() {
|
||
// The new case M10.5 enables.
|
||
assert!(
|
||
is_supported_protocol_version(2),
|
||
"v2 daemon must accept v2 frontend (the v1.0 happy path)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn m10_5_handshake_matrix_versions_outside_range_rejected() {
|
||
// v1 daemon's strict-equality behavior is documented at the
|
||
// v0.1 code level (different binary); v2 daemon's range check
|
||
// rejects v3+ until v0.2 ships.
|
||
assert!(!is_supported_protocol_version(0));
|
||
assert!(!is_supported_protocol_version(3));
|
||
assert!(!is_supported_protocol_version(u32::MAX));
|
||
}
|
||
|
||
#[test]
|
||
fn m10_5_strict_equality_v1_frontend_simulation() {
|
||
// T M10.5 framing-pass risk #5 verification: existing v1
|
||
// frontends (the v0.1.0 release codebase, pre-M10.5) do
|
||
// strict equality on Hello.protocol_version. Simulate that
|
||
// check explicitly so the audit doc has empirical evidence
|
||
// of the actual backward-compat surface.
|
||
//
|
||
// Before M10.5: `if hello.protocol_version != 1 { reject }`.
|
||
// After M10.5: `if !is_supported_protocol_version(...) { reject }`.
|
||
//
|
||
// For a v1-strict-frontend connecting to a v2 daemon: the
|
||
// daemon's Hello carries protocol_version=2; the v1-strict
|
||
// frontend rejects with VersionMismatch.
|
||
fn v1_strict_check(hello_version: u32) -> bool {
|
||
hello_version == 1
|
||
}
|
||
// v1-strict frontend hitting v2 daemon's Hello: rejected.
|
||
assert!(
|
||
!v1_strict_check(2),
|
||
"v1-strict frontend rejects v2 daemon's Hello — pre-M10.5 binaries \
|
||
can NOT connect to v2 daemons even though v2 daemons accept their requests"
|
||
);
|
||
// v1-strict frontend hitting v1 daemon's Hello: accepted.
|
||
assert!(v1_strict_check(1));
|
||
// For comparison, M10.5's relaxed check (v2 frontend after this milestone):
|
||
assert!(is_supported_protocol_version(1));
|
||
assert!(is_supported_protocol_version(2));
|
||
}
|
||
|
||
// T M10.6 — PresenceUpdate wire shape tests.
|
||
|
||
#[test]
|
||
fn instance_message_presence_update_round_trips_no_selection() {
|
||
let msg = InstanceMessage::PresenceUpdate {
|
||
frontend_id: FrontendId(42),
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
cursor: 100,
|
||
selection: None,
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
assert_eq!(msg, decoded);
|
||
}
|
||
|
||
#[test]
|
||
fn instance_message_presence_update_round_trips_with_selection() {
|
||
let msg = InstanceMessage::PresenceUpdate {
|
||
frontend_id: FrontendId(7),
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
cursor: 500,
|
||
selection: Some(SelectionSnapshot {
|
||
anchor: 480,
|
||
active: 500,
|
||
}),
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
assert_eq!(msg, decoded);
|
||
}
|
||
|
||
#[test]
|
||
fn presence_update_typical_size_under_64_bytes() {
|
||
// T M10.6 size acceptance — typical case: cursor at offset
|
||
// 100 in a small buffer, no selection. Should be well under
|
||
// 64B (varint encoding of small u64s is 1-2 bytes each).
|
||
let msg = InstanceMessage::PresenceUpdate {
|
||
frontend_id: FrontendId(2),
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
cursor: 100,
|
||
selection: None,
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let size = bytes.len();
|
||
eprintln!(
|
||
"[T M10.6 wire-size] PresenceUpdate typical (cursor=100, no selection): {size} B"
|
||
);
|
||
assert!(
|
||
size < 64,
|
||
"typical PresenceUpdate is {size} B; spec target is <64 B"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn presence_update_worst_case_size_recorded() {
|
||
// T M10.6 size acceptance — worst case: max u64 values for
|
||
// every position field, selection present spanning a large
|
||
// range. Varint encoding of u64::MAX is 10 bytes; this is
|
||
// the upper bound on a single PresenceUpdate's wire size.
|
||
// Recording the actual number for the audit doc.
|
||
let msg = InstanceMessage::PresenceUpdate {
|
||
frontend_id: FrontendId(u64::MAX),
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
cursor: u64::MAX,
|
||
selection: Some(SelectionSnapshot {
|
||
anchor: 0,
|
||
active: u64::MAX,
|
||
}),
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let size = bytes.len();
|
||
eprintln!(
|
||
"[T M10.6 wire-size] PresenceUpdate worst-case (all-max u64s + selection): {size} B"
|
||
);
|
||
// Worst-case bound: 1 (variant tag) + 10 (frontend_id) + ~2
|
||
// (BufferId varint — small) + 10 (cursor) + 1 (Some tag) +
|
||
// 10 (anchor zero = 1B) + 10 (active = u64::MAX = 10B) = ~44
|
||
// upper bound. Buffer-id is freshly minted so its varint
|
||
// encoding is small. We assert <64 to cover the spec target,
|
||
// and log the actual number for the audit.
|
||
assert!(
|
||
size < 64,
|
||
"worst-case PresenceUpdate is {size} B; spec target is <64 B"
|
||
);
|
||
}
|
||
|
||
// -----------------------------------------------------------------
|
||
// T M10.10 round-trip + size tests for BufferSnapshot.
|
||
// -----------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn instance_message_buffer_snapshot_round_trips_through_postcard() {
|
||
// Synthetic loro-snapshot bytes — the wire-level test is
|
||
// independent of the actual loro encoding.
|
||
let msg = InstanceMessage::BufferSnapshot {
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
crdt_snapshot: vec![0xCD, 0x07, 0x00, 0x01, 0x02, 0x03, 0xFF],
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => {
|
||
assert_eq!(
|
||
crdt_snapshot,
|
||
vec![0xCD, 0x07, 0x00, 0x01, 0x02, 0x03, 0xFF]
|
||
);
|
||
}
|
||
other => panic!("expected BufferSnapshot, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn instance_message_cursor_byte_round_trips_through_postcard() {
|
||
let msg = InstanceMessage::CursorByte {
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
byte_pos: 12345,
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
InstanceMessage::CursorByte { byte_pos, .. } => assert_eq!(byte_pos, 12345),
|
||
other => panic!("expected CursorByte, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn instance_message_cursor_byte_zero_position_round_trips() {
|
||
let msg = InstanceMessage::CursorByte {
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
byte_pos: 0,
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
assert!(matches!(
|
||
decoded,
|
||
InstanceMessage::CursorByte { byte_pos: 0, .. }
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn instance_message_buffer_snapshot_empty_snapshot_round_trips() {
|
||
// An empty CRDT (no edits yet) — loro's export produces a
|
||
// small but non-zero byte string. The wire layer must round-trip
|
||
// a zero-length crdt_snapshot regardless of whether loro ever
|
||
// emits one.
|
||
let msg = InstanceMessage::BufferSnapshot {
|
||
buffer_id: crate::buffer::BufferId::next(),
|
||
crdt_snapshot: vec![],
|
||
};
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
match decoded {
|
||
InstanceMessage::BufferSnapshot { crdt_snapshot, .. } => {
|
||
assert!(crdt_snapshot.is_empty());
|
||
}
|
||
other => panic!("expected BufferSnapshot, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// T M10.7 — capability negotiation matrix + error round-trip.
|
||
|
||
/// Build a `FrontendCapabilities` with the M10-era negotiated
|
||
/// bits set as specified and all other fields at their default.
|
||
fn front_caps(multi_frontend: bool, crdt_replica: bool) -> FrontendCapabilities {
|
||
FrontendCapabilities {
|
||
multi_frontend,
|
||
crdt_replica,
|
||
..FrontendCapabilities::default()
|
||
}
|
||
}
|
||
|
||
fn inst_caps(multi_frontend: bool, crdt_replica: bool) -> InstanceCapabilities {
|
||
InstanceCapabilities {
|
||
multi_frontend,
|
||
crdt_replica,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_neither_side_declares_anything() {
|
||
let res = negotiate_capabilities(&front_caps(false, false), &inst_caps(false, false))
|
||
.expect("ok");
|
||
assert!(!res.multi_frontend);
|
||
assert!(!res.crdt_replica);
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_frontend_silent_instance_offers() {
|
||
// Frontend didn't request, instance has — frontend's silence
|
||
// is accepted as "single-frontend subset is fine."
|
||
let res =
|
||
negotiate_capabilities(&front_caps(false, false), &inst_caps(true, true)).expect("ok");
|
||
assert!(!res.multi_frontend, "frontend didn't ask → doesn't get");
|
||
assert!(!res.crdt_replica, "frontend didn't ask → doesn't get");
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_both_sides_declare_multi_frontend() {
|
||
let res =
|
||
negotiate_capabilities(&front_caps(true, false), &inst_caps(true, false)).expect("ok");
|
||
assert!(res.multi_frontend);
|
||
assert!(!res.crdt_replica);
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_both_sides_declare_both_bits() {
|
||
let res =
|
||
negotiate_capabilities(&front_caps(true, true), &inst_caps(true, true)).expect("ok");
|
||
assert!(res.multi_frontend);
|
||
assert!(res.crdt_replica);
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_frontend_wants_multi_instance_lacks() {
|
||
// T M10.7 criterion 4 — mismatch produces clear error
|
||
// naming what was requested vs available.
|
||
let err = negotiate_capabilities(&front_caps(true, false), &inst_caps(false, false))
|
||
.expect_err("should mismatch");
|
||
match err {
|
||
GoodbyeReason::CapabilityMismatch { missing } => {
|
||
assert_eq!(missing, vec!["multi_frontend".to_string()]);
|
||
}
|
||
other => panic!("expected CapabilityMismatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_frontend_wants_crdt_replica_instance_lacks() {
|
||
let err = negotiate_capabilities(&front_caps(false, true), &inst_caps(false, false))
|
||
.expect_err("should mismatch");
|
||
match err {
|
||
GoodbyeReason::CapabilityMismatch { missing } => {
|
||
assert_eq!(missing, vec!["crdt_replica".to_string()]);
|
||
}
|
||
other => panic!("expected CapabilityMismatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_frontend_wants_both_instance_lacks_both() {
|
||
// Multiple missing bits land in a single CapabilityMismatch
|
||
// — one round-trip carries the complete picture.
|
||
let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(false, false))
|
||
.expect_err("should mismatch");
|
||
match err {
|
||
GoodbyeReason::CapabilityMismatch { missing } => {
|
||
assert_eq!(
|
||
missing,
|
||
vec!["multi_frontend".to_string(), "crdt_replica".to_string()]
|
||
);
|
||
}
|
||
other => panic!("expected CapabilityMismatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn negotiate_partial_mismatch_only_lists_missing() {
|
||
// Frontend wants both, instance has multi but not crdt:
|
||
// only crdt_replica lands in `missing`.
|
||
let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(true, false))
|
||
.expect_err("should mismatch");
|
||
match err {
|
||
GoodbyeReason::CapabilityMismatch { missing } => {
|
||
assert_eq!(missing, vec!["crdt_replica".to_string()]);
|
||
}
|
||
other => panic!("expected CapabilityMismatch, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn goodbye_capability_mismatch_round_trips() {
|
||
let msg = InstanceMessage::Goodbye(GoodbyeReason::CapabilityMismatch {
|
||
missing: vec!["multi_frontend".to_string(), "crdt_replica".to_string()],
|
||
});
|
||
let bytes = postcard::to_allocvec(&msg).expect("encode");
|
||
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
|
||
assert_eq!(msg, decoded);
|
||
}
|
||
|
||
#[test]
|
||
fn missing_strings_are_field_names_not_descriptions() {
|
||
// T M10.7 wire-format-stability commitment: the strings
|
||
// emitted into `missing` are exactly the
|
||
// `FrontendCapabilities`/`InstanceCapabilities` field names.
|
||
// Human-readable translation happens in
|
||
// `AttachError::Display`, not on the wire. Renaming a bit
|
||
// requires updating both this emission and the field name
|
||
// in lockstep — this test pins the current names so a
|
||
// future rename forces an audit-visible diff here too.
|
||
let err = negotiate_capabilities(&front_caps(true, true), &inst_caps(false, false))
|
||
.expect_err("should mismatch");
|
||
match err {
|
||
GoodbyeReason::CapabilityMismatch { missing } => {
|
||
// The exact strings the wire carries — no
|
||
// pluralization, no hyphenation, no human polish.
|
||
assert!(
|
||
missing
|
||
.iter()
|
||
.all(|s| s.chars().all(|c| c.is_ascii_lowercase() || c == '_')),
|
||
"missing strings must be field-name identifiers (ascii lowercase + underscore), got {missing:?}"
|
||
);
|
||
}
|
||
other => panic!("expected CapabilityMismatch, got {other:?}"),
|
||
}
|
||
}
|
||
}
|