Skip to main content

rpc/
envelope.rs

1use nomiscript::{Expr, Fraction, Reader, Value, format_value};
2use thiserror::Error;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum RequestId {
6    Int(i64),
7    String(String),
8}
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct Request {
12    pub id: RequestId,
13    pub form: Expr,
14}
15
16/// Wire-level error code. Every code is a Lisp-style symbol string,
17/// rendered as the bare token after `:code` in the response envelope.
18/// The wire surface is open: well-known engine-emitted codes are
19/// associated `&str` constants on the type, while script-raised codes
20/// (via `(error 'symbol "msg")`) flow through the same channel without
21/// a sentinel. Clients pattern-match identically on engine and script
22/// codes — adding a new well-known code is a non-breaking change.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ErrorCode(String);
25
26impl ErrorCode {
27    pub fn new(symbol: impl Into<String>) -> Self {
28        Self(symbol.into())
29    }
30
31    #[must_use]
32    pub fn as_symbol(&self) -> &str {
33        &self.0
34    }
35
36    pub const ARGS: &str = "args";
37    pub const CONFIG: &str = "config";
38    pub const DB: &str = "db";
39    pub const SERVER: &str = "server";
40    pub const FINANCE: &str = "finance";
41    pub const SCRIPT: &str = "script";
42    pub const PARSE: &str = "parse";
43    pub const COMPILE: &str = "compile";
44    pub const RUNTIME: &str = "runtime";
45    pub const AUTH: &str = "auth";
46    pub const INTERRUPTED: &str = "interrupted";
47    /// `convert-commodity` found no Price row linking source and
48    /// target in either direction. A host-classification label (not a
49    /// raised condition): `convert-commodity`'s host fn returns `Err`, the
50    /// classifier lifts it here, and the remedy differs from a commodity
51    /// mismatch (add a price row vs. avoid the cross-commodity arithmetic).
52    ///
53    /// Note: `commodity-mismatch` is NOT a const here — unlike `no-conversion`
54    /// it is a *raised condition* (the guest `throw`s `$nomi_error` with the
55    /// reader-folded symbol code `COMMODITY-MISMATCH`; ADR-0026), so it rides
56    /// the `ScriptRaised` path with its code verbatim, like any script
57    /// `(error 'sym …)`. It needs no well-known const.
58    pub const NO_CONVERSION: &str = "no-conversion";
59}
60
61#[derive(Debug, Clone, PartialEq)]
62pub enum ResponsePayload {
63    Value(Value),
64    Error {
65        code: ErrorCode,
66        message: String,
67        detail: Option<String>,
68    },
69}
70
71#[derive(Debug, Clone, PartialEq)]
72pub struct Response {
73    pub id: RequestId,
74    pub payload: ResponsePayload,
75}
76
77#[derive(Debug, Error)]
78pub enum EnvelopeError {
79    #[error("frame parse error: {0}")]
80    Parse(String),
81    #[error("envelope must contain exactly one top-level expression")]
82    NotSingleExpr,
83    #[error("envelope must be a list of plist pairs")]
84    NotPlist,
85    #[error("missing required key {0}")]
86    MissingKey(&'static str),
87    #[error("invalid value for key {0}: {1}")]
88    InvalidValue(&'static str, String),
89}
90
91pub fn parse_request(frame: &str) -> Result<Request, EnvelopeError> {
92    let program = Reader::parse(frame).map_err(|e| EnvelopeError::Parse(e.to_string()))?;
93    let mut iter = program.exprs.into_iter();
94    let envelope = iter.next().ok_or(EnvelopeError::NotSingleExpr)?;
95    if iter.next().is_some() {
96        return Err(EnvelopeError::NotSingleExpr);
97    }
98    let plist = match envelope {
99        Expr::List(items) => items,
100        _ => return Err(EnvelopeError::NotPlist),
101    };
102    let pairs = collect_plist(plist)?;
103    let id_expr = pairs
104        .iter()
105        .find(|(k, _)| k == "ID")
106        .map(|(_, v)| v.clone())
107        .ok_or(EnvelopeError::MissingKey(":id"))?;
108    let form = pairs
109        .into_iter()
110        .find(|(k, _)| k == "FORM")
111        .map(|(_, v)| v)
112        .ok_or(EnvelopeError::MissingKey(":form"))?;
113    let id = expr_to_request_id(&id_expr)?;
114    Ok(Request { id, form })
115}
116
117pub fn format_response(response: &Response) -> String {
118    match &response.payload {
119        ResponsePayload::Value(value) => format!(
120            "(:id {} :value {})",
121            format_id(&response.id),
122            format_value(value)
123        ),
124        ResponsePayload::Error {
125            code,
126            message,
127            detail,
128        } => {
129            let mut error_body = format!(
130                "(:code {} :message {})",
131                code.as_symbol(),
132                format_value(&Value::String(message.clone()))
133            );
134            if let Some(detail) = detail {
135                error_body = format!(
136                    "(:code {} :message {} :detail {})",
137                    code.as_symbol(),
138                    format_value(&Value::String(message.clone())),
139                    format_value(&Value::String(detail.clone()))
140                );
141            }
142            format!("(:id {} :error {})", format_id(&response.id), error_body)
143        }
144    }
145}
146
147fn format_id(id: &RequestId) -> String {
148    match id {
149        RequestId::Int(n) => n.to_string(),
150        RequestId::String(s) => format_value(&Value::String(s.clone())),
151    }
152}
153
154fn collect_plist(items: Vec<Expr>) -> Result<Vec<(String, Expr)>, EnvelopeError> {
155    if !items.len().is_multiple_of(2) {
156        return Err(EnvelopeError::NotPlist);
157    }
158    let mut pairs = Vec::with_capacity(items.len() / 2);
159    let mut iter = items.into_iter();
160    while let Some(key) = iter.next() {
161        let key_name = match key {
162            Expr::Keyword(name) => name,
163            _ => return Err(EnvelopeError::NotPlist),
164        };
165        let value = iter.next().ok_or(EnvelopeError::NotPlist)?;
166        pairs.push((key_name, value));
167    }
168    Ok(pairs)
169}
170
171fn expr_to_request_id(expr: &Expr) -> Result<RequestId, EnvelopeError> {
172    match expr {
173        Expr::Number(n) => integer_value(n).map(RequestId::Int).ok_or_else(|| {
174            EnvelopeError::InvalidValue(":id", format!("expected integer, got {n}"))
175        }),
176        Expr::String(s) => Ok(RequestId::String(s.clone())),
177        other => Err(EnvelopeError::InvalidValue(
178            ":id",
179            format!("expected integer or string, got {other:?}"),
180        )),
181    }
182}
183
184fn integer_value(n: &Fraction) -> Option<i64> {
185    if *n.denom() == 1 {
186        Some(*n.numer())
187    } else {
188        None
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use nomiscript::Pair;
196
197    #[test]
198    fn parses_basic_request() {
199        let req = parse_request("(:id 42 :form (list-accounts))").unwrap();
200        assert_eq!(req.id, RequestId::Int(42));
201        assert_eq!(
202            req.form,
203            Expr::List(vec![Expr::Symbol("LIST-ACCOUNTS".into())])
204        );
205    }
206
207    #[test]
208    fn parses_string_id() {
209        let req = parse_request("(:id \"abc\" :form 1)").unwrap();
210        assert_eq!(req.id, RequestId::String("abc".to_string()));
211    }
212
213    #[test]
214    fn rejects_envelope_missing_id() {
215        let err = parse_request("(:form (foo))").unwrap_err();
216        assert!(matches!(err, EnvelopeError::MissingKey(":id")));
217    }
218
219    #[test]
220    fn rejects_envelope_missing_form() {
221        let err = parse_request("(:id 1)").unwrap_err();
222        assert!(matches!(err, EnvelopeError::MissingKey(":form")));
223    }
224
225    #[test]
226    fn rejects_envelope_with_invalid_id() {
227        let err = parse_request("(:id (a b) :form 1)").unwrap_err();
228        assert!(matches!(err, EnvelopeError::InvalidValue(":id", _)));
229    }
230
231    #[test]
232    fn rejects_envelope_with_fractional_id() {
233        let err = parse_request("(:id 3/4 :form 1)").unwrap_err();
234        assert!(matches!(err, EnvelopeError::InvalidValue(":id", _)));
235    }
236
237    #[test]
238    fn rejects_envelope_with_unbalanced_plist() {
239        let err = parse_request("(:id 1 :form)").unwrap_err();
240        assert!(matches!(err, EnvelopeError::NotPlist));
241    }
242
243    #[test]
244    fn rejects_non_list_envelope() {
245        let err = parse_request("42").unwrap_err();
246        assert!(matches!(err, EnvelopeError::NotPlist));
247    }
248
249    #[test]
250    fn rejects_multiple_envelopes_per_frame() {
251        let err = parse_request("(:id 1 :form 1) (:id 2 :form 2)").unwrap_err();
252        assert!(matches!(err, EnvelopeError::NotSingleExpr));
253    }
254
255    #[test]
256    fn formats_value_response() {
257        let resp = Response {
258            id: RequestId::Int(7),
259            payload: ResponsePayload::Value(Value::Number(Fraction::from_integer(99))),
260        };
261        assert_eq!(format_response(&resp), "(:id 7 :value 99)");
262    }
263
264    #[test]
265    fn formats_value_response_with_string_id() {
266        let resp = Response {
267            id: RequestId::String("req-1".into()),
268            payload: ResponsePayload::Value(Value::Bool(true)),
269        };
270        assert_eq!(format_response(&resp), "(:id \"req-1\" :value #t)");
271    }
272
273    #[test]
274    fn formats_error_response_without_detail() {
275        let resp = Response {
276            id: RequestId::Int(3),
277            payload: ResponsePayload::Error {
278                code: ErrorCode::new(ErrorCode::ARGS),
279                message: "missing :user-id".into(),
280                detail: None,
281            },
282        };
283        assert_eq!(
284            format_response(&resp),
285            "(:id 3 :error (:code args :message \"missing :user-id\"))"
286        );
287    }
288
289    #[test]
290    fn formats_error_response_with_detail() {
291        let resp = Response {
292            id: RequestId::Int(8),
293            payload: ResponsePayload::Error {
294                code: ErrorCode::new(ErrorCode::DB),
295                message: "query failed".into(),
296                detail: Some("SqlxError(...)".into()),
297            },
298        };
299        assert_eq!(
300            format_response(&resp),
301            "(:id 8 :error (:code db :message \"query failed\" :detail \"SqlxError(...)\"))"
302        );
303    }
304
305    #[test]
306    fn formats_error_for_every_well_known_code() {
307        for symbol in [
308            ErrorCode::ARGS,
309            ErrorCode::CONFIG,
310            ErrorCode::DB,
311            ErrorCode::SERVER,
312            ErrorCode::FINANCE,
313            ErrorCode::SCRIPT,
314            ErrorCode::PARSE,
315            ErrorCode::COMPILE,
316            ErrorCode::RUNTIME,
317            ErrorCode::AUTH,
318            ErrorCode::INTERRUPTED,
319            ErrorCode::NO_CONVERSION,
320        ] {
321            let code = ErrorCode::new(symbol);
322            let resp = Response {
323                id: RequestId::Int(0),
324                payload: ResponsePayload::Error {
325                    code: code.clone(),
326                    message: "x".into(),
327                    detail: None,
328                },
329            };
330            let formatted = format_response(&resp);
331            assert!(
332                formatted.contains(code.as_symbol()),
333                "expected {symbol:?} in {formatted:?}",
334            );
335        }
336    }
337
338    #[test]
339    fn formats_script_raised_code_verbatim() {
340        let resp = Response {
341            id: RequestId::Int(4),
342            payload: ResponsePayload::Error {
343                code: ErrorCode::new("no-such-account"),
344                message: "id=42".into(),
345                detail: None,
346            },
347        };
348        assert_eq!(
349            format_response(&resp),
350            "(:id 4 :error (:code no-such-account :message \"id=42\"))"
351        );
352    }
353
354    #[test]
355    fn formats_value_response_with_bytes() {
356        let resp = Response {
357            id: RequestId::Int(1),
358            payload: ResponsePayload::Value(Value::Bytes(vec![0xCA, 0xFE])),
359        };
360        assert_eq!(format_response(&resp), "(:id 1 :value #u8(202 254))");
361    }
362
363    #[test]
364    fn formats_value_response_with_pair() {
365        let list = Pair::cons(
366            Value::Number(Fraction::from_integer(1)),
367            Pair::cons(Value::Number(Fraction::from_integer(2)), Value::Nil),
368        );
369        let resp = Response {
370            id: RequestId::Int(1),
371            payload: ResponsePayload::Value(list),
372        };
373        assert_eq!(format_response(&resp), "(:id 1 :value (1 2))");
374    }
375}