1
//! Shared eval logic for CLI and TUI surfaces (ADR-0021 Phase 3a).
2

            
3
pub mod convert;
4
pub mod transaction;
5
pub use convert::{build_convert_amount_form, parse_scalar_reply};
6
pub use transaction::{
7
    LogicalSplit, PhysicalSplit, PriceRow, build_create_transaction_form_dated,
8
    build_create_transaction_multi, lower_logical_split, lower_logical_transaction,
9
};
10

            
11
use chrono::Utc;
12
use nomiscript::{Value, list_to_vec};
13
use num_rational::Rational64;
14
use plotting::{
15
    ChartSpec,
16
    adapters::{
17
        ActivityChartOpts, BalanceChartOpts, BreakdownChartOpts, SortOrder, activity_chart,
18
        balance_chart, breakdown_chart,
19
    },
20
    kitty::{KittyOpts, render_kitty},
21
    text::render_text_default,
22
};
23
use rpc::Session;
24
use sqlx::types::Uuid;
25
use std::io::IsTerminal;
26
use std::sync::atomic::{AtomicU64, Ordering};
27

            
28
use crate::{
29
    CommandError,
30
    render::{RenderError, WireValue, parse_wire, reparse_list},
31
    reports::{
32
        RendererHint, parse_chart_shape, parse_renderer_hint, probe_terminal_kitty,
33
        value_to_activity_periods, value_to_balance_rows, value_to_breakdown_periods,
34
    },
35
};
36

            
37
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
38

            
39
25
fn next_id() -> u64 {
40
25
    NEXT_ID.fetch_add(1, Ordering::Relaxed)
41
25
}
42

            
43
/// Output of a single [`eval_form`] call, ready for the caller to emit.
44
pub enum Rendered {
45
    Lines(Vec<String>),
46
    Bytes(Vec<u8>),
47
    Nothing,
48
}
49

            
50
/// How the value part of a wire reply is rendered.
51
pub enum RenderMode {
52
    /// Print the scalar string value (or its formatted form) directly.
53
    Scalar,
54
    /// `pair:Account` list — print each account's `:name`, one per line.
55
    AccountList,
56
    /// `pair:Commodity` list — print each as `{symbol} - {name}`, one per line.
57
    CommodityList,
58
    /// The value is a `(:config-value <v>)` plist string: extract and print `<v>`.
59
    ConfigValue,
60
    /// An i32/bool success flag: print `true` for a non-zero/true value, else `false`.
61
    BoolSuccess,
62
    /// Produce no output (used for mutations whose result carries no output).
63
    Silent,
64
    /// `pair:string` balance list from `get-balances`.
65
    BalanceList,
66
    /// `pair:Transaction` entity list from `list-transactions`.
67
    TransactionList,
68
    /// `pair:SshKey` entity list from `list-ssh-keys`.
69
    SshKeyList,
70
    /// Balance report plist string → chart.
71
    ReportBalance { chart: String },
72
    /// Activity report plist string → chart.
73
    ReportActivity { chart: String },
74
    /// Category breakdown plist string → chart.
75
    ReportBreakdown { chart: String },
76
}
77

            
78
/// Escape a string for embedding in a nomiscript s-expression literal.
79
530
pub fn escape_str(s: &str) -> String {
80
530
    let mut out = String::with_capacity(s.len() + 2);
81
530
    out.push('"');
82
36770
    for ch in s.chars() {
83
36770
        match ch {
84
1282
            '"' => out.push_str("\\\""),
85
4
            '\\' => out.push_str("\\\\"),
86
3
            '\n' => out.push_str("\\n"),
87
            '\r' => out.push_str("\\r"),
88
35481
            other => out.push(other),
89
        }
90
    }
91
530
    out.push('"');
92
530
    out
93
530
}
94

            
95
/// Build the nomiscript form for `(create-commodity sym name)`.
96
15
pub fn build_create_commodity_form(symbol: &str, name: &str) -> String {
97
15
    format!(
98
        "(create-commodity {} {})",
99
15
        escape_str(symbol),
100
15
        escape_str(name)
101
    )
102
15
}
103

            
104
/// Build the nomiscript form for `(create-account name parent)`.
105
///
106
/// The `create-account` native is declared with a fixed arity of TWO string
107
/// args, and its runtime treats an empty-string parent as "no parent" (a root
108
/// account). So a root account is `(create-account "name" "")` — both args are
109
/// always present; omitting the parent fails the compiler arity check.
110
29
pub fn build_create_account_form(name: &str, parent: Option<&str>) -> String {
111
29
    let parent = parent.filter(|p| !p.is_empty()).unwrap_or("");
112
29
    format!(
113
        "(create-account {} {})",
114
29
        escape_str(name),
115
29
        escape_str(parent)
116
    )
117
29
}
118

            
119
/// Build the nomiscript form for `(delete-transaction uuid)`.
120
41
pub fn build_delete_transaction_form(id: &str) -> String {
121
41
    format!("(delete-transaction {})", escape_str(id))
122
41
}
123

            
124
/// Build the nomiscript form for `(set-account-tag uuid name value)`.
125
15
pub fn build_set_account_tag_form(id: &str, name: &str, value: &str) -> String {
126
15
    format!(
127
        "(set-account-tag {} {} {})",
128
15
        escape_str(id),
129
15
        escape_str(name),
130
15
        escape_str(value)
131
    )
132
15
}
133

            
134
/// Build the nomiscript form for `(set-transaction-tag uuid name value)`.
135
15
pub fn build_set_transaction_tag_form(id: &str, name: &str, value: &str) -> String {
136
15
    format!(
137
        "(set-transaction-tag {} {} {})",
138
15
        escape_str(id),
139
15
        escape_str(name),
140
15
        escape_str(value)
141
    )
142
15
}
143

            
144
49
fn format_rational(r: Rational64) -> String {
145
49
    if *r.denom() == 1 {
146
47
        r.numer().to_string()
147
    } else {
148
2
        format!("{}/{}", r.numer(), r.denom())
149
    }
150
49
}
151

            
152
/// Build the nomiscript `(create-transaction "...")` form from CLI args.
153
///
154
/// Thin back-compat wrapper over [`build_create_transaction_form_dated`] that
155
/// stamps the current time. Callers that need a deterministic date should use
156
/// the dated variant directly.
157
3
pub fn build_create_transaction_form(
158
3
    from: Uuid,
159
3
    to: Uuid,
160
3
    from_currency: Uuid,
161
3
    to_currency: Uuid,
162
3
    value: Rational64,
163
3
    to_amount: Option<Rational64>,
164
3
    note: Option<&str>,
165
3
) -> Result<String, CommandError> {
166
3
    let ls = LogicalSplit {
167
3
        from,
168
3
        to,
169
3
        from_commodity: from_currency,
170
3
        to_commodity: to_currency,
171
3
        value,
172
3
        to_amount,
173
3
    };
174
3
    build_create_transaction_form_dated(&ls, note, Utc::now())
175
3
}
176

            
177
/// Evaluate `form` via `session`, render the reply according to `mode`.
178
///
179
/// A server-side error is surfaced as `CommandError::Command` and the caller
180
/// propagates it through the error path (→ stderr + `exit(1)`).
181
23
pub async fn eval_form(
182
23
    session: &mut Session,
183
23
    form: &str,
184
23
    mode: RenderMode,
185
23
) -> Result<Rendered, CommandError> {
186
23
    let id = next_id();
187
23
    let frame = format!("(:id {id} :form {form})");
188
23
    let wire = session.handle_form(&frame).await;
189
23
    let wire_value = parse_wire(&wire).map_err(render_error_to_command)?;
190
21
    let WireValue::Value(value) = wire_value;
191
21
    match mode {
192
2
        RenderMode::Scalar => Ok(render_scalar(&value)),
193
2
        RenderMode::AccountList => render_entity_list(value, account_list_line),
194
1
        RenderMode::CommodityList => render_entity_list(value, commodity_list_line),
195
3
        RenderMode::ConfigValue => render_config_value(&value),
196
5
        RenderMode::BoolSuccess => render_bool_success(&value),
197
1
        RenderMode::Silent => Ok(Rendered::Nothing),
198
1
        RenderMode::BalanceList => render_balance_list(&value),
199
2
        RenderMode::TransactionList => render_transaction_list(&value),
200
1
        RenderMode::SshKeyList => render_entity_list(value, ssh_key_list_line),
201
1
        RenderMode::ReportBalance { chart } => render_report_balance(&value, &chart),
202
1
        RenderMode::ReportActivity { chart } => render_report_activity(&value, &chart),
203
1
        RenderMode::ReportBreakdown { chart } => render_report_breakdown(&value, &chart),
204
    }
205
23
}
206

            
207
/// Success-flag natives still return an i32 (0 = failed) until they move to
208
/// `bool`; any other reply means the native's contract changed, so it is an
209
/// error rather than a silent `true`.
210
10
fn render_bool_success(value: &Value) -> Result<Rendered, CommandError> {
211
10
    let success = match value {
212
2
        Value::T => true,
213
2
        Value::Nil => false,
214
4
        Value::Number(n) => *n != nomiscript::Fraction::from_integer(0),
215
2
        other => {
216
2
            return Err(CommandError::Command(format!(
217
2
                "expected a success flag, got {}",
218
2
                nomiscript::format_value(other)
219
2
            )));
220
        }
221
    };
222
8
    Ok(Rendered::Lines(vec![success.to_string()]))
223
10
}
224

            
225
3
fn render_error_to_command(e: RenderError) -> CommandError {
226
3
    match e {
227
3
        RenderError::Server { code, message } => {
228
3
            CommandError::Command(format!("[{code}] {message}"))
229
        }
230
        other => CommandError::Command(other.to_string()),
231
    }
232
3
}
233

            
234
2
fn render_scalar(value: &Value) -> Rendered {
235
2
    match value {
236
1
        Value::String(s) => Rendered::Lines(vec![s.clone()]),
237
1
        other => Rendered::Lines(vec![nomiscript::format_value(other)]),
238
    }
239
2
}
240

            
241
5
fn render_entity_list(
242
5
    value: Value,
243
5
    line: fn(&Value) -> Option<String>,
244
5
) -> Result<Rendered, CommandError> {
245
5
    let raw = match &value {
246
3
        Value::String(s) => s.as_str(),
247
2
        Value::Nil => return Ok(Rendered::Lines(vec![])),
248
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
249
    };
250
3
    let reparsed =
251
3
        reparse_list(raw).map_err(|e| CommandError::Command(format!("list render failed: {e}")))?;
252
3
    let Some(elements) = list_to_vec(&reparsed) else {
253
        return Ok(Rendered::Lines(vec![]));
254
    };
255
3
    let mut rows = Vec::with_capacity(elements.len());
256
3
    for element in &elements {
257
3
        let rendered = line(element)
258
3
            .ok_or_else(|| CommandError::Command(format!("unexpected entity shape: {raw}")))?;
259
3
        rows.push(rendered);
260
    }
261
3
    Ok(Rendered::Lines(rows))
262
5
}
263

            
264
5
fn account_list_line(element: &Value) -> Option<String> {
265
5
    plist_field(element, ":name")
266
5
}
267

            
268
2
fn commodity_list_line(element: &Value) -> Option<String> {
269
2
    let symbol = plist_field(element, ":symbol")?;
270
2
    let name = plist_field(element, ":name")?;
271
2
    Some(format!("{symbol} - {name}"))
272
2
}
273

            
274
6
fn ssh_key_list_line(element: &Value) -> Option<String> {
275
6
    let fingerprint = plist_field(element, ":fingerprint")?;
276
5
    let key_type = plist_field(element, ":key-type")?;
277
4
    let created_at = plist_field(element, ":created-at")?;
278
3
    let name = plist_field(element, ":name").unwrap_or_default();
279
3
    let last_used_at = plist_field(element, ":last-used-at").unwrap_or_default();
280
3
    let label = if name.is_empty() {
281
1
        fingerprint.clone()
282
    } else {
283
2
        name
284
    };
285
3
    let used = if last_used_at.is_empty() {
286
2
        "never".to_string()
287
    } else {
288
1
        last_used_at
289
    };
290
3
    Some(format!(
291
3
        "{label}  [{key_type}]  {fingerprint}  created:{created_at}  last-used:{used}"
292
3
    ))
293
6
}
294

            
295
/// Legacy sentinel for a no-splits / missing-account / zero-balance account:
296
/// the old balance path emitted `(0, "No transaction yet", "NONE")` rendered as
297
/// `"{balance} {symbol} ({name})"` — i.e. `0 NONE (No transaction yet)`.
298
const NO_TRANSACTION_YET: &str = "0 NONE (No transaction yet)";
299

            
300
3
fn render_balance_list(value: &Value) -> Result<Rendered, CommandError> {
301
3
    let raw = match value {
302
1
        Value::String(s) => s.as_str(),
303
2
        Value::Nil => return Ok(Rendered::Lines(vec![NO_TRANSACTION_YET.to_string()])),
304
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
305
    };
306
1
    let outer =
307
1
        reparse_list(raw).map_err(|e| CommandError::Command(format!("balance list parse: {e}")))?;
308
1
    let elements = list_to_vec(&outer).unwrap_or_default();
309
1
    if elements.is_empty() {
310
1
        return Ok(Rendered::Lines(vec![NO_TRANSACTION_YET.to_string()]));
311
    }
312
    let balances: Vec<(Rational64, String, String)> = elements
313
        .iter()
314
        .map(|el| parse_balance_element(el, raw))
315
        .collect::<Result<_, _>>()?;
316

            
317
    if balances.len() == 1 {
318
        let (r, symbol, name) = &balances[0];
319
        let line = if *r == Rational64::new(0, 1) {
320
            NO_TRANSACTION_YET.to_string()
321
        } else {
322
            format!("{} {} ({})", format_rational(*r), symbol, name)
323
        };
324
        Ok(Rendered::Lines(vec![line]))
325
    } else {
326
        let parts: Vec<String> = balances
327
            .iter()
328
            .map(|(r, symbol, _)| format!("{} {}", format_rational(*r), symbol))
329
            .collect();
330
        Ok(Rendered::Lines(vec![parts.join(", ")]))
331
    }
332
3
}
333

            
334
3
fn parse_balance_element(
335
3
    el: &Value,
336
3
    raw: &str,
337
3
) -> Result<(Rational64, String, String), CommandError> {
338
3
    let inner_str = match el {
339
3
        Value::String(s) => s.as_str(),
340
        _ => {
341
            return Err(CommandError::Command(format!(
342
                "unexpected balance element: {raw}"
343
            )));
344
        }
345
    };
346
3
    let inner = reparse_list(inner_str)
347
3
        .map_err(|e| CommandError::Command(format!("balance element parse: {e}")))?;
348
3
    let num_str = plist_field(&inner, ":value-num")
349
3
        .ok_or_else(|| CommandError::Command(format!("balance missing :value-num: {inner_str}")))?;
350
3
    let denom_str = plist_field(&inner, ":value-denom").ok_or_else(|| {
351
        CommandError::Command(format!("balance missing :value-denom: {inner_str}"))
352
    })?;
353
3
    let symbol = plist_field(&inner, ":symbol")
354
3
        .ok_or_else(|| CommandError::Command(format!("balance missing :symbol: {inner_str}")))?;
355
3
    let name = plist_field(&inner, ":name")
356
3
        .ok_or_else(|| CommandError::Command(format!("balance missing :name: {inner_str}")))?;
357
3
    let num: i64 = num_str
358
3
        .parse()
359
3
        .map_err(|e| CommandError::Command(format!("balance :value-num parse: {e}")))?;
360
3
    let denom: i64 = denom_str
361
3
        .parse()
362
3
        .map_err(|e| CommandError::Command(format!("balance :value-denom parse: {e}")))?;
363
3
    if denom == 0 {
364
1
        return Err(CommandError::Command(format!(
365
1
            "balance :value-denom must be non-zero: {inner_str}"
366
1
        )));
367
2
    }
368
2
    Ok((Rational64::new(num, denom), symbol, name))
369
3
}
370

            
371
3
fn render_transaction_list(value: &Value) -> Result<Rendered, CommandError> {
372
3
    let raw = match value {
373
1
        Value::String(s) => s.as_str(),
374
2
        Value::Nil => return Ok(Rendered::Lines(vec![])),
375
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
376
    };
377
1
    let outer = reparse_list(raw)
378
1
        .map_err(|e| CommandError::Command(format!("transaction list parse: {e}")))?;
379
1
    let Some(elements) = list_to_vec(&outer) else {
380
        return Ok(Rendered::Lines(vec![]));
381
    };
382
1
    let mut rows = Vec::with_capacity(elements.len());
383
1
    for el in &elements {
384
1
        let line = transaction_list_line(el)
385
1
            .ok_or_else(|| CommandError::Command(format!("unexpected transaction shape: {raw}")))?;
386
1
        rows.push(line);
387
    }
388
1
    Ok(Rendered::Lines(rows))
389
3
}
390

            
391
3
fn transaction_list_line(element: &Value) -> Option<String> {
392
3
    let id = plist_field(element, ":id")?;
393
3
    let post_date = plist_field(element, ":post-date")?;
394
3
    let note = plist_field(element, ":note").unwrap_or_default();
395
3
    let label = if note.is_empty() { id } else { note };
396
3
    Some(format!("{label} - {post_date}"))
397
3
}
398

            
399
3
fn render_config_value(value: &Value) -> Result<Rendered, CommandError> {
400
3
    let raw = match value {
401
3
        Value::String(s) => s.as_str(),
402
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
403
    };
404
3
    let parsed = reparse_list(raw)
405
3
        .map_err(|e| CommandError::Command(format!("config-value parse failed: {e}")))?;
406
3
    let extracted = plist_field_raw(&parsed, ":config-value")
407
3
        .ok_or_else(|| CommandError::Command(format!("unexpected config-value reply: {raw}")))?;
408
3
    match extracted {
409
1
        Value::String(s) => Ok(Rendered::Lines(vec![s])),
410
1
        Value::Bytes(b) => Ok(Rendered::Bytes(b)),
411
1
        Value::Nil => Ok(Rendered::Nothing),
412
        other => Ok(Rendered::Lines(vec![nomiscript::format_value(&other)])),
413
    }
414
3
}
415

            
416
7
fn select_renderer(spec: &ChartSpec, chart: &str) -> Rendered {
417
7
    let hint = parse_renderer_hint(chart);
418
7
    let use_kitty = match hint {
419
1
        RendererHint::Kitty => std::io::stdout().is_terminal(),
420
6
        RendererHint::Text => false,
421
        RendererHint::Auto => probe_terminal_kitty(),
422
    };
423
7
    if use_kitty {
424
        let s = render_kitty(spec, KittyOpts::default());
425
        if !s.is_empty() {
426
            return Rendered::Bytes(s.into_bytes());
427
        }
428
7
    }
429
7
    Rendered::Lines(
430
7
        render_text_default(spec)
431
7
            .lines()
432
7
            .map(str::to_string)
433
7
            .collect(),
434
7
    )
435
7
}
436

            
437
2
fn render_report_balance(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
438
2
    let rows = value_to_balance_rows(value)?;
439
2
    let spec = balance_chart(
440
2
        &rows,
441
2
        BalanceChartOpts {
442
2
            kind: parse_chart_shape(chart),
443
2
            top_n: 10,
444
2
            sort_order: SortOrder::MagnitudeDesc,
445
2
        },
446
    );
447
2
    Ok(select_renderer(&spec, chart))
448
2
}
449

            
450
2
fn render_report_activity(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
451
2
    let periods = value_to_activity_periods(value)?;
452
2
    let spec = activity_chart(
453
2
        &periods,
454
2
        ActivityChartOpts {
455
2
            kind: parse_chart_shape(chart),
456
2
            include_net: true,
457
2
        },
458
    );
459
2
    Ok(select_renderer(&spec, chart))
460
2
}
461

            
462
2
fn render_report_breakdown(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
463
2
    let periods = value_to_breakdown_periods(value)?;
464
2
    let spec = breakdown_chart(
465
2
        &periods,
466
2
        BreakdownChartOpts {
467
2
            kind: parse_chart_shape(chart),
468
2
            top_n: 10,
469
2
        },
470
    );
471
2
    Ok(select_renderer(&spec, chart))
472
2
}
473

            
474
278
pub(crate) fn plist_field_raw(value: &Value, key: &str) -> Option<Value> {
475
278
    let items = list_to_vec(value)?;
476
278
    let pos = items
477
278
        .iter()
478
1024
        .position(|v| matches!(v, Value::Symbol(s) if s == key))?;
479
277
    items.get(pos + 1).cloned()
480
278
}
481

            
482
1474
pub(crate) fn plist_field(value: &Value, key: &str) -> Option<String> {
483
1474
    let items = list_to_vec(value)?;
484
1474
    let pos = items
485
1474
        .iter()
486
4098
        .position(|v| matches!(v, Value::Symbol(s) if s == key))?;
487
1248
    let raw = items.get(pos + 1)?;
488
1248
    Some(match raw {
489
1110
        Value::String(s) => s.clone(),
490
138
        other => nomiscript::format_value(other),
491
    })
492
1474
}
493

            
494
#[cfg(test)]
495
mod tests;