Lines
93.04 %
Functions
88.89 %
Branches
100 %
//! Shared wire-parse + render surface for CLI and TUI consumers.
pub mod schema;
use nomiscript::{Reader, Value, format_value, list_to_vec};
use thiserror::Error;
use rpc::{EnvelopeError, ResponsePayload, parse_response, try_expr_to_value};
#[derive(Debug, Clone, PartialEq)]
pub enum WireValue {
Value(Value),
}
/// A structured row from a list reply, carrying an optional entity id alongside display cells.
///
/// The `cells` are identical to what [`value_to_rows`] produces — display is unchanged.
/// The `id` is the `:id` plist field extracted separately for navigation (e.g. open/edit).
pub struct ListRow {
/// Entity id from the `:id` plist field, or `None` when absent.
pub id: Option<String>,
/// Display cells, byte-identical to the `value_to_rows` output for this element.
pub cells: Vec<String>,
#[derive(Debug, Error)]
pub enum RenderError {
#[error("envelope parse failed: {0}")]
Envelope(#[from] EnvelopeError),
#[error("wire error from server: [{code}] {message}")]
Server { code: String, message: String },
#[error("re-parse of printed list failed: {0}")]
ListReparse(String),
/// Parse a wire reply into a [`WireValue`].
/// The `:value` payload is returned as-is — a string value stays
/// `Value::String(s)` without any re-interpretation. Callers that know
/// the command returns a `pair:*` or `entity:*` result (per the native
/// reference) must call [`reparse_list`] on the string to obtain a
/// structured value.
/// Returns `Err(RenderError::Server)` when the frame contains `:error`.
pub fn parse_wire(wire: &str) -> Result<WireValue, RenderError> {
let response = parse_response(wire)?;
match response.payload {
ResponsePayload::Error { code, message, .. } => Err(RenderError::Server {
code: code.as_symbol().to_string(),
message,
}),
ResponsePayload::Value(v) => Ok(WireValue::Value(v)),
/// Paren-nesting depth above which [`reparse_list`] refuses to parse, to keep
/// the reader's per-paren recursion within the native stack.
const MAX_REPARSE_DEPTH: usize = 64;
/// Largest paren-nesting depth in `s`, ignoring parens inside string literals
/// (so a `"USD)"` cell does not inflate the count). Cheap single pass.
fn max_nesting_depth(s: &str) -> usize {
let (mut depth, mut max) = (0usize, 0usize);
let (mut in_string, mut escaped) = (false, false);
for ch in s.chars() {
if in_string {
match (escaped, ch) {
(true, _) => escaped = false,
(false, '\\') => escaped = true,
(false, '"') => in_string = false,
_ => {}
continue;
match ch {
'"' => in_string = true,
'(' => {
depth += 1;
max = max.max(depth);
')' => depth = depth.saturating_sub(1),
max
/// Re-parse a printed-list string (as returned by `pair:*`/`entity:*` natives)
/// into a structured [`Value`].
/// Only call this when the command's declared result type is `pair:*` or
/// `entity:*`. Scalar string values (UUIDs, names) must NOT be passed here,
/// as they may accidentally parse as atoms or produce incorrect values.
pub fn reparse_list(s: &str) -> Result<Value, RenderError> {
// Bound nesting BEFORE handing the string to the reader: `Reader::parse`
// itself recurses per paren and overflows the native stack on deeply
// nested input. Real entity/balance plists are shallow (depth 1-2), so a
// generous cap rejects malformed/hostile wire data without parsing it.
if max_nesting_depth(s) > MAX_REPARSE_DEPTH {
return Err(RenderError::ListReparse(format!(
"printed list nested beyond {MAX_REPARSE_DEPTH}"
)));
let program = Reader::parse(s).map_err(|e| RenderError::ListReparse(e.to_string()))?;
let mut iter = program.exprs.into_iter();
let Some(expr) = iter.next() else {
return Ok(Value::Nil);
};
if iter.next().is_some() {
return Err(RenderError::ListReparse(
"printed list string contains multiple top-level expressions".into(),
));
try_expr_to_value(expr).map_err(|e| RenderError::ListReparse(e.to_string()))
/// Walk a [`Value`] into columnar rows for display.
/// - `Nil` → no rows
/// - A top-level plist (`(:k v :k v …)`) → ONE row of its field values
/// - Other proper list → one row per element; plist elements yield field cells
/// - Scalar → single 1-cell row
pub fn value_to_rows(value: &Value) -> Vec<Vec<String>> {
match value {
Value::Nil => vec![],
Value::Pair(_) => {
if let Some(row) = try_plist_values(value) {
vec![row]
} else if let Some(elements) = list_to_vec(value) {
elements.iter().map(element_to_row).collect()
} else {
vec![vec![format_value(value)]]
other => vec![vec![format_value(other)]],
/// Walk a [`Value`] into [`ListRow`]s, pairing display cells with the `:id` plist field.
/// The `cells` of every row are byte-identical to what [`value_to_rows`] produces;
/// the `id` is extracted separately by [`row_id`]. Real list rows are typed records
/// with a leading tag (`(:account :id … :name …)`), so the id is found by position,
/// not by assuming `:id` sits at an even key slot.
pub fn rows_with_ids(value: &Value) -> Vec<ListRow> {
if let Some(cells) = try_plist_values(value) {
vec![ListRow {
id: row_id(value),
cells,
}]
elements.iter().map(element_to_list_row).collect()
id: None,
cells: vec![format_value(value)],
other => vec![ListRow {
cells: vec![format_value(other)],
}],
fn element_to_list_row(element: &Value) -> ListRow {
match element {
let cells = try_plist_values(element).unwrap_or_else(|| vec![format_value(element)]);
ListRow {
id: row_id(element),
other => ListRow {
},
/// Extract a row's `:id`, reusing the single-source plist scan.
/// [`crate::eval::plist_field`] finds `:id` by position over the top-level items,
/// so a leading type tag (`:account`) does not displace it and a nested split's
/// `:id` is never reached.
fn row_id(value: &Value) -> Option<String> {
crate::eval::plist_field(value, ":id")
fn element_to_row(element: &Value) -> Vec<String> {
if let Some(cells) = try_plist_values(element) {
return cells;
vec![format_value(element)]
other => vec![format_value(other)],
fn is_plist_key(v: &Value) -> bool {
matches!(v, Value::Symbol(s) if s.starts_with(':'))
fn try_plist_values(value: &Value) -> Option<Vec<String>> {
let items = list_to_vec(value)?;
if items.len() < 2 || !items.len().is_multiple_of(2) {
return None;
if !items.iter().step_by(2).all(is_plist_key) {
Some(
items
.into_iter()
.skip(1)
.step_by(2)
.map(|v| format_value(&v))
.collect(),
)
#[cfg(test)]
mod tests {
use nomiscript::{Fraction, Pair, Value};
use super::*;
#[test]
fn parse_wire_nil_value() {
let result = parse_wire("(:id 1 :value NIL)").unwrap();
assert_eq!(result, WireValue::Value(Value::Nil));
fn parse_wire_number_value() {
let result = parse_wire("(:id 1 :value 42)").unwrap();
assert_eq!(
result,
WireValue::Value(Value::Number(Fraction::from_integer(42)))
);
/// Scalar strings that happen to look like s-expressions must stay as strings.
fn parse_wire_scalar_string_list_like_stays_string() {
let result = parse_wire(r#"(:id 1 :value "(1 2)")"#).unwrap();
assert_eq!(result, WireValue::Value(Value::String("(1 2)".into())));
fn parse_wire_scalar_string_nil_stays_string() {
let result = parse_wire(r#"(:id 1 :value "nil")"#).unwrap();
assert_eq!(result, WireValue::Value(Value::String("nil".into())));
fn parse_wire_scalar_string_empty_list_stays_string() {
let result = parse_wire(r#"(:id 1 :value "()")"#).unwrap();
assert_eq!(result, WireValue::Value(Value::String("()".into())));
fn parse_wire_scalar_string_with_quotes_stays_string() {
let result = parse_wire(r#"(:id 1 :value "\"hello\"")"#).unwrap();
assert_eq!(result, WireValue::Value(Value::String("\"hello\"".into())));
fn parse_wire_plain_string_passthrough() {
let result = parse_wire(r#"(:id 1 :value "some-plain-uuid-string")"#).unwrap();
WireValue::Value(Value::String("some-plain-uuid-string".into()))
fn parse_wire_bool_value() {
let result = parse_wire("(:id 1 :value #t)").unwrap();
assert_eq!(result, WireValue::Value(Value::Bool(true)));
fn parse_wire_error_returns_err() {
let result = parse_wire(r#"(:id 1 :error (:code args :message "oops"))"#);
assert!(matches!(
Err(RenderError::Server { code, message }) if code == "args" && message == "oops"
fn reparse_list_parses_two_element_list() {
let result = reparse_list("(1 2)").unwrap();
let expected = Pair::cons(
Value::Number(Fraction::from_integer(1)),
Pair::cons(Value::Number(Fraction::from_integer(2)), Value::Nil),
assert_eq!(result, expected);
fn reparse_list_parses_empty_list() {
let result = reparse_list("()").unwrap();
assert_eq!(result, Value::Nil);
fn reparse_list_errors_on_over_depth_input() {
// A printed list nested far beyond MAX_VALUE_DEPTH must surface an
// error, not silently collapse to Value::Nil (masking malformed data).
let deep = format!("{}1{}", "(".repeat(300), ")".repeat(300));
let err = reparse_list(&deep).expect_err("over-depth list must error");
assert!(matches!(err, RenderError::ListReparse(_)), "got {err:?}");
fn reparse_list_parses_keyword_plist() {
// Simulates a get-balances plist with keyword keys
let s = "(:commodity-id \"abc\" :symbol \"USD\" :value-num 100 :value-denom 1)";
let result = reparse_list(s).unwrap();
// The result is a flat list with keyword symbols and values alternating
if let Value::Pair(p) = &result {
assert_eq!(p.car, Value::Symbol(":commodity-id".into()));
panic!("expected pair, got: {result:?}");
fn value_to_rows_nil() {
assert_eq!(value_to_rows(&Value::Nil), Vec::<Vec<String>>::new());
fn value_to_rows_scalar_number() {
value_to_rows(&Value::Number(Fraction::from_integer(42))),
vec![vec!["42".to_string()]]
fn value_to_rows_list_of_numbers() {
let list = Pair::cons(
Pair::cons(
Value::Number(Fraction::from_integer(2)),
Pair::cons(Value::Number(Fraction::from_integer(3)), Value::Nil),
),
let rows = value_to_rows(&list);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0], vec!["1"]);
assert_eq!(rows[1], vec!["2"]);
assert_eq!(rows[2], vec!["3"]);
fn value_to_rows_plist_element_keyword_keys() {
// Keywords map to Symbol(":key") after reparse_list
let plist = Pair::cons(
Value::Symbol(":name".into()),
Value::String("Alice".into()),
Value::Symbol(":age".into()),
Pair::cons(Value::Number(Fraction::from_integer(30)), Value::Nil),
let list = Pair::cons(plist, Value::Nil);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0], vec!["\"Alice\"", "30"]);
fn value_to_rows_bare_symbol_list_is_not_plist() {
// Only `:keyword` symbols are plist keys (real reparsed wire keys carry
// the `:` prefix). A list of bare symbols is NOT a plist, so its element
// renders as a single formatted cell, not field-extracted values.
let inner = Pair::cons(
Value::Symbol("NAME".into()),
Value::Symbol("AGE".into()),
let list = Pair::cons(inner, Value::Nil);
assert_eq!(rows[0].len(), 1, "bare-symbol list must not field-extract");
// Real list rows are typed records with a leading tag, exactly as
// `render_entity` emits them: `(:account :id "uuid" :name "Cash" :parent "")`.
// The id sits AFTER the tag, never at an even key slot.
fn typed_accounts_list() -> Value {
reparse_list(
r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#,
.expect("typed account list parses")
fn rows_with_ids_extracts_id_from_typed_account_rows() {
let rows = rows_with_ids(&typed_accounts_list());
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
fn rows_with_ids_extracts_id_from_typed_commodity_row() {
let value =
reparse_list(r#"((:commodity :id "c-1" :symbol "USD" :name "US Dollar"))"#).unwrap();
let rows = rows_with_ids(&value);
assert_eq!(rows[0].id.as_deref(), Some("c-1"));
fn rows_with_ids_extracts_id_from_typed_transaction_row() {
reparse_list(r#"((:transaction :id "t-1" :note "lunch" :post-date "2026-06-27"))"#)
.unwrap();
assert_eq!(rows[0].id.as_deref(), Some("t-1"));
fn rows_with_ids_cells_match_value_to_rows() {
let value = typed_accounts_list();
let plain = value_to_rows(&value);
let structured = rows_with_ids(&value);
let cells: Vec<Vec<String>> = structured.iter().map(|r| r.cells.clone()).collect();
cells, plain,
"cells must be byte-identical to value_to_rows"
fn rows_with_ids_nil_id_for_tagged_record_without_id() {
// A realistic tagged record that simply lacks :id → id None, cells still rendered.
let value = reparse_list(r#"((:account :name "Cash" :parent ""))"#).unwrap();
assert_eq!(rows[0].id, None);
assert!(!rows[0].cells.is_empty());
fn rows_with_ids_scalar_produces_no_id() {
let rows = rows_with_ids(&Value::Number(Fraction::from_integer(42)));
assert_eq!(rows[0].cells, vec!["42"]);