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
2
fn next_id() -> u64 {
40
2
    NEXT_ID.fetch_add(1, Ordering::Relaxed)
41
2
}
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
pub async fn eval_form(
182
    session: &mut Session,
183
    form: &str,
184
    mode: RenderMode,
185
) -> Result<Rendered, CommandError> {
186
    let id = next_id();
187
    let frame = format!("(:id {id} :form {form})");
188
    let wire = session.handle_form(&frame).await;
189
    let wire_value = parse_wire(&wire).map_err(render_error_to_command)?;
190
    let WireValue::Value(value) = wire_value;
191
    match mode {
192
        RenderMode::Scalar => Ok(render_scalar(&value)),
193
        RenderMode::AccountList => render_entity_list(value, account_list_line),
194
        RenderMode::CommodityList => render_entity_list(value, commodity_list_line),
195
        RenderMode::ConfigValue => render_config_value(&value),
196
        RenderMode::BoolSuccess => Ok(Rendered::Lines(vec![value_truthy(&value).to_string()])),
197
        RenderMode::Silent => Ok(Rendered::Nothing),
198
        RenderMode::BalanceList => render_balance_list(&value),
199
        RenderMode::TransactionList => render_transaction_list(&value),
200
        RenderMode::SshKeyList => render_entity_list(value, ssh_key_list_line),
201
        RenderMode::ReportBalance { chart } => render_report_balance(&value, &chart),
202
        RenderMode::ReportActivity { chart } => render_report_activity(&value, &chart),
203
        RenderMode::ReportBreakdown { chart } => render_report_breakdown(&value, &chart),
204
    }
205
}
206

            
207
4
fn value_truthy(value: &Value) -> bool {
208
4
    match value {
209
2
        Value::Bool(b) => *b,
210
2
        Value::Number(n) => *n != nomiscript::Fraction::from_integer(0),
211
        _ => false,
212
    }
213
4
}
214

            
215
1
fn render_error_to_command(e: RenderError) -> CommandError {
216
1
    match e {
217
1
        RenderError::Server { code, message } => {
218
1
            CommandError::Command(format!("[{code}] {message}"))
219
        }
220
        other => CommandError::Command(other.to_string()),
221
    }
222
1
}
223

            
224
fn render_scalar(value: &Value) -> Rendered {
225
    match value {
226
        Value::String(s) => Rendered::Lines(vec![s.clone()]),
227
        other => Rendered::Lines(vec![nomiscript::format_value(other)]),
228
    }
229
}
230

            
231
fn render_entity_list(
232
    value: Value,
233
    line: fn(&Value) -> Option<String>,
234
) -> Result<Rendered, CommandError> {
235
    let raw = match &value {
236
        Value::String(s) => s.as_str(),
237
        Value::Nil => return Ok(Rendered::Lines(vec![])),
238
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
239
    };
240
    let reparsed =
241
        reparse_list(raw).map_err(|e| CommandError::Command(format!("list render failed: {e}")))?;
242
    let Some(elements) = list_to_vec(&reparsed) else {
243
        return Ok(Rendered::Lines(vec![]));
244
    };
245
    let mut rows = Vec::with_capacity(elements.len());
246
    for element in &elements {
247
        let rendered = line(element)
248
            .ok_or_else(|| CommandError::Command(format!("unexpected entity shape: {raw}")))?;
249
        rows.push(rendered);
250
    }
251
    Ok(Rendered::Lines(rows))
252
}
253

            
254
4
fn account_list_line(element: &Value) -> Option<String> {
255
4
    plist_field(element, ":name")
256
4
}
257

            
258
1
fn commodity_list_line(element: &Value) -> Option<String> {
259
1
    let symbol = plist_field(element, ":symbol")?;
260
1
    let name = plist_field(element, ":name")?;
261
1
    Some(format!("{symbol} - {name}"))
262
1
}
263

            
264
5
fn ssh_key_list_line(element: &Value) -> Option<String> {
265
5
    let fingerprint = plist_field(element, ":fingerprint")?;
266
4
    let key_type = plist_field(element, ":key-type")?;
267
3
    let created_at = plist_field(element, ":created-at")?;
268
2
    let name = plist_field(element, ":name").unwrap_or_default();
269
2
    let last_used_at = plist_field(element, ":last-used-at").unwrap_or_default();
270
2
    let label = if name.is_empty() {
271
1
        fingerprint.clone()
272
    } else {
273
1
        name
274
    };
275
2
    let used = if last_used_at.is_empty() {
276
1
        "never".to_string()
277
    } else {
278
1
        last_used_at
279
    };
280
2
    Some(format!(
281
2
        "{label}  [{key_type}]  {fingerprint}  created:{created_at}  last-used:{used}"
282
2
    ))
283
5
}
284

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

            
290
1
fn render_balance_list(value: &Value) -> Result<Rendered, CommandError> {
291
1
    let raw = match value {
292
1
        Value::String(s) => s.as_str(),
293
        Value::Nil => return Ok(Rendered::Lines(vec![NO_TRANSACTION_YET.to_string()])),
294
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
295
    };
296
1
    let outer =
297
1
        reparse_list(raw).map_err(|e| CommandError::Command(format!("balance list parse: {e}")))?;
298
1
    let elements = list_to_vec(&outer).unwrap_or_default();
299
1
    if elements.is_empty() {
300
1
        return Ok(Rendered::Lines(vec![NO_TRANSACTION_YET.to_string()]));
301
    }
302
    let balances: Vec<(Rational64, String, String)> = elements
303
        .iter()
304
        .map(|el| parse_balance_element(el, raw))
305
        .collect::<Result<_, _>>()?;
306

            
307
    if balances.len() == 1 {
308
        let (r, symbol, name) = &balances[0];
309
        let line = if *r == Rational64::new(0, 1) {
310
            NO_TRANSACTION_YET.to_string()
311
        } else {
312
            format!("{} {} ({})", format_rational(*r), symbol, name)
313
        };
314
        Ok(Rendered::Lines(vec![line]))
315
    } else {
316
        let parts: Vec<String> = balances
317
            .iter()
318
            .map(|(r, symbol, _)| format!("{} {}", format_rational(*r), symbol))
319
            .collect();
320
        Ok(Rendered::Lines(vec![parts.join(", ")]))
321
    }
322
1
}
323

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

            
361
fn render_transaction_list(value: &Value) -> Result<Rendered, CommandError> {
362
    let raw = match value {
363
        Value::String(s) => s.as_str(),
364
        Value::Nil => return Ok(Rendered::Lines(vec![])),
365
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
366
    };
367
    let outer = reparse_list(raw)
368
        .map_err(|e| CommandError::Command(format!("transaction list parse: {e}")))?;
369
    let Some(elements) = list_to_vec(&outer) else {
370
        return Ok(Rendered::Lines(vec![]));
371
    };
372
    let mut rows = Vec::with_capacity(elements.len());
373
    for el in &elements {
374
        let line = transaction_list_line(el)
375
            .ok_or_else(|| CommandError::Command(format!("unexpected transaction shape: {raw}")))?;
376
        rows.push(line);
377
    }
378
    Ok(Rendered::Lines(rows))
379
}
380

            
381
2
fn transaction_list_line(element: &Value) -> Option<String> {
382
2
    let id = plist_field(element, ":id")?;
383
2
    let post_date = plist_field(element, ":post-date")?;
384
2
    let note = plist_field(element, ":note").unwrap_or_default();
385
2
    let label = if note.is_empty() { id } else { note };
386
2
    Some(format!("{label} - {post_date}"))
387
2
}
388

            
389
fn render_config_value(value: &Value) -> Result<Rendered, CommandError> {
390
    let raw = match value {
391
        Value::String(s) => s.as_str(),
392
        other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
393
    };
394
    let parsed = reparse_list(raw)
395
        .map_err(|e| CommandError::Command(format!("config-value parse failed: {e}")))?;
396
    let extracted = plist_field_raw(&parsed, ":config-value")
397
        .ok_or_else(|| CommandError::Command(format!("unexpected config-value reply: {raw}")))?;
398
    match extracted {
399
        Value::String(s) => Ok(Rendered::Lines(vec![s])),
400
        Value::Bytes(b) => Ok(Rendered::Bytes(b)),
401
        Value::Nil => Ok(Rendered::Nothing),
402
        other => Ok(Rendered::Lines(vec![nomiscript::format_value(&other)])),
403
    }
404
}
405

            
406
1
fn select_renderer(spec: &ChartSpec, chart: &str) -> Rendered {
407
1
    let hint = parse_renderer_hint(chart);
408
1
    let use_kitty = match hint {
409
1
        RendererHint::Kitty => std::io::stdout().is_terminal(),
410
        RendererHint::Text => false,
411
        RendererHint::Auto => probe_terminal_kitty(),
412
    };
413
1
    if use_kitty {
414
        let s = render_kitty(spec, KittyOpts::default());
415
        if !s.is_empty() {
416
            return Rendered::Bytes(s.into_bytes());
417
        }
418
1
    }
419
1
    Rendered::Lines(
420
1
        render_text_default(spec)
421
1
            .lines()
422
1
            .map(str::to_string)
423
1
            .collect(),
424
1
    )
425
1
}
426

            
427
fn render_report_balance(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
428
    let rows = value_to_balance_rows(value)?;
429
    let spec = balance_chart(
430
        &rows,
431
        BalanceChartOpts {
432
            kind: parse_chart_shape(chart),
433
            top_n: 10,
434
            sort_order: SortOrder::MagnitudeDesc,
435
        },
436
    );
437
    Ok(select_renderer(&spec, chart))
438
}
439

            
440
fn render_report_activity(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
441
    let periods = value_to_activity_periods(value)?;
442
    let spec = activity_chart(
443
        &periods,
444
        ActivityChartOpts {
445
            kind: parse_chart_shape(chart),
446
            include_net: true,
447
        },
448
    );
449
    Ok(select_renderer(&spec, chart))
450
}
451

            
452
fn render_report_breakdown(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
453
    let periods = value_to_breakdown_periods(value)?;
454
    let spec = breakdown_chart(
455
        &periods,
456
        BreakdownChartOpts {
457
            kind: parse_chart_shape(chart),
458
            top_n: 10,
459
        },
460
    );
461
    Ok(select_renderer(&spec, chart))
462
}
463

            
464
269
pub(crate) fn plist_field_raw(value: &Value, key: &str) -> Option<Value> {
465
269
    let items = list_to_vec(value)?;
466
269
    let pos = items
467
269
        .iter()
468
1015
        .position(|v| matches!(v, Value::Symbol(s) if s == key))?;
469
268
    items.get(pos + 1).cloned()
470
269
}
471

            
472
1463
pub(crate) fn plist_field(value: &Value, key: &str) -> Option<String> {
473
1463
    let items = list_to_vec(value)?;
474
1463
    let pos = items
475
1463
        .iter()
476
4060
        .position(|v| matches!(v, Value::Symbol(s) if s == key))?;
477
1238
    let raw = items.get(pos + 1)?;
478
1238
    Some(match raw {
479
1100
        Value::String(s) => s.clone(),
480
138
        other => nomiscript::format_value(other),
481
    })
482
1463
}
483

            
484
#[cfg(test)]
485
mod tests;