Lines
99.54 %
Functions
71.19 %
Branches
100 %
use nomiscript::{Expr, Fraction, Reader, Value, format_value};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestId {
Int(i64),
String(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
pub id: RequestId,
pub form: Expr,
/// Wire-level error code. Every code is a Lisp-style symbol string,
/// rendered as the bare token after `:code` in the response envelope.
/// The wire surface is open: well-known engine-emitted codes are
/// associated `&str` constants on the type, while script-raised codes
/// (via `(error 'symbol "msg")`) flow through the same channel without
/// a sentinel. Clients pattern-match identically on engine and script
/// codes — adding a new well-known code is a non-breaking change.
pub struct ErrorCode(String);
impl ErrorCode {
pub fn new(symbol: impl Into<String>) -> Self {
Self(symbol.into())
#[must_use]
pub fn as_symbol(&self) -> &str {
&self.0
pub const ARGS: &str = "args";
pub const CONFIG: &str = "config";
pub const DB: &str = "db";
pub const SERVER: &str = "server";
pub const FINANCE: &str = "finance";
pub const SCRIPT: &str = "script";
pub const PARSE: &str = "parse";
pub const COMPILE: &str = "compile";
pub const RUNTIME: &str = "runtime";
pub const AUTH: &str = "auth";
pub const INTERRUPTED: &str = "interrupted";
/// `convert-commodity` found no Price row linking source and
/// target in either direction. A host-classification label (not a
/// raised condition): `convert-commodity`'s host fn returns `Err`, the
/// classifier lifts it here, and the remedy differs from a commodity
/// mismatch (add a price row vs. avoid the cross-commodity arithmetic).
///
/// Note: `commodity-mismatch` is NOT a const here — unlike `no-conversion`
/// it is a *raised condition* (the guest `throw`s `$nomi_error` with the
/// reader-folded symbol code `COMMODITY-MISMATCH`; ADR-0026), so it rides
/// the `ScriptRaised` path with its code verbatim, like any script
/// `(error 'sym …)`. It needs no well-known const.
pub const NO_CONVERSION: &str = "no-conversion";
pub enum ResponsePayload {
Value(Value),
Error {
code: ErrorCode,
message: String,
detail: Option<String>,
},
pub struct Response {
pub payload: ResponsePayload,
#[derive(Debug, Error)]
pub enum EnvelopeError {
#[error("frame parse error: {0}")]
Parse(String),
#[error("envelope must contain exactly one top-level expression")]
NotSingleExpr,
#[error("envelope must be a list of plist pairs")]
NotPlist,
#[error("missing required key {0}")]
MissingKey(&'static str),
#[error("invalid value for key {0}: {1}")]
InvalidValue(&'static str, String),
pub fn parse_request(frame: &str) -> Result<Request, EnvelopeError> {
let program = Reader::parse(frame).map_err(|e| EnvelopeError::Parse(e.to_string()))?;
let mut iter = program.exprs.into_iter();
let envelope = iter.next().ok_or(EnvelopeError::NotSingleExpr)?;
if iter.next().is_some() {
return Err(EnvelopeError::NotSingleExpr);
let plist = match envelope {
Expr::List(items) => items,
_ => return Err(EnvelopeError::NotPlist),
};
let pairs = collect_plist(plist)?;
let id_expr = pairs
.iter()
.find(|(k, _)| k == "ID")
.map(|(_, v)| v.clone())
.ok_or(EnvelopeError::MissingKey(":id"))?;
let form = pairs
.into_iter()
.find(|(k, _)| k == "FORM")
.map(|(_, v)| v)
.ok_or(EnvelopeError::MissingKey(":form"))?;
let id = expr_to_request_id(&id_expr)?;
Ok(Request { id, form })
pub fn format_response(response: &Response) -> String {
match &response.payload {
ResponsePayload::Value(value) => format!(
"(:id {} :value {})",
format_id(&response.id),
format_value(value)
),
ResponsePayload::Error {
code,
message,
detail,
} => {
let mut error_body = format!(
"(:code {} :message {})",
code.as_symbol(),
format_value(&Value::String(message.clone()))
);
if let Some(detail) = detail {
error_body = format!(
"(:code {} :message {} :detail {})",
format_value(&Value::String(message.clone())),
format_value(&Value::String(detail.clone()))
format!("(:id {} :error {})", format_id(&response.id), error_body)
fn format_id(id: &RequestId) -> String {
match id {
RequestId::Int(n) => n.to_string(),
RequestId::String(s) => format_value(&Value::String(s.clone())),
fn collect_plist(items: Vec<Expr>) -> Result<Vec<(String, Expr)>, EnvelopeError> {
if !items.len().is_multiple_of(2) {
return Err(EnvelopeError::NotPlist);
let mut pairs = Vec::with_capacity(items.len() / 2);
let mut iter = items.into_iter();
while let Some(key) = iter.next() {
let key_name = match key {
Expr::Keyword(name) => name,
let value = iter.next().ok_or(EnvelopeError::NotPlist)?;
pairs.push((key_name, value));
Ok(pairs)
fn expr_to_request_id(expr: &Expr) -> Result<RequestId, EnvelopeError> {
match expr {
Expr::Number(n) => integer_value(n).map(RequestId::Int).ok_or_else(|| {
EnvelopeError::InvalidValue(":id", format!("expected integer, got {n}"))
}),
Expr::String(s) => Ok(RequestId::String(s.clone())),
other => Err(EnvelopeError::InvalidValue(
":id",
format!("expected integer or string, got {other:?}"),
)),
fn integer_value(n: &Fraction) -> Option<i64> {
if *n.denom() == 1 {
Some(*n.numer())
} else {
None
#[cfg(test)]
mod tests {
use super::*;
use nomiscript::Pair;
#[test]
fn parses_basic_request() {
let req = parse_request("(:id 42 :form (list-accounts))").unwrap();
assert_eq!(req.id, RequestId::Int(42));
assert_eq!(
req.form,
Expr::List(vec![Expr::Symbol("LIST-ACCOUNTS".into())])
fn parses_string_id() {
let req = parse_request("(:id \"abc\" :form 1)").unwrap();
assert_eq!(req.id, RequestId::String("abc".to_string()));
fn rejects_envelope_missing_id() {
let err = parse_request("(:form (foo))").unwrap_err();
assert!(matches!(err, EnvelopeError::MissingKey(":id")));
fn rejects_envelope_missing_form() {
let err = parse_request("(:id 1)").unwrap_err();
assert!(matches!(err, EnvelopeError::MissingKey(":form")));
fn rejects_envelope_with_invalid_id() {
let err = parse_request("(:id (a b) :form 1)").unwrap_err();
assert!(matches!(err, EnvelopeError::InvalidValue(":id", _)));
fn rejects_envelope_with_fractional_id() {
let err = parse_request("(:id 3/4 :form 1)").unwrap_err();
fn rejects_envelope_with_unbalanced_plist() {
let err = parse_request("(:id 1 :form)").unwrap_err();
assert!(matches!(err, EnvelopeError::NotPlist));
fn rejects_non_list_envelope() {
let err = parse_request("42").unwrap_err();
fn rejects_multiple_envelopes_per_frame() {
let err = parse_request("(:id 1 :form 1) (:id 2 :form 2)").unwrap_err();
assert!(matches!(err, EnvelopeError::NotSingleExpr));
fn formats_value_response() {
let resp = Response {
id: RequestId::Int(7),
payload: ResponsePayload::Value(Value::Number(Fraction::from_integer(99))),
assert_eq!(format_response(&resp), "(:id 7 :value 99)");
fn formats_value_response_with_string_id() {
id: RequestId::String("req-1".into()),
payload: ResponsePayload::Value(Value::Bool(true)),
assert_eq!(format_response(&resp), "(:id \"req-1\" :value #t)");
fn formats_error_response_without_detail() {
id: RequestId::Int(3),
payload: ResponsePayload::Error {
code: ErrorCode::new(ErrorCode::ARGS),
message: "missing :user-id".into(),
detail: None,
format_response(&resp),
"(:id 3 :error (:code args :message \"missing :user-id\"))"
fn formats_error_response_with_detail() {
id: RequestId::Int(8),
code: ErrorCode::new(ErrorCode::DB),
message: "query failed".into(),
detail: Some("SqlxError(...)".into()),
"(:id 8 :error (:code db :message \"query failed\" :detail \"SqlxError(...)\"))"
fn formats_error_for_every_well_known_code() {
for symbol in [
ErrorCode::ARGS,
ErrorCode::CONFIG,
ErrorCode::DB,
ErrorCode::SERVER,
ErrorCode::FINANCE,
ErrorCode::SCRIPT,
ErrorCode::PARSE,
ErrorCode::COMPILE,
ErrorCode::RUNTIME,
ErrorCode::AUTH,
ErrorCode::INTERRUPTED,
ErrorCode::NO_CONVERSION,
] {
let code = ErrorCode::new(symbol);
id: RequestId::Int(0),
code: code.clone(),
message: "x".into(),
let formatted = format_response(&resp);
assert!(
formatted.contains(code.as_symbol()),
"expected {symbol:?} in {formatted:?}",
fn formats_script_raised_code_verbatim() {
id: RequestId::Int(4),
code: ErrorCode::new("no-such-account"),
message: "id=42".into(),
"(:id 4 :error (:code no-such-account :message \"id=42\"))"
fn formats_value_response_with_bytes() {
id: RequestId::Int(1),
payload: ResponsePayload::Value(Value::Bytes(vec![0xCA, 0xFE])),
assert_eq!(format_response(&resp), "(:id 1 :value #u8(202 254))");
fn formats_value_response_with_pair() {
let list = Pair::cons(
Value::Number(Fraction::from_integer(1)),
Pair::cons(Value::Number(Fraction::from_integer(2)), Value::Nil),
payload: ResponsePayload::Value(list),
assert_eq!(format_response(&resp), "(:id 1 :value (1 2))");