Lines
51.37 %
Functions
32.69 %
Branches
100 %
//! Eval bridge and routing logic for `App`.
//!
//! Methods that touch `console_eval`, submit nomiscript forms, route replies,
//! and keep the tab-fetch state machines in sync all live here so `app.rs`
//! stays focused on pure state shape and navigation.
use cli_core::eval::{build_create_account_form, build_create_commodity_form, escape_str};
use cli_core::forms::{
LogicalSplitInput, build_transaction_logical_payload, parse_account_options,
parse_commodity_options,
};
use crate::app::App;
use crate::app::form_options::{
inject_all_select_options, inject_select_options, inject_splits_account_options,
inject_splits_commodity_options,
use crate::form::FormKind;
use crate::modal::Modal;
use crate::route::{FormOptionsSource, Route, RouteCtx};
use crate::tabs::config::ConfigCell;
use crate::tabs::fetch::Fetch;
use crate::tabs::nms::format_result;
use crate::tabs::nms_eval::DrainItem;
use crate::tabs::reports::ReportKind;
use crate::view::{Tab, ViewId};
use crate::widgets::SelectOption;
impl App {
/// Submit a report fetch for the given kind + date range + chart shape.
pub fn fetch_report(&mut self, kind: ReportKind, from: &str, to: &str, chart: &str) {
let form = match kind {
ReportKind::Balance => "(balance-report)".to_string(),
ReportKind::Activity => {
format!("(activity-report {} {})", escape_str(from), escape_str(to))
}
ReportKind::Breakdown => {
format!(
"(category-breakdown {} {})",
escape_str(from),
escape_str(to)
)
if self.console_eval.is_none() {
self.status = "console not connected".to_string();
return;
if let Some(id) = self.dispatch_eval(
ViewId::Reports,
RouteCtx::Reports {
kind,
chart: chart.to_string(),
},
form,
) {
self.reports.set_loading(id, kind, chart);
pub fn submit_console_form(&mut self, form: String) {
self.console.push_scrollback(format!("> {form}"));
self.console.reset_scroll();
self.console.push_scrollback("console not connected");
if self
.dispatch_eval(ViewId::Console, RouteCtx::None, form)
.is_none()
{
self.console.push_scrollback("eval worker stopped");
pub fn interrupt_console(&self) {
if let Some(eval) = &self.console_eval {
eval.interrupt();
pub fn drain_eval(&mut self) {
let items = {
let Some(eval) = &mut self.console_eval else {
eval.drain()
for item in items {
match item {
DrainItem::Routed { id, wire } => self.route_reply(id, &wire),
DrainItem::Unroutable(wire) => {
for line in format_result(&wire) {
self.console.push_scrollback(line);
DrainItem::Notice(text) => {
self.console.push_scrollback(text);
self.fail_inflight_fetches("eval worker stopped");
pub fn drain_console(&mut self) {
self.drain_eval();
pub fn refresh_tab(&mut self, tab: Tab) {
let in_flight = match tab {
Tab::Accounts => matches!(self.accounts.state, Fetch::Loading { .. }),
Tab::Transactions => matches!(self.transactions.state, Fetch::Loading { .. }),
Tab::Commodities => matches!(self.commodities.state, Fetch::Loading { .. }),
Tab::Reports => matches!(self.reports.state, Fetch::Loading { .. }),
Tab::Config => self.config.any_loading(),
_ => false,
if in_flight {
match tab {
Tab::Accounts => self.accounts.reset(),
Tab::Transactions => self.transactions.reset(),
Tab::Commodities => self.commodities.reset(),
Tab::Reports => self.reports.reset(),
Tab::Config => self.config.reset(),
_ => {}
self.ensure_tab_loaded(tab);
pub fn fetch_config(&mut self) {
let keys: Vec<String> = self
.config
.entries
.iter()
.filter(|(_, c)| matches!(c, ConfigCell::Unset))
.map(|(k, _)| k.clone())
.collect();
for key in keys {
let form = format!("(get-config {})", escape_str(&key));
if let Some(id) =
self.dispatch_eval(ViewId::Config, RouteCtx::Config { key: key.clone() }, form)
self.config.set_loading(&key, id);
pub fn submit_config_set(&mut self, key: &str, value: &str) -> bool {
let form = crate::tabs::config::build_set_config_form(key, value);
return false;
match self.dispatch_eval(ViewId::Console, RouteCtx::None, form) {
Some(_) => {
self.refetch_config_key(key);
true
None => {
self.status = "eval worker stopped".to_string();
false
/// Submit a `create-commodity` mutation. Returns `false` only if the eval worker has stopped.
pub fn submit_commodity_create(&mut self, symbol: &str, name: &str) -> bool {
let form = build_create_commodity_form(symbol, name);
match self.dispatch_eval(
ViewId::Commodities,
RouteCtx::Mutation {
refresh: ViewId::Commodities,
Some(_) => true,
/// Submit a `create-account` mutation. Returns `false` only if the eval worker has stopped.
pub fn submit_account_create(&mut self, name: &str, parent: Option<&str>) -> bool {
let form = build_create_account_form(name, parent);
ViewId::Accounts,
refresh: ViewId::Accounts,
/// Submit a `create-transaction-logical` mutation. Returns `false` only if the eval worker
/// has stopped or the payload fails to build.
pub fn submit_transaction_create(
&mut self,
splits: &[LogicalSplitInput],
note: &str,
date: &str,
) -> bool {
let form = match build_transaction_logical_payload(splits, note, date) {
Ok(f) => f,
Err(e) => {
self.status = format!("transaction payload error: {e}");
ViewId::Transactions,
refresh: ViewId::Transactions,
pub(super) fn ensure_tab_loaded(&mut self, tab: Tab) {
Tab::Accounts | Tab::Transactions | Tab::Commodities => {
self.ensure_list_tab_loaded(tab);
Tab::Config if self.config.is_idle() => {
self.fetch_config();
fn ensure_list_tab_loaded(&mut self, tab: Tab) {
let (form, target) = match tab {
Tab::Accounts if matches!(self.accounts.state, Fetch::Idle) => {
(self.accounts.request_form.clone(), ViewId::Accounts)
Tab::Transactions if matches!(self.transactions.state, Fetch::Idle) => {
(self.transactions.request_form.clone(), ViewId::Transactions)
Tab::Commodities if matches!(self.commodities.state, Fetch::Idle) => {
(self.commodities.request_form.clone(), ViewId::Commodities)
_ => return,
if let Some(id) = self.dispatch_eval(target, RouteCtx::None, form) {
Tab::Accounts => self.accounts.set_loading_id(id),
Tab::Transactions => self.transactions.set_loading_id(id),
Tab::Commodities => self.commodities.set_loading_id(id),
fn refetch_config_key(&mut self, key: &str) {
let form = format!("(get-config {})", escape_str(key));
ViewId::Config,
RouteCtx::Config {
key: key.to_string(),
self.config.set_loading(key, id);
/// Submit `form` to the eval worker, insert a `Route`, and return the id.
///
/// Returns `None` when there is no attached eval or the worker has stopped.
pub(super) fn dispatch_eval(
target: ViewId,
ctx: RouteCtx,
form: String,
) -> Option<i64> {
let eval = self.console_eval.as_mut()?;
let id = eval.submit(form)? as i64;
self.pending_routes.insert(id, Route { target, ctx });
Some(id)
/// Dispatch a completed reply. A single match handles every `RouteCtx` in
/// one place — `route.ctx` is read exactly once, never re-inspected inside a
/// nested target match.
pub(super) fn deliver_reply(&mut self, route: Route, wire: &str) {
match route {
Route {
ctx: RouteCtx::Mutation { refresh },
..
} => self.deliver_mutation_reply(wire, refresh),
ctx: RouteCtx::FormOptions { seq, source },
} => self.deliver_form_options_reply(wire, seq, source),
ctx: RouteCtx::TransactionEdit,
} => self.deliver_transaction_edit_reply(wire),
ctx:
RouteCtx::ConvertQuery {
amount_str,
from_label,
to_label,
} => self.deliver_convert_reply(wire, &amount_str, &from_label, &to_label),
target: ViewId::Reports,
ctx: RouteCtx::Reports { kind, chart },
} => self.reports.on_reply(wire, kind, &chart),
target: ViewId::Config,
ctx: RouteCtx::Config { key },
} => self.config.on_reply(&key, wire),
Route { target, .. } => match target {
ViewId::Console => {
for line in format_result(wire) {
ViewId::Accounts => self.accounts.on_reply(wire),
ViewId::Transactions => self.transactions.on_reply(wire),
ViewId::Commodities => self.commodities.on_reply(wire),
ViewId::Reports | ViewId::Config => {}
fn deliver_mutation_reply(&mut self, wire: &str, refresh: ViewId) {
match cli_core::render::parse_wire(wire) {
Ok(_) => self.refresh_tab(refresh),
Err(e) => self.status = format!("mutation failed: {e}"),
fn deliver_form_options_reply(&mut self, wire: &str, seq: u64, source: FormOptionsSource) {
if seq != self.form_options_seq {
match source {
FormOptionsSource::Accounts => self.deliver_account_options(wire),
FormOptionsSource::Commodities => self.deliver_commodity_options(wire),
/// Inject account options into the open account-create OR transaction-create form.
fn deliver_account_options(&mut self, wire: &str) {
let pairs = parse_account_options(wire);
if pairs.is_empty() {
let mut options: Vec<SelectOption> = vec![SelectOption {
id: String::new(),
label: "(none)".to_string(),
}];
options.extend(
pairs
.into_iter()
.map(|(id, label)| SelectOption { id, label }),
);
// Try account-create form first (single Select field).
if let Some(Modal::Form(form)) = self.overlays.top_mut() {
match form.kind {
FormKind::AccountCreate => {
inject_select_options(&mut form.fields, &options, false);
FormKind::TransactionCreate | FormKind::TransactionEdit => {
inject_splits_account_options(&mut form.fields, options);
fn deliver_commodity_options(&mut self, wire: &str) {
let pairs = parse_commodity_options(wire);
let options: Vec<SelectOption> = pairs
.map(|(id, label)| SelectOption { id, label })
inject_splits_commodity_options(&mut form.fields, options);
FormKind::CommodityConvert => {
inject_all_select_options(&mut form.fields, &options);
/// Route one drained reply to its destination view, or surface an
/// orphan-warn status if the id has no pending route.
pub(super) fn route_reply(&mut self, id: i64, wire: &str) {
match self.pending_routes.remove(&id) {
Some(route) => self.deliver_reply(route, wire),
self.status = format!("[warn] orphan reply id={id}");
fn fail_inflight_fetches(&mut self, reason: &str) {
self.pending_routes.clear();
for tab in [
&mut self.accounts,
&mut self.transactions,
&mut self.commodities,
] {
if matches!(tab.state, Fetch::Loading { .. }) {
tab.state = Fetch::Error(reason.to_string());
if matches!(self.reports.state, Fetch::Loading { .. }) {
self.reports.state = Fetch::Error(reason.to_string());
for (_, cell) in &mut self.config.entries {
if matches!(cell, ConfigCell::Loading { .. }) {
*cell = ConfigCell::Error(reason.to_string());