1
use nomiscript::{Expr, Fraction, Reader, Value, format_value};
2
use thiserror::Error;
3

            
4
#[derive(Debug, Clone, PartialEq, Eq)]
5
pub enum RequestId {
6
    Int(i64),
7
    String(String),
8
}
9

            
10
#[derive(Debug, Clone, PartialEq)]
11
pub 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)]
24
pub struct ErrorCode(String);
25

            
26
impl ErrorCode {
27
414
    pub fn new(symbol: impl Into<String>) -> Self {
28
414
        Self(symbol.into())
29
414
    }
30

            
31
    #[must_use]
32
629
    pub fn as_symbol(&self) -> &str {
33
629
        &self.0
34
629
    }
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)]
62
pub 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)]
72
pub struct Response {
73
    pub id: RequestId,
74
    pub payload: ResponsePayload,
75
}
76

            
77
#[derive(Debug, Error)]
78
pub 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

            
91
2642
pub fn parse_request(frame: &str) -> Result<Request, EnvelopeError> {
92
2642
    let program = Reader::parse(frame).map_err(|e| EnvelopeError::Parse(e.to_string()))?;
93
2641
    let mut iter = program.exprs.into_iter();
94
2641
    let envelope = iter.next().ok_or(EnvelopeError::NotSingleExpr)?;
95
2641
    if iter.next().is_some() {
96
19
        return Err(EnvelopeError::NotSingleExpr);
97
2622
    }
98
2622
    let plist = match envelope {
99
2621
        Expr::List(items) => items,
100
1
        _ => return Err(EnvelopeError::NotPlist),
101
    };
102
2621
    let pairs = collect_plist(plist)?;
103
2620
    let id_expr = pairs
104
2620
        .iter()
105
2620
        .find(|(k, _)| k == "ID")
106
2620
        .map(|(_, v)| v.clone())
107
2620
        .ok_or(EnvelopeError::MissingKey(":id"))?;
108
2618
    let form = pairs
109
2618
        .into_iter()
110
5235
        .find(|(k, _)| k == "FORM")
111
2618
        .map(|(_, v)| v)
112
2618
        .ok_or(EnvelopeError::MissingKey(":form"))?;
113
2617
    let id = expr_to_request_id(&id_expr)?;
114
2615
    Ok(Request { id, form })
115
2642
}
116

            
117
2652
pub fn format_response(response: &Response) -> String {
118
2652
    match &response.payload {
119
2298
        ResponsePayload::Value(value) => format!(
120
            "(:id {} :value {})",
121
2298
            format_id(&response.id),
122
2298
            format_value(value)
123
        ),
124
        ResponsePayload::Error {
125
354
            code,
126
354
            message,
127
354
            detail,
128
        } => {
129
354
            let mut error_body = format!(
130
                "(:code {} :message {})",
131
354
                code.as_symbol(),
132
354
                format_value(&Value::String(message.clone()))
133
            );
134
354
            if let Some(detail) = detail {
135
261
                error_body = format!(
136
261
                    "(:code {} :message {} :detail {})",
137
261
                    code.as_symbol(),
138
261
                    format_value(&Value::String(message.clone())),
139
261
                    format_value(&Value::String(detail.clone()))
140
261
                );
141
273
            }
142
354
            format!("(:id {} :error {})", format_id(&response.id), error_body)
143
        }
144
    }
145
2652
}
146

            
147
2652
fn format_id(id: &RequestId) -> String {
148
2652
    match id {
149
2651
        RequestId::Int(n) => n.to_string(),
150
1
        RequestId::String(s) => format_value(&Value::String(s.clone())),
151
    }
152
2652
}
153

            
154
2621
fn collect_plist(items: Vec<Expr>) -> Result<Vec<(String, Expr)>, EnvelopeError> {
155
2621
    if !items.len().is_multiple_of(2) {
156
1
        return Err(EnvelopeError::NotPlist);
157
2620
    }
158
2620
    let mut pairs = Vec::with_capacity(items.len() / 2);
159
2620
    let mut iter = items.into_iter();
160
7857
    while let Some(key) = iter.next() {
161
5237
        let key_name = match key {
162
5237
            Expr::Keyword(name) => name,
163
            _ => return Err(EnvelopeError::NotPlist),
164
        };
165
5237
        let value = iter.next().ok_or(EnvelopeError::NotPlist)?;
166
5237
        pairs.push((key_name, value));
167
    }
168
2620
    Ok(pairs)
169
2621
}
170

            
171
2617
fn expr_to_request_id(expr: &Expr) -> Result<RequestId, EnvelopeError> {
172
2617
    match expr {
173
2615
        Expr::Number(n) => integer_value(n).map(RequestId::Int).ok_or_else(|| {
174
1
            EnvelopeError::InvalidValue(":id", format!("expected integer, got {n}"))
175
1
        }),
176
1
        Expr::String(s) => Ok(RequestId::String(s.clone())),
177
1
        other => Err(EnvelopeError::InvalidValue(
178
1
            ":id",
179
1
            format!("expected integer or string, got {other:?}"),
180
1
        )),
181
    }
182
2617
}
183

            
184
2615
fn integer_value(n: &Fraction) -> Option<i64> {
185
2615
    if *n.denom() == 1 {
186
2614
        Some(*n.numer())
187
    } else {
188
1
        None
189
    }
190
2615
}
191

            
192
#[cfg(test)]
193
mod tests {
194
    use super::*;
195
    use nomiscript::Pair;
196

            
197
    #[test]
198
1
    fn parses_basic_request() {
199
1
        let req = parse_request("(:id 42 :form (list-accounts))").unwrap();
200
1
        assert_eq!(req.id, RequestId::Int(42));
201
1
        assert_eq!(
202
            req.form,
203
1
            Expr::List(vec![Expr::Symbol("LIST-ACCOUNTS".into())])
204
        );
205
1
    }
206

            
207
    #[test]
208
1
    fn parses_string_id() {
209
1
        let req = parse_request("(:id \"abc\" :form 1)").unwrap();
210
1
        assert_eq!(req.id, RequestId::String("abc".to_string()));
211
1
    }
212

            
213
    #[test]
214
1
    fn rejects_envelope_missing_id() {
215
1
        let err = parse_request("(:form (foo))").unwrap_err();
216
1
        assert!(matches!(err, EnvelopeError::MissingKey(":id")));
217
1
    }
218

            
219
    #[test]
220
1
    fn rejects_envelope_missing_form() {
221
1
        let err = parse_request("(:id 1)").unwrap_err();
222
1
        assert!(matches!(err, EnvelopeError::MissingKey(":form")));
223
1
    }
224

            
225
    #[test]
226
1
    fn rejects_envelope_with_invalid_id() {
227
1
        let err = parse_request("(:id (a b) :form 1)").unwrap_err();
228
1
        assert!(matches!(err, EnvelopeError::InvalidValue(":id", _)));
229
1
    }
230

            
231
    #[test]
232
1
    fn rejects_envelope_with_fractional_id() {
233
1
        let err = parse_request("(:id 3/4 :form 1)").unwrap_err();
234
1
        assert!(matches!(err, EnvelopeError::InvalidValue(":id", _)));
235
1
    }
236

            
237
    #[test]
238
1
    fn rejects_envelope_with_unbalanced_plist() {
239
1
        let err = parse_request("(:id 1 :form)").unwrap_err();
240
1
        assert!(matches!(err, EnvelopeError::NotPlist));
241
1
    }
242

            
243
    #[test]
244
1
    fn rejects_non_list_envelope() {
245
1
        let err = parse_request("42").unwrap_err();
246
1
        assert!(matches!(err, EnvelopeError::NotPlist));
247
1
    }
248

            
249
    #[test]
250
1
    fn rejects_multiple_envelopes_per_frame() {
251
1
        let err = parse_request("(:id 1 :form 1) (:id 2 :form 2)").unwrap_err();
252
1
        assert!(matches!(err, EnvelopeError::NotSingleExpr));
253
1
    }
254

            
255
    #[test]
256
1
    fn formats_value_response() {
257
1
        let resp = Response {
258
1
            id: RequestId::Int(7),
259
1
            payload: ResponsePayload::Value(Value::Number(Fraction::from_integer(99))),
260
1
        };
261
1
        assert_eq!(format_response(&resp), "(:id 7 :value 99)");
262
1
    }
263

            
264
    #[test]
265
1
    fn formats_value_response_with_string_id() {
266
1
        let resp = Response {
267
1
            id: RequestId::String("req-1".into()),
268
1
            payload: ResponsePayload::Value(Value::Bool(true)),
269
1
        };
270
1
        assert_eq!(format_response(&resp), "(:id \"req-1\" :value #t)");
271
1
    }
272

            
273
    #[test]
274
1
    fn formats_error_response_without_detail() {
275
1
        let resp = Response {
276
1
            id: RequestId::Int(3),
277
1
            payload: ResponsePayload::Error {
278
1
                code: ErrorCode::new(ErrorCode::ARGS),
279
1
                message: "missing :user-id".into(),
280
1
                detail: None,
281
1
            },
282
1
        };
283
1
        assert_eq!(
284
1
            format_response(&resp),
285
            "(:id 3 :error (:code args :message \"missing :user-id\"))"
286
        );
287
1
    }
288

            
289
    #[test]
290
1
    fn formats_error_response_with_detail() {
291
1
        let resp = Response {
292
1
            id: RequestId::Int(8),
293
1
            payload: ResponsePayload::Error {
294
1
                code: ErrorCode::new(ErrorCode::DB),
295
1
                message: "query failed".into(),
296
1
                detail: Some("SqlxError(...)".into()),
297
1
            },
298
1
        };
299
1
        assert_eq!(
300
1
            format_response(&resp),
301
            "(:id 8 :error (:code db :message \"query failed\" :detail \"SqlxError(...)\"))"
302
        );
303
1
    }
304

            
305
    #[test]
306
1
    fn formats_error_for_every_well_known_code() {
307
12
        for symbol in [
308
1
            ErrorCode::ARGS,
309
1
            ErrorCode::CONFIG,
310
1
            ErrorCode::DB,
311
1
            ErrorCode::SERVER,
312
1
            ErrorCode::FINANCE,
313
1
            ErrorCode::SCRIPT,
314
1
            ErrorCode::PARSE,
315
1
            ErrorCode::COMPILE,
316
1
            ErrorCode::RUNTIME,
317
1
            ErrorCode::AUTH,
318
1
            ErrorCode::INTERRUPTED,
319
1
            ErrorCode::NO_CONVERSION,
320
1
        ] {
321
12
            let code = ErrorCode::new(symbol);
322
12
            let resp = Response {
323
12
                id: RequestId::Int(0),
324
12
                payload: ResponsePayload::Error {
325
12
                    code: code.clone(),
326
12
                    message: "x".into(),
327
12
                    detail: None,
328
12
                },
329
12
            };
330
12
            let formatted = format_response(&resp);
331
12
            assert!(
332
12
                formatted.contains(code.as_symbol()),
333
                "expected {symbol:?} in {formatted:?}",
334
            );
335
        }
336
1
    }
337

            
338
    #[test]
339
1
    fn formats_script_raised_code_verbatim() {
340
1
        let resp = Response {
341
1
            id: RequestId::Int(4),
342
1
            payload: ResponsePayload::Error {
343
1
                code: ErrorCode::new("no-such-account"),
344
1
                message: "id=42".into(),
345
1
                detail: None,
346
1
            },
347
1
        };
348
1
        assert_eq!(
349
1
            format_response(&resp),
350
            "(:id 4 :error (:code no-such-account :message \"id=42\"))"
351
        );
352
1
    }
353

            
354
    #[test]
355
1
    fn formats_value_response_with_bytes() {
356
1
        let resp = Response {
357
1
            id: RequestId::Int(1),
358
1
            payload: ResponsePayload::Value(Value::Bytes(vec![0xCA, 0xFE])),
359
1
        };
360
1
        assert_eq!(format_response(&resp), "(:id 1 :value #u8(202 254))");
361
1
    }
362

            
363
    #[test]
364
1
    fn formats_value_response_with_pair() {
365
1
        let list = Pair::cons(
366
1
            Value::Number(Fraction::from_integer(1)),
367
1
            Pair::cons(Value::Number(Fraction::from_integer(2)), Value::Nil),
368
        );
369
1
        let resp = Response {
370
1
            id: RequestId::Int(1),
371
1
            payload: ResponsePayload::Value(list),
372
1
        };
373
1
        assert_eq!(format_response(&resp), "(:id 1 :value (1 2))");
374
1
    }
375
}