1
//! Shared wire-parse + render surface for CLI and TUI consumers.
2

            
3
pub mod schema;
4

            
5
use nomiscript::{Reader, Value, format_value, list_to_vec};
6
use thiserror::Error;
7

            
8
use rpc::{EnvelopeError, ResponsePayload, parse_response, try_expr_to_value};
9

            
10
#[derive(Debug, Clone, PartialEq)]
11
pub enum WireValue {
12
    Value(Value),
13
}
14

            
15
/// A structured row from a list reply, carrying an optional entity id alongside display cells.
16
///
17
/// The `cells` are identical to what [`value_to_rows`] produces — display is unchanged.
18
/// The `id` is the `:id` plist field extracted separately for navigation (e.g. open/edit).
19
#[derive(Debug, Clone, PartialEq)]
20
pub struct ListRow {
21
    /// Entity id from the `:id` plist field, or `None` when absent.
22
    pub id: Option<String>,
23
    /// Display cells, byte-identical to the `value_to_rows` output for this element.
24
    pub cells: Vec<String>,
25
}
26

            
27
#[derive(Debug, Error)]
28
pub enum RenderError {
29
    #[error("envelope parse failed: {0}")]
30
    Envelope(#[from] EnvelopeError),
31
    #[error("wire error from server: [{code}] {message}")]
32
    Server { code: String, message: String },
33
    #[error("re-parse of printed list failed: {0}")]
34
    ListReparse(String),
35
}
36

            
37
/// Parse a wire reply into a [`WireValue`].
38
///
39
/// The `:value` payload is returned as-is — a string value stays
40
/// `Value::String(s)` without any re-interpretation. Callers that know
41
/// the command returns a `pair:*` or `entity:*` result (per the native
42
/// reference) must call [`reparse_list`] on the string to obtain a
43
/// structured value.
44
///
45
/// Returns `Err(RenderError::Server)` when the frame contains `:error`.
46
889
pub fn parse_wire(wire: &str) -> Result<WireValue, RenderError> {
47
889
    let response = parse_response(wire)?;
48
766
    match response.payload {
49
171
        ResponsePayload::Error { code, message, .. } => Err(RenderError::Server {
50
171
            code: code.as_symbol().to_string(),
51
171
            message,
52
171
        }),
53
595
        ResponsePayload::Value(v) => Ok(WireValue::Value(v)),
54
    }
55
889
}
56

            
57
/// Paren-nesting depth above which [`reparse_list`] refuses to parse, to keep
58
/// the reader's per-paren recursion within the native stack.
59
const MAX_REPARSE_DEPTH: usize = 64;
60

            
61
/// Largest paren-nesting depth in `s`, ignoring parens inside string literals
62
/// (so a `"USD)"` cell does not inflate the count). Cheap single pass.
63
499
fn max_nesting_depth(s: &str) -> usize {
64
499
    let (mut depth, mut max) = (0usize, 0usize);
65
499
    let (mut in_string, mut escaped) = (false, false);
66
52300
    for ch in s.chars() {
67
52300
        if in_string {
68
24890
            match (escaped, ch) {
69
156
                (true, _) => escaped = false,
70
156
                (false, '\\') => escaped = true,
71
1508
                (false, '"') => in_string = false,
72
23070
                _ => {}
73
            }
74
24890
            continue;
75
27410
        }
76
27410
        match ch {
77
1508
            '"' => in_string = true,
78
1522
            '(' => {
79
1522
                depth += 1;
80
1522
                max = max.max(depth);
81
1522
            }
82
1522
            ')' => depth = depth.saturating_sub(1),
83
22858
            _ => {}
84
        }
85
    }
86
499
    max
87
499
}
88

            
89
/// Re-parse a printed-list string (as returned by `pair:*`/`entity:*` natives)
90
/// into a structured [`Value`].
91
///
92
/// Only call this when the command's declared result type is `pair:*` or
93
/// `entity:*`. Scalar string values (UUIDs, names) must NOT be passed here,
94
/// as they may accidentally parse as atoms or produce incorrect values.
95
499
pub fn reparse_list(s: &str) -> Result<Value, RenderError> {
96
    // Bound nesting BEFORE handing the string to the reader: `Reader::parse`
97
    // itself recurses per paren and overflows the native stack on deeply
98
    // nested input. Real entity/balance plists are shallow (depth 1-2), so a
99
    // generous cap rejects malformed/hostile wire data without parsing it.
100
499
    if max_nesting_depth(s) > MAX_REPARSE_DEPTH {
101
1
        return Err(RenderError::ListReparse(format!(
102
1
            "printed list nested beyond {MAX_REPARSE_DEPTH}"
103
1
        )));
104
498
    }
105
498
    let program = Reader::parse(s).map_err(|e| RenderError::ListReparse(e.to_string()))?;
106
497
    let mut iter = program.exprs.into_iter();
107
497
    let Some(expr) = iter.next() else {
108
        return Ok(Value::Nil);
109
    };
110
497
    if iter.next().is_some() {
111
        return Err(RenderError::ListReparse(
112
            "printed list string contains multiple top-level expressions".into(),
113
        ));
114
497
    }
115
497
    try_expr_to_value(expr).map_err(|e| RenderError::ListReparse(e.to_string()))
116
499
}
117

            
118
/// Walk a [`Value`] into columnar rows for display.
119
///
120
/// - `Nil` → no rows
121
/// - A top-level plist (`(:k v :k v …)`) → ONE row of its field values
122
/// - Other proper list → one row per element; plist elements yield field cells
123
/// - Scalar → single 1-cell row
124
84
pub fn value_to_rows(value: &Value) -> Vec<Vec<String>> {
125
84
    match value {
126
14
        Value::Nil => vec![],
127
        Value::Pair(_) => {
128
69
            if let Some(row) = try_plist_values(value) {
129
13
                vec![row]
130
56
            } else if let Some(elements) = list_to_vec(value) {
131
56
                elements.iter().map(element_to_row).collect()
132
            } else {
133
                vec![vec![format_value(value)]]
134
            }
135
        }
136
1
        other => vec![vec![format_value(other)]],
137
    }
138
84
}
139

            
140
/// Walk a [`Value`] into [`ListRow`]s, pairing display cells with the `:id` plist field.
141
///
142
/// The `cells` of every row are byte-identical to what [`value_to_rows`] produces;
143
/// the `id` is extracted separately by [`row_id`]. Real list rows are typed records
144
/// with a leading tag (`(:account :id … :name …)`), so the id is found by position,
145
/// not by assuming `:id` sits at an even key slot.
146
6
pub fn rows_with_ids(value: &Value) -> Vec<ListRow> {
147
6
    match value {
148
        Value::Nil => vec![],
149
        Value::Pair(_) => {
150
5
            if let Some(cells) = try_plist_values(value) {
151
                vec![ListRow {
152
                    id: row_id(value),
153
                    cells,
154
                }]
155
5
            } else if let Some(elements) = list_to_vec(value) {
156
5
                elements.iter().map(element_to_list_row).collect()
157
            } else {
158
                vec![ListRow {
159
                    id: None,
160
                    cells: vec![format_value(value)],
161
                }]
162
            }
163
        }
164
1
        other => vec![ListRow {
165
1
            id: None,
166
1
            cells: vec![format_value(other)],
167
1
        }],
168
    }
169
6
}
170

            
171
7
fn element_to_list_row(element: &Value) -> ListRow {
172
7
    match element {
173
        Value::Pair(_) => {
174
7
            let cells = try_plist_values(element).unwrap_or_else(|| vec![format_value(element)]);
175
7
            ListRow {
176
7
                id: row_id(element),
177
7
                cells,
178
7
            }
179
        }
180
        other => ListRow {
181
            id: None,
182
            cells: vec![format_value(other)],
183
        },
184
    }
185
7
}
186

            
187
/// Extract a row's `:id`, reusing the single-source plist scan.
188
///
189
/// [`crate::eval::plist_field`] finds `:id` by position over the top-level items,
190
/// so a leading type tag (`:account`) does not displace it and a nested split's
191
/// `:id` is never reached.
192
303
fn row_id(value: &Value) -> Option<String> {
193
303
    crate::eval::plist_field(value, ":id")
194
303
}
195

            
196
98
fn element_to_row(element: &Value) -> Vec<String> {
197
98
    match element {
198
        Value::Pair(_) => {
199
69
            if let Some(cells) = try_plist_values(element) {
200
40
                return cells;
201
29
            }
202
29
            vec![format_value(element)]
203
        }
204
29
        other => vec![format_value(other)],
205
    }
206
98
}
207

            
208
188
fn is_plist_key(v: &Value) -> bool {
209
146
    matches!(v, Value::Symbol(s) if s.starts_with(':'))
210
188
}
211

            
212
150
fn try_plist_values(value: &Value) -> Option<Vec<String>> {
213
150
    let items = list_to_vec(value)?;
214
150
    if items.len() < 2 || !items.len().is_multiple_of(2) {
215
54
        return None;
216
96
    }
217
96
    if !items.iter().step_by(2).all(is_plist_key) {
218
43
        return None;
219
53
    }
220
    Some(
221
53
        items
222
53
            .into_iter()
223
53
            .skip(1)
224
53
            .step_by(2)
225
145
            .map(|v| format_value(&v))
226
53
            .collect(),
227
    )
228
150
}
229

            
230
#[cfg(test)]
231
mod tests {
232
    use nomiscript::{Fraction, Pair, Value};
233

            
234
    use super::*;
235

            
236
    #[test]
237
1
    fn parse_wire_nil_value() {
238
1
        let result = parse_wire("(:id 1 :value NIL)").unwrap();
239
1
        assert_eq!(result, WireValue::Value(Value::Nil));
240
1
    }
241

            
242
    #[test]
243
1
    fn parse_wire_number_value() {
244
1
        let result = parse_wire("(:id 1 :value 42)").unwrap();
245
1
        assert_eq!(
246
            result,
247
1
            WireValue::Value(Value::Number(Fraction::from_integer(42)))
248
        );
249
1
    }
250

            
251
    /// Scalar strings that happen to look like s-expressions must stay as strings.
252
    #[test]
253
1
    fn parse_wire_scalar_string_list_like_stays_string() {
254
1
        let result = parse_wire(r#"(:id 1 :value "(1 2)")"#).unwrap();
255
1
        assert_eq!(result, WireValue::Value(Value::String("(1 2)".into())));
256
1
    }
257

            
258
    #[test]
259
1
    fn parse_wire_scalar_string_nil_stays_string() {
260
1
        let result = parse_wire(r#"(:id 1 :value "nil")"#).unwrap();
261
1
        assert_eq!(result, WireValue::Value(Value::String("nil".into())));
262
1
    }
263

            
264
    #[test]
265
1
    fn parse_wire_scalar_string_empty_list_stays_string() {
266
1
        let result = parse_wire(r#"(:id 1 :value "()")"#).unwrap();
267
1
        assert_eq!(result, WireValue::Value(Value::String("()".into())));
268
1
    }
269

            
270
    #[test]
271
1
    fn parse_wire_scalar_string_with_quotes_stays_string() {
272
1
        let result = parse_wire(r#"(:id 1 :value "\"hello\"")"#).unwrap();
273
1
        assert_eq!(result, WireValue::Value(Value::String("\"hello\"".into())));
274
1
    }
275

            
276
    #[test]
277
1
    fn parse_wire_plain_string_passthrough() {
278
1
        let result = parse_wire(r#"(:id 1 :value "some-plain-uuid-string")"#).unwrap();
279
1
        assert_eq!(
280
            result,
281
1
            WireValue::Value(Value::String("some-plain-uuid-string".into()))
282
        );
283
1
    }
284

            
285
    #[test]
286
1
    fn parse_wire_bool_value() {
287
1
        let result = parse_wire("(:id 1 :value #t)").unwrap();
288
1
        assert_eq!(result, WireValue::Value(Value::Bool(true)));
289
1
    }
290

            
291
    #[test]
292
1
    fn parse_wire_error_returns_err() {
293
1
        let result = parse_wire(r#"(:id 1 :error (:code args :message "oops"))"#);
294
1
        assert!(matches!(
295
1
            result,
296
1
            Err(RenderError::Server { code, message }) if code == "args" && message == "oops"
297
        ));
298
1
    }
299

            
300
    #[test]
301
1
    fn reparse_list_parses_two_element_list() {
302
1
        let result = reparse_list("(1 2)").unwrap();
303
1
        let expected = Pair::cons(
304
1
            Value::Number(Fraction::from_integer(1)),
305
1
            Pair::cons(Value::Number(Fraction::from_integer(2)), Value::Nil),
306
        );
307
1
        assert_eq!(result, expected);
308
1
    }
309

            
310
    #[test]
311
1
    fn reparse_list_parses_empty_list() {
312
1
        let result = reparse_list("()").unwrap();
313
1
        assert_eq!(result, Value::Nil);
314
1
    }
315

            
316
    #[test]
317
1
    fn reparse_list_errors_on_over_depth_input() {
318
        // A printed list nested far beyond MAX_VALUE_DEPTH must surface an
319
        // error, not silently collapse to Value::Nil (masking malformed data).
320
1
        let deep = format!("{}1{}", "(".repeat(300), ")".repeat(300));
321
1
        let err = reparse_list(&deep).expect_err("over-depth list must error");
322
1
        assert!(matches!(err, RenderError::ListReparse(_)), "got {err:?}");
323
1
    }
324

            
325
    #[test]
326
1
    fn reparse_list_parses_keyword_plist() {
327
        // Simulates a get-balances plist with keyword keys
328
1
        let s = "(:commodity-id \"abc\" :symbol \"USD\" :value-num 100 :value-denom 1)";
329
1
        let result = reparse_list(s).unwrap();
330
        // The result is a flat list with keyword symbols and values alternating
331
1
        if let Value::Pair(p) = &result {
332
1
            assert_eq!(p.car, Value::Symbol(":commodity-id".into()));
333
        } else {
334
            panic!("expected pair, got: {result:?}");
335
        }
336
1
    }
337

            
338
    #[test]
339
1
    fn value_to_rows_nil() {
340
1
        assert_eq!(value_to_rows(&Value::Nil), Vec::<Vec<String>>::new());
341
1
    }
342

            
343
    #[test]
344
1
    fn value_to_rows_scalar_number() {
345
1
        assert_eq!(
346
1
            value_to_rows(&Value::Number(Fraction::from_integer(42))),
347
1
            vec![vec!["42".to_string()]]
348
        );
349
1
    }
350

            
351
    #[test]
352
1
    fn value_to_rows_list_of_numbers() {
353
1
        let list = Pair::cons(
354
1
            Value::Number(Fraction::from_integer(1)),
355
1
            Pair::cons(
356
1
                Value::Number(Fraction::from_integer(2)),
357
1
                Pair::cons(Value::Number(Fraction::from_integer(3)), Value::Nil),
358
            ),
359
        );
360
1
        let rows = value_to_rows(&list);
361
1
        assert_eq!(rows.len(), 3);
362
1
        assert_eq!(rows[0], vec!["1"]);
363
1
        assert_eq!(rows[1], vec!["2"]);
364
1
        assert_eq!(rows[2], vec!["3"]);
365
1
    }
366

            
367
    #[test]
368
1
    fn value_to_rows_plist_element_keyword_keys() {
369
        // Keywords map to Symbol(":key") after reparse_list
370
1
        let plist = Pair::cons(
371
1
            Value::Symbol(":name".into()),
372
1
            Pair::cons(
373
1
                Value::String("Alice".into()),
374
1
                Pair::cons(
375
1
                    Value::Symbol(":age".into()),
376
1
                    Pair::cons(Value::Number(Fraction::from_integer(30)), Value::Nil),
377
                ),
378
            ),
379
        );
380
1
        let list = Pair::cons(plist, Value::Nil);
381
1
        let rows = value_to_rows(&list);
382
1
        assert_eq!(rows.len(), 1);
383
1
        assert_eq!(rows[0], vec!["\"Alice\"", "30"]);
384
1
    }
385

            
386
    #[test]
387
1
    fn value_to_rows_bare_symbol_list_is_not_plist() {
388
        // Only `:keyword` symbols are plist keys (real reparsed wire keys carry
389
        // the `:` prefix). A list of bare symbols is NOT a plist, so its element
390
        // renders as a single formatted cell, not field-extracted values.
391
1
        let inner = Pair::cons(
392
1
            Value::Symbol("NAME".into()),
393
1
            Pair::cons(
394
1
                Value::String("Alice".into()),
395
1
                Pair::cons(
396
1
                    Value::Symbol("AGE".into()),
397
1
                    Pair::cons(Value::Number(Fraction::from_integer(30)), Value::Nil),
398
                ),
399
            ),
400
        );
401
1
        let list = Pair::cons(inner, Value::Nil);
402
1
        let rows = value_to_rows(&list);
403
1
        assert_eq!(rows.len(), 1);
404
1
        assert_eq!(rows[0].len(), 1, "bare-symbol list must not field-extract");
405
1
    }
406

            
407
    // Real list rows are typed records with a leading tag, exactly as
408
    // `render_entity` emits them: `(:account :id "uuid" :name "Cash" :parent "")`.
409
    // The id sits AFTER the tag, never at an even key slot.
410
2
    fn typed_accounts_list() -> Value {
411
2
        reparse_list(
412
2
            r#"((:account :id "uuid-1" :name "Checking" :parent "") (:account :id "uuid-2" :name "Savings" :parent ""))"#,
413
        )
414
2
        .expect("typed account list parses")
415
2
    }
416

            
417
    #[test]
418
1
    fn rows_with_ids_extracts_id_from_typed_account_rows() {
419
1
        let rows = rows_with_ids(&typed_accounts_list());
420
1
        assert_eq!(rows.len(), 2);
421
1
        assert_eq!(rows[0].id.as_deref(), Some("uuid-1"));
422
1
        assert_eq!(rows[1].id.as_deref(), Some("uuid-2"));
423
1
    }
424

            
425
    #[test]
426
1
    fn rows_with_ids_extracts_id_from_typed_commodity_row() {
427
1
        let value =
428
1
            reparse_list(r#"((:commodity :id "c-1" :symbol "USD" :name "US Dollar"))"#).unwrap();
429
1
        let rows = rows_with_ids(&value);
430
1
        assert_eq!(rows.len(), 1);
431
1
        assert_eq!(rows[0].id.as_deref(), Some("c-1"));
432
1
    }
433

            
434
    #[test]
435
1
    fn rows_with_ids_extracts_id_from_typed_transaction_row() {
436
1
        let value =
437
1
            reparse_list(r#"((:transaction :id "t-1" :note "lunch" :post-date "2026-06-27"))"#)
438
1
                .unwrap();
439
1
        let rows = rows_with_ids(&value);
440
1
        assert_eq!(rows.len(), 1);
441
1
        assert_eq!(rows[0].id.as_deref(), Some("t-1"));
442
1
    }
443

            
444
    #[test]
445
1
    fn rows_with_ids_cells_match_value_to_rows() {
446
1
        let value = typed_accounts_list();
447
1
        let plain = value_to_rows(&value);
448
1
        let structured = rows_with_ids(&value);
449
2
        let cells: Vec<Vec<String>> = structured.iter().map(|r| r.cells.clone()).collect();
450
1
        assert_eq!(
451
            cells, plain,
452
            "cells must be byte-identical to value_to_rows"
453
        );
454
1
    }
455

            
456
    #[test]
457
1
    fn rows_with_ids_nil_id_for_tagged_record_without_id() {
458
        // A realistic tagged record that simply lacks :id → id None, cells still rendered.
459
1
        let value = reparse_list(r#"((:account :name "Cash" :parent ""))"#).unwrap();
460
1
        let rows = rows_with_ids(&value);
461
1
        assert_eq!(rows.len(), 1);
462
1
        assert_eq!(rows[0].id, None);
463
1
        assert!(!rows[0].cells.is_empty());
464
1
    }
465

            
466
    #[test]
467
1
    fn rows_with_ids_scalar_produces_no_id() {
468
1
        let rows = rows_with_ids(&Value::Number(Fraction::from_integer(42)));
469
1
        assert_eq!(rows.len(), 1);
470
1
        assert_eq!(rows[0].id, None);
471
1
        assert_eq!(rows[0].cells, vec!["42"]);
472
1
    }
473
}