Skip to main content

termwright_protocol/
messages.rs

1//! Wire messages: typed builders for what an adapter sends, checked parsers
2//! for what it receives.
3//!
4//! The adapter pushes commits, the driver issues requests, and either side may
5//! send an error and close. Everything is validated against the active limits
6//! before it is retained; failures are returned, never raised.
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::error::ParseError;
12use crate::framing::project_dto;
13use crate::limits::Limits;
14use crate::logs::{validate_log_record, LogRecord};
15use crate::marker::MAX_SAFE_INTEGER;
16use crate::roles::{valid_capability, Capability, ADAPTER_CAPABILITIES};
17use crate::tree::Snapshot;
18use crate::validate::{validate_snapshot, validate_tree_delta};
19
20/// The wire protocol identifier both sides must agree on.
21pub const PROTOCOL_ID: &str = "termwright/1";
22/// Qualified observation protocol identifier.
23pub const PROTOCOL_V2_ID: &str = "termwright/2";
24
25/// The current major version.
26pub const PROTOCOL_VERSION: u8 = 1;
27
28/// Longest token, identifier or free-text message accepted.
29const MAX_IDENTIFIER_LENGTH: usize = 1024;
30
31const ERROR_CODES: [&str; 5] = [
32    "bad-token",
33    "bad-version",
34    "malformed",
35    "limit-exceeded",
36    "internal",
37];
38
39const LIMIT_FIELDS: [&str; 11] = [
40    "maxFrameBytes",
41    "maxSnapshotBytes",
42    "maxNodes",
43    "maxDepth",
44    "maxStringBytes",
45    "maxRelationTargets",
46    "maxQueuedFrames",
47    "maxPendingWaiters",
48    "maxSessions",
49    "maxLogRecordBytes",
50    "maxLogQueue",
51];
52
53/// Identifies the adapter implementation to the driver.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct AdapterInfo {
56    /// Accessible name; empty when the node has none.
57    pub name: String,
58    /// Adapter version string.
59    pub version: String,
60}
61
62/// The adapter's handshake: sent exactly once, before anything else.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct Hello {
65    /// Wire discriminator (`type` on the wire).
66    #[serde(rename = "type")]
67    pub kind: String,
68    /// Protocol identifier; must be `termwright/1`.
69    pub protocol: String,
70    /// Per-launch session token from the environment.
71    pub token: String,
72    /// Adapter name and version.
73    pub adapter: AdapterInfo,
74    /// What this adapter can provide.
75    pub capabilities: Vec<Capability>,
76    /// Present when the sender is a probe rather than a hand-written adapter.
77    ///
78    /// Carries what the probe can actually observe — framework and versions,
79    /// the best identity it can produce, and its optional abilities — so the
80    /// driver negotiates against measured capability rather than a floor.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub probe: Option<ProbeInfo>,
83}
84
85/// How an object's identity behaves across frames.
86///
87/// `FrameLocal` is a legitimate answer, not a degraded one: in immediate mode
88/// the widget is consumed by the render and nothing survives to be named
89/// again. A consumer must not correlate frame-local values between frames.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum ProbeIdentityKind {
93    /// Identities survive across frames and may be correlated.
94    Stable,
95    /// Identities are meaningful only within their own frame.
96    FrameLocal,
97}
98
99/// What a probe says about itself when it attaches.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct ProbeInfo {
103    /// Framework name, e.g. `ratatui`.
104    pub framework: String,
105    /// Version of the framework, when the probe can determine it.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub framework_version: Option<String>,
108    /// Version of the probe itself, so a mismatch is diagnosable.
109    pub probe_version: String,
110    /// The best identity this probe can offer for any object.
111    pub identity_kind: ProbeIdentityKind,
112    /// Optional abilities, from the protocol's closed set.
113    pub capabilities: Vec<String>,
114}
115
116impl Hello {
117    /// Build a handshake for this adapter.
118    pub fn new(token: &str, name: &str, version: &str, capabilities: Vec<Capability>) -> Self {
119        Self {
120            kind: "hello".into(),
121            protocol: PROTOCOL_ID.into(),
122            token: token.to_owned(),
123            adapter: AdapterInfo {
124                name: name.to_owned(),
125                version: version.to_owned(),
126            },
127            capabilities,
128            probe: None,
129        }
130    }
131
132    /// Build a protocol v2 handshake, adding its required capability.
133    pub fn new_v2(
134        token: &str,
135        name: &str,
136        version: &str,
137        mut capabilities: Vec<Capability>,
138    ) -> Self {
139        if !capabilities.contains(&Capability::QualifiedObservations) {
140            capabilities.push(Capability::QualifiedObservations);
141        }
142        let mut hello = Self::new(token, name, version, capabilities);
143        hello.protocol = PROTOCOL_V2_ID.into();
144        hello
145    }
146
147    /// Attach a probe's declaration to this handshake.
148    #[must_use]
149    pub fn with_probe(mut self, probe: ProbeInfo) -> Self {
150        self.probe = Some(probe);
151        self
152    }
153}
154
155/// Whether the adapter should emit render markers.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
157pub struct MarkerConfig {
158    /// Whether the adapter should emit render markers.
159    pub enabled: bool,
160}
161
162/// The log-channel allowance, sent only when the adapter announced the `logs`
163/// capability. Absent means logs are disabled: an adapter that receives no
164/// budget must not emit log messages at all.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct LogBudget {
168    /// Whether the driver wants log records at all.
169    pub enabled: bool,
170    /// Sustained ceiling on records per second.
171    pub max_records_per_second: i64,
172    /// Records allowed in a burst on top of the sustained rate.
173    pub burst: i64,
174}
175
176/// The driver's reply: session id, negotiated limits, what to push.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "camelCase")]
179pub struct HelloAck {
180    /// Wire discriminator (`type` on the wire).
181    #[serde(rename = "type")]
182    pub kind: String,
183    /// Protocol identifier; must be `termwright/1`.
184    pub protocol: String,
185    /// Session this snapshot belongs to.
186    pub session_id: String,
187    /// Ceilings the driver imposes for this session.
188    pub limits: Limits,
189    /// What the driver wants pushed: snapshots or revisions.
190    pub subscribe: String,
191    /// Whether render markers are wanted.
192    pub marker: MarkerConfig,
193    /// Log-channel budget; `None` means logs are disabled.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub logs: Option<LogBudget>,
196}
197
198/// Announces that a render was committed to the terminal.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200pub struct RevisionCommit {
201    /// Wire discriminator (`type` on the wire).
202    #[serde(rename = "type")]
203    pub kind: &'static str,
204    /// Render revision, strictly increasing per session.
205    pub revision: i64,
206}
207
208impl RevisionCommit {
209    /// Commit `revision`.
210    pub fn new(revision: i64) -> Self {
211        Self {
212            kind: "revision-commit",
213            revision,
214        }
215    }
216}
217
218/// Carries a full tree for one revision.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220pub struct SnapshotMessage<'a> {
221    /// Wire discriminator (`type` on the wire).
222    #[serde(rename = "type")]
223    pub kind: &'static str,
224    /// The tree being carried.
225    pub snapshot: &'a Snapshot,
226}
227
228impl<'a> SnapshotMessage<'a> {
229    /// Wrap a snapshot in its envelope.
230    pub fn new(snapshot: &'a Snapshot) -> Self {
231        Self {
232            kind: "snapshot",
233            snapshot,
234        }
235    }
236}
237
238/// The driver asking for a tree: the latest, or a held revision.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "camelCase")]
241pub struct GetTree {
242    /// Wire discriminator (`type` on the wire).
243    #[serde(rename = "type")]
244    pub kind: String,
245    /// Correlates a request with its answer.
246    pub request_id: i64,
247    /// Render revision, strictly increasing per session.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub revision: Option<i64>,
250}
251
252/// Answers a [`GetTree`] with exactly one of a snapshot or an error.
253#[derive(Debug, Clone, Serialize)]
254#[serde(rename_all = "camelCase")]
255pub struct GetTreeResult {
256    /// Wire discriminator (`type` on the wire).
257    #[serde(rename = "type")]
258    pub kind: &'static str,
259    /// Correlates a request with its answer.
260    pub request_id: i64,
261    /// The tree being carried.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub snapshot: Option<Box<serde_json::value::RawValue>>,
264    /// Why the request could not be answered.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub error: Option<String>,
267}
268
269impl GetTreeResult {
270    /// Answer with a retained snapshot body.
271    pub fn found(request_id: i64, snapshot: Box<serde_json::value::RawValue>) -> Self {
272        Self {
273            kind: "get-tree-result",
274            request_id,
275            snapshot: Some(snapshot),
276            error: None,
277        }
278    }
279
280    /// Answer that the requested revision is not available.
281    pub fn missing(request_id: i64, detail: impl Into<String>) -> Self {
282        Self {
283            kind: "get-tree-result",
284            request_id,
285            snapshot: None,
286            error: Some(detail.into()),
287        }
288    }
289}
290
291/// Carries one application log record to the driver.
292#[derive(Debug, Clone, PartialEq, Serialize)]
293pub struct LogMessage<'a> {
294    /// Wire discriminator (`type` on the wire).
295    #[serde(rename = "type")]
296    pub kind: &'static str,
297    /// The record.
298    pub record: &'a LogRecord,
299}
300
301impl<'a> LogMessage<'a> {
302    /// Wrap a record in its envelope.
303    pub fn new(record: &'a LogRecord) -> Self {
304        Self {
305            kind: "log",
306            record,
307        }
308    }
309}
310
311/// Terminal error: the sender closes after emitting it.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct ProtocolErrorMessage {
314    /// Wire discriminator (`type` on the wire).
315    #[serde(rename = "type")]
316    pub kind: String,
317    /// One of the five wire error codes.
318    pub code: String,
319    /// Human-readable detail; never carries the token.
320    pub message: String,
321}
322
323impl ProtocolErrorMessage {
324    /// Build an error message with one of the five wire codes.
325    pub fn new(code: &str, message: impl Into<String>) -> Self {
326        Self {
327            kind: "error".into(),
328            code: code.to_owned(),
329            message: message.into(),
330        }
331    }
332}
333
334/// Every capability a tree-publishing adapter with real bounds announces.
335pub fn default_capabilities() -> Vec<Capability> {
336    vec![
337        Capability::Tree,
338        Capability::Bounds,
339        Capability::AbsoluteBounds,
340        Capability::States,
341        Capability::Actions,
342        Capability::RenderRevisions,
343    ]
344}
345
346// -- parsing ---------------------------------------------------------------
347
348fn project(value: &Value, limits: &Limits) -> Result<(), ParseError> {
349    project_dto(value, limits.max_depth).map_err(|violation| {
350        if violation.code == "dto-depth" {
351            ParseError::new("limit-exceeded", violation.to_string())
352        } else {
353            ParseError::malformed(violation.to_string())
354        }
355    })
356}
357
358fn as_message(value: &Value) -> Result<(&Map<String, Value>, &str), ParseError> {
359    let object = value
360        .as_object()
361        .ok_or_else(|| ParseError::malformed("unknown or missing message type"))?;
362    let kind = object
363        .get("type")
364        .and_then(Value::as_str)
365        .ok_or_else(|| ParseError::malformed("unknown or missing message type"))?;
366    Ok((object, kind))
367}
368
369/// Check that every required key is present, tolerating unknown ones.
370fn required_keys(object: &Map<String, Value>, required: &[&str]) -> Result<(), ParseError> {
371    for key in required {
372        if !object.contains_key(*key) {
373            return Err(ParseError::malformed(format!("missing field \"{key}\"")));
374        }
375    }
376    Ok(())
377}
378
379fn require_keys(
380    object: &Map<String, Value>,
381    required: &[&str],
382    optional: &[&str],
383) -> Result<(), ParseError> {
384    for key in required {
385        if !object.contains_key(*key) {
386            return Err(ParseError::malformed(format!("missing field \"{key}\"")));
387        }
388    }
389    for key in object.keys() {
390        if !required.contains(&key.as_str()) && !optional.contains(&key.as_str()) {
391            return Err(ParseError::malformed(format!("unrecognized key \"{key}\"")));
392        }
393    }
394    Ok(())
395}
396
397fn identifier(object: &Map<String, Value>, key: &str, allow_empty: bool) -> Result<(), ParseError> {
398    let Some(text) = object.get(key).and_then(Value::as_str) else {
399        return Err(ParseError::malformed(format!("{key}: expected a string")));
400    };
401    if text.len() > MAX_IDENTIFIER_LENGTH {
402        return Err(ParseError::malformed(format!(
403            "{key}: expected at most {MAX_IDENTIFIER_LENGTH} characters"
404        )));
405    }
406    if !allow_empty && text.is_empty() {
407        return Err(ParseError::malformed(format!(
408            "{key}: expected a non-empty string"
409        )));
410    }
411    Ok(())
412}
413
414fn whole_number(object: &Map<String, Value>, key: &str, positive: bool) -> Result<(), ParseError> {
415    let number = object
416        .get(key)
417        .and_then(Value::as_i64)
418        .filter(|n| n.abs() <= MAX_SAFE_INTEGER);
419    match number {
420        Some(number) if positive && number > 0 => Ok(()),
421        Some(number) if !positive && number >= 0 => Ok(()),
422        _ if positive => Err(ParseError::malformed(format!(
423            "{key}: expected a positive safe integer"
424        ))),
425        _ => Err(ParseError::malformed(format!(
426            "{key}: expected a non-negative safe integer"
427        ))),
428    }
429}
430
431fn check_embedded_snapshot(value: &Value, limits: &Limits) -> Result<(), ParseError> {
432    match validate_snapshot(value, limits) {
433        Ok(()) => Ok(()),
434        Err(error) => {
435            let code = match error.code {
436                "bytes" | "count" | "depth" | "string-bytes" => "limit-exceeded",
437                _ => "malformed",
438            };
439            Err(ParseError::new(code, format!("snapshot {error}")))
440        }
441    }
442}
443
444/// Validate the optional log-channel budget carried by `hello-ack`.
445fn check_log_budget(value: &Value) -> Result<(), ParseError> {
446    let budget = value
447        .as_object()
448        .ok_or_else(|| ParseError::malformed("logs: expected an object"))?;
449    required_keys(budget, &["enabled", "maxRecordsPerSecond", "burst"])?;
450    if !budget["enabled"].is_boolean() {
451        return Err(ParseError::malformed("logs.enabled: expected a boolean"));
452    }
453    whole_number(budget, "maxRecordsPerSecond", true)?;
454    whole_number(budget, "burst", false)
455}
456
457fn check_error_message(object: &Map<String, Value>, strict: bool) -> Result<(), ParseError> {
458    if strict {
459        require_keys(object, &["type", "code", "message"], &[])?;
460    } else {
461        required_keys(object, &["type", "code", "message"])?;
462    }
463    let code = object
464        .get("code")
465        .and_then(Value::as_str)
466        .unwrap_or_default();
467    if !ERROR_CODES.contains(&code) {
468        return Err(ParseError::malformed("code: unknown error code"));
469    }
470    identifier(object, "message", true)
471}
472
473fn check_protocol_field(object: &Map<String, Value>) -> Result<(), ParseError> {
474    match object.get("protocol").and_then(Value::as_str) {
475        Some(protocol) if protocol != PROTOCOL_ID && protocol != PROTOCOL_V2_ID => Err(
476            ParseError::new("bad-version", format!("unsupported protocol {protocol}")),
477        ),
478        _ => Ok(()),
479    }
480}
481
482/// Validate one adapter → driver message.
483///
484/// Strict: an unknown field from an adapter is a protocol error, not an
485/// extension. See [`parse_driver_message`] for the other direction.
486///
487/// # Errors
488/// Returns a [`ParseError`] whose `code` is `bad-version`, `malformed` or
489/// `limit-exceeded`.
490pub fn parse_adapter_message(value: &Value, limits: &Limits) -> Result<(), ParseError> {
491    project(value, limits)?;
492    let (object, kind) = as_message(value)?;
493
494    match kind {
495        "hello" => {
496            check_protocol_field(object)?;
497            require_keys(
498                object,
499                &["type", "protocol", "token", "adapter", "capabilities"],
500                &[],
501            )?;
502            identifier(object, "token", false)?;
503            let adapter = object
504                .get("adapter")
505                .and_then(Value::as_object)
506                .ok_or_else(|| ParseError::malformed("adapter: expected an object"))?;
507            require_keys(adapter, &["name", "version"], &[])?;
508            identifier(adapter, "name", false)?;
509            identifier(adapter, "version", false)?;
510            let capabilities = object
511                .get("capabilities")
512                .and_then(Value::as_array)
513                .ok_or_else(|| ParseError::malformed("capabilities: expected an array"))?;
514            if capabilities.len() > ADAPTER_CAPABILITIES.len() {
515                return Err(ParseError::malformed("capabilities: too many entries"));
516            }
517            for item in capabilities {
518                match item.as_str() {
519                    Some(name) if valid_capability(name) => {}
520                    _ => return Err(ParseError::malformed("capabilities: unknown capability")),
521                }
522            }
523            let protocol = object
524                .get("protocol")
525                .and_then(Value::as_str)
526                .unwrap_or_default();
527            let qualified = capabilities
528                .iter()
529                .any(|item| item.as_str() == Some("qualified-observations"));
530            let pointer_grid = capabilities
531                .iter()
532                .any(|item| item.as_str() == Some("pointer-hit-grid"));
533            if (protocol == PROTOCOL_V2_ID) != qualified {
534                return Err(ParseError::malformed(
535                    "termwright/2 and qualified-observations must be negotiated together",
536                ));
537            }
538            if pointer_grid && !qualified {
539                return Err(ParseError::malformed(
540                    "pointer-hit-grid requires qualified-observations",
541                ));
542            }
543            Ok(())
544        }
545        "revision-commit" => {
546            require_keys(object, &["type", "revision"], &[])?;
547            whole_number(object, "revision", true)
548        }
549        "snapshot" => {
550            require_keys(object, &["type", "snapshot"], &[])?;
551            check_embedded_snapshot(&object["snapshot"], limits)
552        }
553        "get-tree-result" => {
554            require_keys(object, &["type", "requestId"], &["snapshot", "error"])?;
555            whole_number(object, "requestId", false)?;
556            let has_snapshot = object.contains_key("snapshot");
557            let has_error = object.contains_key("error");
558            if has_snapshot == has_error {
559                return Err(ParseError::malformed(
560                    "exactly one of snapshot or error must be present",
561                ));
562            }
563            if has_error {
564                return identifier(object, "error", true);
565            }
566            check_embedded_snapshot(&object["snapshot"], limits)
567        }
568        "tree-delta" => match validate_tree_delta(value, limits) {
569            Ok(()) => Ok(()),
570            Err(error) => {
571                let code = match error.code {
572                    "bytes" | "count" | "depth" | "string-bytes" => "limit-exceeded",
573                    _ => "malformed",
574                };
575                Err(ParseError::new(code, format!("tree-delta {error}")))
576            }
577        },
578        "log" => {
579            require_keys(object, &["type", "record"], &[])?;
580            check_embedded_log_record(&object["record"], limits)
581        }
582        "error" => check_error_message(object, true),
583        _ => Err(ParseError::malformed("unknown or missing message type")),
584    }
585}
586
587/// Map a record failure onto the wire taxonomy: capacity failures are
588/// `limit-exceeded`, the rest are `malformed`.
589fn check_embedded_log_record(value: &Value, limits: &Limits) -> Result<(), ParseError> {
590    match validate_log_record(value, limits) {
591        Ok(()) => Ok(()),
592        Err(error) => {
593            let code = match error.code {
594                "bytes" | "count" | "depth" | "string-bytes" => "limit-exceeded",
595                _ => "malformed",
596            };
597            Err(ParseError::new(code, format!("log record {error}")))
598        }
599    }
600}
601
602/// Validate one driver → adapter message.
603///
604/// Driver traffic is read tolerantly: unknown fields in the envelope and in
605/// the driver's nested objects (`marker`, `logs`, `limits`) are ignored and
606/// passed through to the caller, so a newer driver can add a field without
607/// breaking an adapter published before it existed.
608///
609/// The asymmetry is about who is speaking, not about the message: adapter
610/// traffic crosses an untrusted boundary, where an unknown field is a signal
611/// rather than an extension. Tolerance is not leniency either — known fields
612/// keep their types, and the closed sets (message types, error codes,
613/// `subscribe`, roles, actions) stay closed in both directions.
614///
615/// # Errors
616/// Returns a [`ParseError`] whose `code` is `bad-version`, `malformed` or
617/// `limit-exceeded`.
618pub fn parse_driver_message(value: &Value, limits: &Limits) -> Result<(), ParseError> {
619    project(value, limits)?;
620    let (object, kind) = as_message(value)?;
621
622    match kind {
623        "hello-ack" => {
624            check_protocol_field(object)?;
625            required_keys(
626                object,
627                &[
628                    "type",
629                    "protocol",
630                    "sessionId",
631                    "limits",
632                    "subscribe",
633                    "marker",
634                ],
635            )?;
636            identifier(object, "sessionId", false)?;
637            let limits_object = object
638                .get("limits")
639                .and_then(Value::as_object)
640                .ok_or_else(|| ParseError::malformed("limits: expected an object"))?;
641            // Required keys must all be present, but unknown ones are
642            // ignored: see the note on `Limits`.
643            required_keys(limits_object, &LIMIT_FIELDS)?;
644            for field in LIMIT_FIELDS {
645                whole_number(limits_object, field, true)?;
646            }
647            match object.get("subscribe").and_then(Value::as_str) {
648                Some("snapshots") | Some("revisions") | Some("diffs") => {}
649                _ => {
650                    return Err(ParseError::malformed(
651                        "subscribe: expected 'snapshots', 'revisions' or 'diffs'",
652                    ))
653                }
654            }
655            let marker = object
656                .get("marker")
657                .and_then(Value::as_object)
658                .ok_or_else(|| ParseError::malformed("marker: expected an object"))?;
659            required_keys(marker, &["enabled"])?;
660            if !marker["enabled"].is_boolean() {
661                return Err(ParseError::malformed("marker.enabled: expected a boolean"));
662            }
663            if let Some(logs) = object.get("logs") {
664                check_log_budget(logs)?;
665            }
666            Ok(())
667        }
668        "get-tree" => {
669            required_keys(object, &["type", "requestId"])?;
670            whole_number(object, "requestId", false)?;
671            if object.contains_key("revision") {
672                whole_number(object, "revision", true)?;
673            }
674            Ok(())
675        }
676        "error" => check_error_message(object, false),
677        _ => Err(ParseError::malformed("unknown or missing message type")),
678    }
679}