Lines
55.1 %
Functions
36.46 %
Branches
100 %
//! Shared eval logic for CLI and TUI surfaces (ADR-0021 Phase 3a).
pub mod convert;
pub mod transaction;
pub use convert::{build_convert_amount_form, parse_scalar_reply};
pub use transaction::{
LogicalSplit, PhysicalSplit, PriceRow, build_create_transaction_form_dated,
build_create_transaction_multi, lower_logical_split, lower_logical_transaction,
};
use chrono::Utc;
use nomiscript::{Value, list_to_vec};
use num_rational::Rational64;
use plotting::{
ChartSpec,
adapters::{
ActivityChartOpts, BalanceChartOpts, BreakdownChartOpts, SortOrder, activity_chart,
balance_chart, breakdown_chart,
},
kitty::{KittyOpts, render_kitty},
text::render_text_default,
use rpc::Session;
use sqlx::types::Uuid;
use std::io::IsTerminal;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::{
CommandError,
render::{RenderError, WireValue, parse_wire, reparse_list},
reports::{
RendererHint, parse_chart_shape, parse_renderer_hint, probe_terminal_kitty,
value_to_activity_periods, value_to_balance_rows, value_to_breakdown_periods,
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
fn next_id() -> u64 {
NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
/// Output of a single [`eval_form`] call, ready for the caller to emit.
pub enum Rendered {
Lines(Vec<String>),
Bytes(Vec<u8>),
Nothing,
/// How the value part of a wire reply is rendered.
pub enum RenderMode {
/// Print the scalar string value (or its formatted form) directly.
Scalar,
/// `pair:Account` list — print each account's `:name`, one per line.
AccountList,
/// `pair:Commodity` list — print each as `{symbol} - {name}`, one per line.
CommodityList,
/// The value is a `(:config-value <v>)` plist string: extract and print `<v>`.
ConfigValue,
/// An i32/bool success flag: print `true` for a non-zero/true value, else `false`.
BoolSuccess,
/// Produce no output (used for mutations whose result carries no output).
Silent,
/// `pair:string` balance list from `get-balances`.
BalanceList,
/// `pair:Transaction` entity list from `list-transactions`.
TransactionList,
/// `pair:SshKey` entity list from `list-ssh-keys`.
SshKeyList,
/// Balance report plist string → chart.
ReportBalance { chart: String },
/// Activity report plist string → chart.
ReportActivity { chart: String },
/// Category breakdown plist string → chart.
ReportBreakdown { chart: String },
/// Escape a string for embedding in a nomiscript s-expression literal.
pub fn escape_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
other => out.push(other),
out
/// Build the nomiscript form for `(create-commodity sym name)`.
pub fn build_create_commodity_form(symbol: &str, name: &str) -> String {
format!(
"(create-commodity {} {})",
escape_str(symbol),
escape_str(name)
)
/// Build the nomiscript form for `(create-account name parent)`.
///
/// The `create-account` native is declared with a fixed arity of TWO string
/// args, and its runtime treats an empty-string parent as "no parent" (a root
/// account). So a root account is `(create-account "name" "")` — both args are
/// always present; omitting the parent fails the compiler arity check.
pub fn build_create_account_form(name: &str, parent: Option<&str>) -> String {
let parent = parent.filter(|p| !p.is_empty()).unwrap_or("");
"(create-account {} {})",
escape_str(name),
escape_str(parent)
/// Build the nomiscript form for `(delete-transaction uuid)`.
pub fn build_delete_transaction_form(id: &str) -> String {
format!("(delete-transaction {})", escape_str(id))
/// Build the nomiscript form for `(set-account-tag uuid name value)`.
pub fn build_set_account_tag_form(id: &str, name: &str, value: &str) -> String {
"(set-account-tag {} {} {})",
escape_str(id),
escape_str(value)
/// Build the nomiscript form for `(set-transaction-tag uuid name value)`.
pub fn build_set_transaction_tag_form(id: &str, name: &str, value: &str) -> String {
"(set-transaction-tag {} {} {})",
fn format_rational(r: Rational64) -> String {
if *r.denom() == 1 {
r.numer().to_string()
} else {
format!("{}/{}", r.numer(), r.denom())
/// Build the nomiscript `(create-transaction "...")` form from CLI args.
/// Thin back-compat wrapper over [`build_create_transaction_form_dated`] that
/// stamps the current time. Callers that need a deterministic date should use
/// the dated variant directly.
pub fn build_create_transaction_form(
from: Uuid,
to: Uuid,
from_currency: Uuid,
to_currency: Uuid,
value: Rational64,
to_amount: Option<Rational64>,
note: Option<&str>,
) -> Result<String, CommandError> {
let ls = LogicalSplit {
from,
to,
from_commodity: from_currency,
to_commodity: to_currency,
value,
to_amount,
build_create_transaction_form_dated(&ls, note, Utc::now())
/// Evaluate `form` via `session`, render the reply according to `mode`.
/// A server-side error is surfaced as `CommandError::Command` and the caller
/// propagates it through the error path (→ stderr + `exit(1)`).
pub async fn eval_form(
session: &mut Session,
form: &str,
mode: RenderMode,
) -> Result<Rendered, CommandError> {
let id = next_id();
let frame = format!("(:id {id} :form {form})");
let wire = session.handle_form(&frame).await;
let wire_value = parse_wire(&wire).map_err(render_error_to_command)?;
let WireValue::Value(value) = wire_value;
match mode {
RenderMode::Scalar => Ok(render_scalar(&value)),
RenderMode::AccountList => render_entity_list(value, account_list_line),
RenderMode::CommodityList => render_entity_list(value, commodity_list_line),
RenderMode::ConfigValue => render_config_value(&value),
RenderMode::BoolSuccess => Ok(Rendered::Lines(vec![value_truthy(&value).to_string()])),
RenderMode::Silent => Ok(Rendered::Nothing),
RenderMode::BalanceList => render_balance_list(&value),
RenderMode::TransactionList => render_transaction_list(&value),
RenderMode::SshKeyList => render_entity_list(value, ssh_key_list_line),
RenderMode::ReportBalance { chart } => render_report_balance(&value, &chart),
RenderMode::ReportActivity { chart } => render_report_activity(&value, &chart),
RenderMode::ReportBreakdown { chart } => render_report_breakdown(&value, &chart),
fn value_truthy(value: &Value) -> bool {
match value {
Value::Bool(b) => *b,
Value::Number(n) => *n != nomiscript::Fraction::from_integer(0),
_ => false,
fn render_error_to_command(e: RenderError) -> CommandError {
match e {
RenderError::Server { code, message } => {
CommandError::Command(format!("[{code}] {message}"))
other => CommandError::Command(other.to_string()),
fn render_scalar(value: &Value) -> Rendered {
Value::String(s) => Rendered::Lines(vec![s.clone()]),
other => Rendered::Lines(vec![nomiscript::format_value(other)]),
fn render_entity_list(
value: Value,
line: fn(&Value) -> Option<String>,
let raw = match &value {
Value::String(s) => s.as_str(),
Value::Nil => return Ok(Rendered::Lines(vec![])),
other => return Ok(Rendered::Lines(vec![nomiscript::format_value(other)])),
let reparsed =
reparse_list(raw).map_err(|e| CommandError::Command(format!("list render failed: {e}")))?;
let Some(elements) = list_to_vec(&reparsed) else {
return Ok(Rendered::Lines(vec![]));
let mut rows = Vec::with_capacity(elements.len());
for element in &elements {
let rendered = line(element)
.ok_or_else(|| CommandError::Command(format!("unexpected entity shape: {raw}")))?;
rows.push(rendered);
Ok(Rendered::Lines(rows))
fn account_list_line(element: &Value) -> Option<String> {
plist_field(element, ":name")
fn commodity_list_line(element: &Value) -> Option<String> {
let symbol = plist_field(element, ":symbol")?;
let name = plist_field(element, ":name")?;
Some(format!("{symbol} - {name}"))
fn ssh_key_list_line(element: &Value) -> Option<String> {
let fingerprint = plist_field(element, ":fingerprint")?;
let key_type = plist_field(element, ":key-type")?;
let created_at = plist_field(element, ":created-at")?;
let name = plist_field(element, ":name").unwrap_or_default();
let last_used_at = plist_field(element, ":last-used-at").unwrap_or_default();
let label = if name.is_empty() {
fingerprint.clone()
name
let used = if last_used_at.is_empty() {
"never".to_string()
last_used_at
Some(format!(
"{label} [{key_type}] {fingerprint} created:{created_at} last-used:{used}"
))
/// Legacy sentinel for a no-splits / missing-account / zero-balance account:
/// the old balance path emitted `(0, "No transaction yet", "NONE")` rendered as
/// `"{balance} {symbol} ({name})"` — i.e. `0 NONE (No transaction yet)`.
const NO_TRANSACTION_YET: &str = "0 NONE (No transaction yet)";
fn render_balance_list(value: &Value) -> Result<Rendered, CommandError> {
let raw = match value {
Value::Nil => return Ok(Rendered::Lines(vec![NO_TRANSACTION_YET.to_string()])),
let outer =
reparse_list(raw).map_err(|e| CommandError::Command(format!("balance list parse: {e}")))?;
let elements = list_to_vec(&outer).unwrap_or_default();
if elements.is_empty() {
return Ok(Rendered::Lines(vec![NO_TRANSACTION_YET.to_string()]));
let balances: Vec<(Rational64, String, String)> = elements
.iter()
.map(|el| parse_balance_element(el, raw))
.collect::<Result<_, _>>()?;
if balances.len() == 1 {
let (r, symbol, name) = &balances[0];
let line = if *r == Rational64::new(0, 1) {
NO_TRANSACTION_YET.to_string()
format!("{} {} ({})", format_rational(*r), symbol, name)
Ok(Rendered::Lines(vec![line]))
let parts: Vec<String> = balances
.map(|(r, symbol, _)| format!("{} {}", format_rational(*r), symbol))
.collect();
Ok(Rendered::Lines(vec![parts.join(", ")]))
fn parse_balance_element(
el: &Value,
raw: &str,
) -> Result<(Rational64, String, String), CommandError> {
let inner_str = match el {
_ => {
return Err(CommandError::Command(format!(
"unexpected balance element: {raw}"
)));
let inner = reparse_list(inner_str)
.map_err(|e| CommandError::Command(format!("balance element parse: {e}")))?;
let num_str = plist_field(&inner, ":value-num")
.ok_or_else(|| CommandError::Command(format!("balance missing :value-num: {inner_str}")))?;
let denom_str = plist_field(&inner, ":value-denom").ok_or_else(|| {
CommandError::Command(format!("balance missing :value-denom: {inner_str}"))
})?;
let symbol = plist_field(&inner, ":symbol")
.ok_or_else(|| CommandError::Command(format!("balance missing :symbol: {inner_str}")))?;
let name = plist_field(&inner, ":name")
.ok_or_else(|| CommandError::Command(format!("balance missing :name: {inner_str}")))?;
let num: i64 = num_str
.parse()
.map_err(|e| CommandError::Command(format!("balance :value-num parse: {e}")))?;
let denom: i64 = denom_str
.map_err(|e| CommandError::Command(format!("balance :value-denom parse: {e}")))?;
if denom == 0 {
"balance :value-denom must be non-zero: {inner_str}"
Ok((Rational64::new(num, denom), symbol, name))
fn render_transaction_list(value: &Value) -> Result<Rendered, CommandError> {
let outer = reparse_list(raw)
.map_err(|e| CommandError::Command(format!("transaction list parse: {e}")))?;
let Some(elements) = list_to_vec(&outer) else {
for el in &elements {
let line = transaction_list_line(el)
.ok_or_else(|| CommandError::Command(format!("unexpected transaction shape: {raw}")))?;
rows.push(line);
fn transaction_list_line(element: &Value) -> Option<String> {
let id = plist_field(element, ":id")?;
let post_date = plist_field(element, ":post-date")?;
let note = plist_field(element, ":note").unwrap_or_default();
let label = if note.is_empty() { id } else { note };
Some(format!("{label} - {post_date}"))
fn render_config_value(value: &Value) -> Result<Rendered, CommandError> {
let parsed = reparse_list(raw)
.map_err(|e| CommandError::Command(format!("config-value parse failed: {e}")))?;
let extracted = plist_field_raw(&parsed, ":config-value")
.ok_or_else(|| CommandError::Command(format!("unexpected config-value reply: {raw}")))?;
match extracted {
Value::String(s) => Ok(Rendered::Lines(vec![s])),
Value::Bytes(b) => Ok(Rendered::Bytes(b)),
Value::Nil => Ok(Rendered::Nothing),
other => Ok(Rendered::Lines(vec![nomiscript::format_value(&other)])),
fn select_renderer(spec: &ChartSpec, chart: &str) -> Rendered {
let hint = parse_renderer_hint(chart);
let use_kitty = match hint {
RendererHint::Kitty => std::io::stdout().is_terminal(),
RendererHint::Text => false,
RendererHint::Auto => probe_terminal_kitty(),
if use_kitty {
let s = render_kitty(spec, KittyOpts::default());
if !s.is_empty() {
return Rendered::Bytes(s.into_bytes());
Rendered::Lines(
render_text_default(spec)
.lines()
.map(str::to_string)
.collect(),
fn render_report_balance(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
let rows = value_to_balance_rows(value)?;
let spec = balance_chart(
&rows,
BalanceChartOpts {
kind: parse_chart_shape(chart),
top_n: 10,
sort_order: SortOrder::MagnitudeDesc,
);
Ok(select_renderer(&spec, chart))
fn render_report_activity(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
let periods = value_to_activity_periods(value)?;
let spec = activity_chart(
&periods,
ActivityChartOpts {
include_net: true,
fn render_report_breakdown(value: &Value, chart: &str) -> Result<Rendered, CommandError> {
let periods = value_to_breakdown_periods(value)?;
let spec = breakdown_chart(
BreakdownChartOpts {
pub(crate) fn plist_field_raw(value: &Value, key: &str) -> Option<Value> {
let items = list_to_vec(value)?;
let pos = items
.position(|v| matches!(v, Value::Symbol(s) if s == key))?;
items.get(pos + 1).cloned()
pub(crate) fn plist_field(value: &Value, key: &str) -> Option<String> {
let raw = items.get(pos + 1)?;
Some(match raw {
Value::String(s) => s.clone(),
other => nomiscript::format_value(other),
})
#[cfg(test)]
mod tests;