Skip to main content

frust_devtools_protocol/
codec.rs

1//! NDJSON line framing: one JSON-RPC 2.0 object per `\n`-terminated line, no
2//! `Content-Length` headers. Pure functions only — no I/O; the caller (the
3//! in-app service or `frust-drive`/`frust-tui` tooling) owns the actual
4//! stream/socket.
5
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::types::Incoming;
10
11/// Serializes `value` to a single JSON-RPC line with **no** trailing
12/// newline — the caller appends `\n` when writing it to a stream (matching
13/// `decode_line`, which also takes one line with the newline already
14/// stripped).
15///
16/// `serde_json` always escapes control characters, including a literal
17/// `\n`, inside string values — a well-formed `Serialize` impl can
18/// therefore never make this produce an embedded newline in the first
19/// place. The `debug_assert!` below is a belt-and-suspenders guarantee
20/// against a future custom `Serialize` impl breaking that invariant, not a
21/// runtime check this protocol expects to ever fire.
22pub fn encode_line(value: &impl Serialize) -> String {
23    let line = serde_json::to_string(value)
24        .expect("frust-devtools-protocol wire types are always representable as JSON");
25    debug_assert!(
26        !line.contains('\n'),
27        "encode_line must never produce an embedded newline (NDJSON framing contract)"
28    );
29    line
30}
31
32/// Parses one NDJSON line into its discriminated [`Incoming`] shape.
33///
34/// Discrimination follows JSON-RPC 2.0's own shape rules rather than a tag
35/// field the wire types don't carry: a `method` key marks a request (if
36/// `id` is also present) or a notification (no `id`); an `id` key with no
37/// `method` marks a response. A trailing `\n`, if present, is stripped
38/// first so a caller can hand this either a bare line or one still carrying
39/// its terminator.
40pub fn decode_line(line: &str) -> Result<Incoming, DecodeError> {
41    let value: Value = serde_json::from_str(line.trim_end_matches('\n'))?;
42    let obj = value.as_object().ok_or(DecodeError::NotAnObject)?;
43    let has_method = obj.contains_key("method");
44    let has_id = obj.contains_key("id");
45
46    if has_method && has_id {
47        Ok(Incoming::Request(serde_json::from_value(value)?))
48    } else if has_method {
49        Ok(Incoming::Notification(serde_json::from_value(value)?))
50    } else if has_id {
51        Ok(Incoming::Response(serde_json::from_value(value)?))
52    } else {
53        Err(DecodeError::UnrecognizedShape)
54    }
55}
56
57/// [`decode_line`]'s failure modes.
58#[derive(Debug)]
59pub enum DecodeError {
60    /// The line isn't valid JSON at all.
61    InvalidJson(serde_json::Error),
62    /// The line parsed as JSON but isn't an object (e.g. a bare array or
63    /// scalar).
64    NotAnObject,
65    /// The object has neither a `method` nor an `id` field, so it matches
66    /// none of [`Incoming`]'s three shapes.
67    UnrecognizedShape,
68}
69
70impl std::fmt::Display for DecodeError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            DecodeError::InvalidJson(e) => write!(f, "invalid JSON: {e}"),
74            DecodeError::NotAnObject => write!(f, "line is not a JSON object"),
75            DecodeError::UnrecognizedShape => write!(
76                f,
77                "object has neither a `method` nor an `id` field — not a Request, Response, or Notification"
78            ),
79        }
80    }
81}
82
83impl std::error::Error for DecodeError {
84    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
85        match self {
86            DecodeError::InvalidJson(e) => Some(e),
87            DecodeError::NotAnObject | DecodeError::UnrecognizedShape => None,
88        }
89    }
90}
91
92impl From<serde_json::Error> for DecodeError {
93    fn from(e: serde_json::Error) -> Self {
94        DecodeError::InvalidJson(e)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::types::{Request, Response};
102
103    #[test]
104    fn encode_line_never_embeds_a_raw_newline() {
105        let req = Request::new(
106            1,
107            "input_text",
108            serde_json::json!({"text": "line one\nline two"}),
109        );
110        let line = encode_line(&req);
111        assert!(
112            !line.contains('\n'),
113            "embedded newline in encoded params must be escaped, not literal: {line:?}"
114        );
115        // ...and it still round-trips the exact string with the embedded newline intact.
116        let decoded: Request = serde_json::from_str(&line).unwrap();
117        assert_eq!(decoded.params["text"], "line one\nline two");
118    }
119
120    #[test]
121    fn decode_line_discriminates_request() {
122        let line = encode_line(&Request::new(1, "handshake", Value::Null));
123        match decode_line(&line).unwrap() {
124            Incoming::Request(r) => assert_eq!(r.method, "handshake"),
125            other => panic!("expected Request, got {other:?}"),
126        }
127    }
128
129    #[test]
130    fn decode_line_discriminates_notification() {
131        let notif = crate::types::Notification::new("frame_stats", serde_json::json!({"n": 1}));
132        let line = encode_line(&notif);
133        match decode_line(&line).unwrap() {
134            Incoming::Notification(n) => assert_eq!(n.method, "frame_stats"),
135            other => panic!("expected Notification, got {other:?}"),
136        }
137    }
138
139    #[test]
140    fn decode_line_discriminates_response() {
141        let resp = Response::success(9, serde_json::json!({"ok": true}));
142        let line = encode_line(&resp);
143        match decode_line(&line).unwrap() {
144            Incoming::Response(r) => assert_eq!(r.id, 9),
145            other => panic!("expected Response, got {other:?}"),
146        }
147    }
148
149    #[test]
150    fn decode_line_accepts_trailing_newline() {
151        let line = format!(
152            "{}\n",
153            encode_line(&Request::new(1, "handshake", Value::Null))
154        );
155        assert!(decode_line(&line).is_ok());
156    }
157
158    #[test]
159    fn decode_line_rejects_shape_with_neither_method_nor_id() {
160        let err = decode_line(r#"{"foo":"bar"}"#).unwrap_err();
161        assert!(matches!(err, DecodeError::UnrecognizedShape));
162    }
163
164    #[test]
165    fn decode_line_rejects_non_object() {
166        let err = decode_line("[1,2,3]").unwrap_err();
167        assert!(matches!(err, DecodeError::NotAnObject));
168    }
169
170    #[test]
171    fn decode_line_rejects_invalid_json() {
172        let err = decode_line("not json").unwrap_err();
173        assert!(matches!(err, DecodeError::InvalidJson(_)));
174    }
175
176    #[test]
177    fn decode_line_tolerates_unknown_method_name() {
178        // A method this crate's `Method` enum doesn't know about must still
179        // decode as a Request — dispatch, not framing, decides what an
180        // unrecognized method means.
181        let req = Request::new(1, "some_future_method", Value::Null);
182        let line = encode_line(&req);
183        match decode_line(&line).unwrap() {
184            Incoming::Request(r) => assert_eq!(r.method, "some_future_method"),
185            other => panic!("expected Request, got {other:?}"),
186        }
187    }
188
189    #[test]
190    fn decode_line_error_impls_std_error() {
191        let err = decode_line("not json").unwrap_err();
192        let _: &dyn std::error::Error = &err;
193        assert!(std::error::Error::source(&err).is_some());
194    }
195}