Skip to main content

tui/app/
eval.rs

1//! Eval bridge and routing logic for `App`.
2//!
3//! Methods that touch `console_eval`, submit nomiscript forms, route replies,
4//! and keep the tab-fetch state machines in sync all live here so `app.rs`
5//! stays focused on pure state shape and navigation.
6
7use cli_core::eval::{build_create_account_form, build_create_commodity_form, escape_str};
8use cli_core::forms::{
9    LogicalSplitInput, build_transaction_logical_payload, parse_account_options,
10    parse_commodity_options,
11};
12
13use crate::app::App;
14use crate::app::form_options::{
15    inject_all_select_options, inject_select_options, inject_splits_account_options,
16    inject_splits_commodity_options,
17};
18use crate::form::FormKind;
19use crate::modal::Modal;
20use crate::route::{FormOptionsSource, Route, RouteCtx};
21use crate::tabs::config::ConfigCell;
22use crate::tabs::fetch::Fetch;
23use crate::tabs::nms::format_result;
24use crate::tabs::nms_eval::DrainItem;
25use crate::tabs::reports::ReportKind;
26use crate::view::{Tab, ViewId};
27use crate::widgets::SelectOption;
28
29impl App {
30    /// Submit a report fetch for the given kind + date range + chart shape.
31    pub fn fetch_report(&mut self, kind: ReportKind, from: &str, to: &str, chart: &str) {
32        let form = match kind {
33            ReportKind::Balance => "(balance-report)".to_string(),
34            ReportKind::Activity => {
35                format!("(activity-report {} {})", escape_str(from), escape_str(to))
36            }
37            ReportKind::Breakdown => {
38                format!(
39                    "(category-breakdown {} {})",
40                    escape_str(from),
41                    escape_str(to)
42                )
43            }
44        };
45        if self.console_eval.is_none() {
46            self.status = "console not connected".to_string();
47            return;
48        }
49        if let Some(id) = self.dispatch_eval(
50            ViewId::Reports,
51            RouteCtx::Reports {
52                kind,
53                chart: chart.to_string(),
54            },
55            form,
56        ) {
57            self.reports.set_loading(id, kind, chart);
58        }
59    }
60
61    pub fn submit_console_form(&mut self, form: String) {
62        self.console.push_scrollback(format!("> {form}"));
63        self.console.reset_scroll();
64        if self.console_eval.is_none() {
65            self.console.push_scrollback("console not connected");
66            return;
67        }
68        if self
69            .dispatch_eval(ViewId::Console, RouteCtx::None, form)
70            .is_none()
71        {
72            self.console.push_scrollback("eval worker stopped");
73        }
74    }
75
76    pub fn interrupt_console(&self) {
77        if let Some(eval) = &self.console_eval {
78            eval.interrupt();
79        }
80    }
81
82    pub fn drain_eval(&mut self) {
83        let items = {
84            let Some(eval) = &mut self.console_eval else {
85                return;
86            };
87            eval.drain()
88        };
89        for item in items {
90            match item {
91                DrainItem::Routed { id, wire } => self.route_reply(id, &wire),
92                DrainItem::Unroutable(wire) => {
93                    for line in format_result(&wire) {
94                        self.console.push_scrollback(line);
95                    }
96                }
97                DrainItem::Notice(text) => {
98                    self.console.push_scrollback(text);
99                    self.fail_inflight_fetches("eval worker stopped");
100                }
101            }
102        }
103    }
104
105    pub fn drain_console(&mut self) {
106        self.drain_eval();
107    }
108
109    pub fn refresh_tab(&mut self, tab: Tab) {
110        let in_flight = match tab {
111            Tab::Accounts => matches!(self.accounts.state, Fetch::Loading { .. }),
112            Tab::Transactions => matches!(self.transactions.state, Fetch::Loading { .. }),
113            Tab::Commodities => matches!(self.commodities.state, Fetch::Loading { .. }),
114            Tab::Reports => matches!(self.reports.state, Fetch::Loading { .. }),
115            Tab::Config => self.config.any_loading(),
116            _ => false,
117        };
118        if in_flight {
119            return;
120        }
121        match tab {
122            Tab::Accounts => self.accounts.reset(),
123            Tab::Transactions => self.transactions.reset(),
124            Tab::Commodities => self.commodities.reset(),
125            Tab::Reports => self.reports.reset(),
126            Tab::Config => self.config.reset(),
127            _ => {}
128        }
129        self.ensure_tab_loaded(tab);
130    }
131
132    pub fn fetch_config(&mut self) {
133        if self.console_eval.is_none() {
134            return;
135        }
136        let keys: Vec<String> = self
137            .config
138            .entries
139            .iter()
140            .filter(|(_, c)| matches!(c, ConfigCell::Unset))
141            .map(|(k, _)| k.clone())
142            .collect();
143        for key in keys {
144            let form = format!("(get-config {})", escape_str(&key));
145            if let Some(id) =
146                self.dispatch_eval(ViewId::Config, RouteCtx::Config { key: key.clone() }, form)
147            {
148                self.config.set_loading(&key, id);
149            }
150        }
151    }
152
153    pub fn submit_config_set(&mut self, key: &str, value: &str) -> bool {
154        let form = crate::tabs::config::build_set_config_form(key, value);
155        if self.console_eval.is_none() {
156            self.status = "console not connected".to_string();
157            return false;
158        }
159        match self.dispatch_eval(ViewId::Console, RouteCtx::None, form) {
160            Some(_) => {
161                self.refetch_config_key(key);
162                true
163            }
164            None => {
165                self.status = "eval worker stopped".to_string();
166                false
167            }
168        }
169    }
170
171    /// Submit a `create-commodity` mutation. Returns `false` only if the eval worker has stopped.
172    pub fn submit_commodity_create(&mut self, symbol: &str, name: &str) -> bool {
173        let form = build_create_commodity_form(symbol, name);
174        if self.console_eval.is_none() {
175            self.status = "console not connected".to_string();
176            return false;
177        }
178        match self.dispatch_eval(
179            ViewId::Commodities,
180            RouteCtx::Mutation {
181                refresh: ViewId::Commodities,
182            },
183            form,
184        ) {
185            Some(_) => true,
186            None => {
187                self.status = "eval worker stopped".to_string();
188                false
189            }
190        }
191    }
192
193    /// Submit a `create-account` mutation. Returns `false` only if the eval worker has stopped.
194    pub fn submit_account_create(&mut self, name: &str, parent: Option<&str>) -> bool {
195        let form = build_create_account_form(name, parent);
196        if self.console_eval.is_none() {
197            self.status = "console not connected".to_string();
198            return false;
199        }
200        match self.dispatch_eval(
201            ViewId::Accounts,
202            RouteCtx::Mutation {
203                refresh: ViewId::Accounts,
204            },
205            form,
206        ) {
207            Some(_) => true,
208            None => {
209                self.status = "eval worker stopped".to_string();
210                false
211            }
212        }
213    }
214
215    /// Submit a `create-transaction-logical` mutation. Returns `false` only if the eval worker
216    /// has stopped or the payload fails to build.
217    pub fn submit_transaction_create(
218        &mut self,
219        splits: &[LogicalSplitInput],
220        note: &str,
221        date: &str,
222    ) -> bool {
223        let form = match build_transaction_logical_payload(splits, note, date) {
224            Ok(f) => f,
225            Err(e) => {
226                self.status = format!("transaction payload error: {e}");
227                return false;
228            }
229        };
230        if self.console_eval.is_none() {
231            self.status = "console not connected".to_string();
232            return false;
233        }
234        match self.dispatch_eval(
235            ViewId::Transactions,
236            RouteCtx::Mutation {
237                refresh: ViewId::Transactions,
238            },
239            form,
240        ) {
241            Some(_) => true,
242            None => {
243                self.status = "eval worker stopped".to_string();
244                false
245            }
246        }
247    }
248
249    pub(super) fn ensure_tab_loaded(&mut self, tab: Tab) {
250        match tab {
251            Tab::Accounts | Tab::Transactions | Tab::Commodities => {
252                self.ensure_list_tab_loaded(tab);
253            }
254            Tab::Config if self.config.is_idle() => {
255                self.fetch_config();
256            }
257            _ => {}
258        }
259    }
260
261    fn ensure_list_tab_loaded(&mut self, tab: Tab) {
262        let (form, target) = match tab {
263            Tab::Accounts if matches!(self.accounts.state, Fetch::Idle) => {
264                (self.accounts.request_form.clone(), ViewId::Accounts)
265            }
266            Tab::Transactions if matches!(self.transactions.state, Fetch::Idle) => {
267                (self.transactions.request_form.clone(), ViewId::Transactions)
268            }
269            Tab::Commodities if matches!(self.commodities.state, Fetch::Idle) => {
270                (self.commodities.request_form.clone(), ViewId::Commodities)
271            }
272            _ => return,
273        };
274        if let Some(id) = self.dispatch_eval(target, RouteCtx::None, form) {
275            match tab {
276                Tab::Accounts => self.accounts.set_loading_id(id),
277                Tab::Transactions => self.transactions.set_loading_id(id),
278                Tab::Commodities => self.commodities.set_loading_id(id),
279                _ => {}
280            }
281        }
282    }
283
284    fn refetch_config_key(&mut self, key: &str) {
285        let form = format!("(get-config {})", escape_str(key));
286        if let Some(id) = self.dispatch_eval(
287            ViewId::Config,
288            RouteCtx::Config {
289                key: key.to_string(),
290            },
291            form,
292        ) {
293            self.config.set_loading(key, id);
294        }
295    }
296
297    /// Submit `form` to the eval worker, insert a `Route`, and return the id.
298    ///
299    /// Returns `None` when there is no attached eval or the worker has stopped.
300    pub(super) fn dispatch_eval(
301        &mut self,
302        target: ViewId,
303        ctx: RouteCtx,
304        form: String,
305    ) -> Option<i64> {
306        let eval = self.console_eval.as_mut()?;
307        let id = eval.submit(form)? as i64;
308        self.pending_routes.insert(id, Route { target, ctx });
309        Some(id)
310    }
311
312    /// Dispatch a completed reply. A single match handles every `RouteCtx` in
313    /// one place — `route.ctx` is read exactly once, never re-inspected inside a
314    /// nested target match.
315    pub(super) fn deliver_reply(&mut self, route: Route, wire: &str) {
316        match route {
317            Route {
318                ctx: RouteCtx::Mutation { refresh },
319                ..
320            } => self.deliver_mutation_reply(wire, refresh),
321            Route {
322                ctx: RouteCtx::FormOptions { seq, source },
323                ..
324            } => self.deliver_form_options_reply(wire, seq, source),
325            Route {
326                ctx: RouteCtx::TransactionEdit,
327                ..
328            } => self.deliver_transaction_edit_reply(wire),
329            Route {
330                ctx:
331                    RouteCtx::ConvertQuery {
332                        amount_str,
333                        from_label,
334                        to_label,
335                    },
336                ..
337            } => self.deliver_convert_reply(wire, &amount_str, &from_label, &to_label),
338            Route {
339                target: ViewId::Reports,
340                ctx: RouteCtx::Reports { kind, chart },
341            } => self.reports.on_reply(wire, kind, &chart),
342            Route {
343                target: ViewId::Config,
344                ctx: RouteCtx::Config { key },
345            } => self.config.on_reply(&key, wire),
346            Route { target, .. } => match target {
347                ViewId::Console => {
348                    for line in format_result(wire) {
349                        self.console.push_scrollback(line);
350                    }
351                }
352                ViewId::Accounts => self.accounts.on_reply(wire),
353                ViewId::Transactions => self.transactions.on_reply(wire),
354                ViewId::Commodities => self.commodities.on_reply(wire),
355                ViewId::Reports | ViewId::Config => {}
356            },
357        }
358    }
359
360    fn deliver_mutation_reply(&mut self, wire: &str, refresh: ViewId) {
361        match cli_core::render::parse_wire(wire) {
362            Ok(_) => self.refresh_tab(refresh),
363            Err(e) => self.status = format!("mutation failed: {e}"),
364        }
365    }
366
367    fn deliver_form_options_reply(&mut self, wire: &str, seq: u64, source: FormOptionsSource) {
368        if seq != self.form_options_seq {
369            return;
370        }
371        match source {
372            FormOptionsSource::Accounts => self.deliver_account_options(wire),
373            FormOptionsSource::Commodities => self.deliver_commodity_options(wire),
374        }
375    }
376
377    /// Inject account options into the open account-create OR transaction-create form.
378    fn deliver_account_options(&mut self, wire: &str) {
379        let pairs = parse_account_options(wire);
380        if pairs.is_empty() {
381            return;
382        }
383        let mut options: Vec<SelectOption> = vec![SelectOption {
384            id: String::new(),
385            label: "(none)".to_string(),
386        }];
387        options.extend(
388            pairs
389                .into_iter()
390                .map(|(id, label)| SelectOption { id, label }),
391        );
392        // Try account-create form first (single Select field).
393        if let Some(Modal::Form(form)) = self.overlays.top_mut() {
394            match form.kind {
395                FormKind::AccountCreate => {
396                    inject_select_options(&mut form.fields, &options, false);
397                }
398                FormKind::TransactionCreate | FormKind::TransactionEdit => {
399                    inject_splits_account_options(&mut form.fields, options);
400                }
401                _ => {}
402            }
403        }
404    }
405
406    fn deliver_commodity_options(&mut self, wire: &str) {
407        let pairs = parse_commodity_options(wire);
408        if pairs.is_empty() {
409            return;
410        }
411        let options: Vec<SelectOption> = pairs
412            .into_iter()
413            .map(|(id, label)| SelectOption { id, label })
414            .collect();
415        if let Some(Modal::Form(form)) = self.overlays.top_mut() {
416            match form.kind {
417                FormKind::TransactionCreate | FormKind::TransactionEdit => {
418                    inject_splits_commodity_options(&mut form.fields, options);
419                }
420                FormKind::CommodityConvert => {
421                    inject_all_select_options(&mut form.fields, &options);
422                }
423                _ => {}
424            }
425        }
426    }
427
428    /// Route one drained reply to its destination view, or surface an
429    /// orphan-warn status if the id has no pending route.
430    pub(super) fn route_reply(&mut self, id: i64, wire: &str) {
431        match self.pending_routes.remove(&id) {
432            Some(route) => self.deliver_reply(route, wire),
433            None => {
434                self.status = format!("[warn] orphan reply id={id}");
435                for line in format_result(wire) {
436                    self.console.push_scrollback(line);
437                }
438            }
439        }
440    }
441
442    fn fail_inflight_fetches(&mut self, reason: &str) {
443        self.pending_routes.clear();
444        for tab in [
445            &mut self.accounts,
446            &mut self.transactions,
447            &mut self.commodities,
448        ] {
449            if matches!(tab.state, Fetch::Loading { .. }) {
450                tab.state = Fetch::Error(reason.to_string());
451            }
452        }
453        if matches!(self.reports.state, Fetch::Loading { .. }) {
454            self.reports.state = Fetch::Error(reason.to_string());
455        }
456        for (_, cell) in &mut self.config.entries {
457            if matches!(cell, ConfigCell::Loading { .. }) {
458                *cell = ConfigCell::Error(reason.to_string());
459            }
460        }
461    }
462}