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

            
7
use cli_core::eval::{build_create_account_form, build_create_commodity_form, escape_str};
8
use cli_core::forms::{
9
    LogicalSplitInput, build_transaction_logical_payload, parse_account_options,
10
    parse_commodity_options,
11
};
12

            
13
use crate::app::App;
14
use crate::app::form_options::{
15
    inject_all_select_options, inject_select_options, inject_splits_account_options,
16
    inject_splits_commodity_options,
17
};
18
use crate::form::FormKind;
19
use crate::modal::Modal;
20
use crate::route::{FormOptionsSource, Route, RouteCtx};
21
use crate::tabs::config::ConfigCell;
22
use crate::tabs::fetch::Fetch;
23
use crate::tabs::nms::format_result;
24
use crate::tabs::nms_eval::DrainItem;
25
use crate::tabs::reports::ReportKind;
26
use crate::view::{Tab, ViewId};
27
use crate::widgets::SelectOption;
28

            
29
impl 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
12
    pub fn submit_console_form(&mut self, form: String) {
62
12
        self.console.push_scrollback(format!("> {form}"));
63
12
        self.console.reset_scroll();
64
12
        if self.console_eval.is_none() {
65
6
            self.console.push_scrollback("console not connected");
66
6
            return;
67
6
        }
68
6
        if self
69
6
            .dispatch_eval(ViewId::Console, RouteCtx::None, form)
70
6
            .is_none()
71
1
        {
72
1
            self.console.push_scrollback("eval worker stopped");
73
5
        }
74
12
    }
75

            
76
2
    pub fn interrupt_console(&self) {
77
2
        if let Some(eval) = &self.console_eval {
78
1
            eval.interrupt();
79
1
        }
80
2
    }
81

            
82
84
    pub fn drain_eval(&mut self) {
83
67
        let items = {
84
84
            let Some(eval) = &mut self.console_eval else {
85
17
                return;
86
            };
87
67
            eval.drain()
88
        };
89
67
        for item in items {
90
8
            match item {
91
1
                DrainItem::Routed { id, wire } => self.route_reply(id, &wire),
92
5
                DrainItem::Unroutable(wire) => {
93
5
                    for line in format_result(&wire) {
94
5
                        self.console.push_scrollback(line);
95
5
                    }
96
                }
97
2
                DrainItem::Notice(text) => {
98
2
                    self.console.push_scrollback(text);
99
2
                    self.fail_inflight_fetches("eval worker stopped");
100
2
                }
101
            }
102
        }
103
84
    }
104

            
105
80
    pub fn drain_console(&mut self) {
106
80
        self.drain_eval();
107
80
    }
108

            
109
4
    pub fn refresh_tab(&mut self, tab: Tab) {
110
4
        let in_flight = match tab {
111
3
            Tab::Accounts => matches!(self.accounts.state, Fetch::Loading { .. }),
112
            Tab::Transactions => matches!(self.transactions.state, Fetch::Loading { .. }),
113
1
            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
4
        if in_flight {
119
1
            return;
120
3
        }
121
3
        match tab {
122
2
            Tab::Accounts => self.accounts.reset(),
123
            Tab::Transactions => self.transactions.reset(),
124
1
            Tab::Commodities => self.commodities.reset(),
125
            Tab::Reports => self.reports.reset(),
126
            Tab::Config => self.config.reset(),
127
            _ => {}
128
        }
129
3
        self.ensure_tab_loaded(tab);
130
4
    }
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
16
    pub(super) fn ensure_tab_loaded(&mut self, tab: Tab) {
250
        match tab {
251
10
            Tab::Accounts | Tab::Transactions | Tab::Commodities => {
252
10
                self.ensure_list_tab_loaded(tab);
253
10
            }
254
            Tab::Config if self.config.is_idle() => {
255
                self.fetch_config();
256
            }
257
6
            _ => {}
258
        }
259
16
    }
260

            
261
10
    fn ensure_list_tab_loaded(&mut self, tab: Tab) {
262
10
        let (form, target) = match tab {
263
6
            Tab::Accounts if matches!(self.accounts.state, Fetch::Idle) => {
264
6
                (self.accounts.request_form.clone(), ViewId::Accounts)
265
            }
266
2
            Tab::Transactions if matches!(self.transactions.state, Fetch::Idle) => {
267
2
                (self.transactions.request_form.clone(), ViewId::Transactions)
268
            }
269
2
            Tab::Commodities if matches!(self.commodities.state, Fetch::Idle) => {
270
2
                (self.commodities.request_form.clone(), ViewId::Commodities)
271
            }
272
            _ => return,
273
        };
274
10
        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
10
        }
282
10
    }
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
18
    pub(super) fn dispatch_eval(
301
18
        &mut self,
302
18
        target: ViewId,
303
18
        ctx: RouteCtx,
304
18
        form: String,
305
18
    ) -> Option<i64> {
306
18
        let eval = self.console_eval.as_mut()?;
307
8
        let id = eval.submit(form)? as i64;
308
7
        self.pending_routes.insert(id, Route { target, ctx });
309
7
        Some(id)
310
18
    }
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
15
    pub(super) fn deliver_reply(&mut self, route: Route, wire: &str) {
316
1
        match route {
317
            Route {
318
3
                ctx: RouteCtx::Mutation { refresh },
319
                ..
320
3
            } => self.deliver_mutation_reply(wire, refresh),
321
            Route {
322
5
                ctx: RouteCtx::FormOptions { seq, source },
323
                ..
324
5
            } => 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
1
                ctx: RouteCtx::Reports { kind, chart },
341
1
            } => self.reports.on_reply(wire, kind, &chart),
342
            Route {
343
                target: ViewId::Config,
344
1
                ctx: RouteCtx::Config { key },
345
1
            } => self.config.on_reply(&key, wire),
346
5
            Route { target, .. } => match target {
347
                ViewId::Console => {
348
2
                    for line in format_result(wire) {
349
2
                        self.console.push_scrollback(line);
350
2
                    }
351
                }
352
1
                ViewId::Accounts => self.accounts.on_reply(wire),
353
1
                ViewId::Transactions => self.transactions.on_reply(wire),
354
1
                ViewId::Commodities => self.commodities.on_reply(wire),
355
                ViewId::Reports | ViewId::Config => {}
356
            },
357
        }
358
15
    }
359

            
360
3
    fn deliver_mutation_reply(&mut self, wire: &str, refresh: ViewId) {
361
3
        match cli_core::render::parse_wire(wire) {
362
2
            Ok(_) => self.refresh_tab(refresh),
363
1
            Err(e) => self.status = format!("mutation failed: {e}"),
364
        }
365
3
    }
366

            
367
5
    fn deliver_form_options_reply(&mut self, wire: &str, seq: u64, source: FormOptionsSource) {
368
5
        if seq != self.form_options_seq {
369
1
            return;
370
4
        }
371
4
        match source {
372
3
            FormOptionsSource::Accounts => self.deliver_account_options(wire),
373
1
            FormOptionsSource::Commodities => self.deliver_commodity_options(wire),
374
        }
375
5
    }
376

            
377
    /// Inject account options into the open account-create OR transaction-create form.
378
3
    fn deliver_account_options(&mut self, wire: &str) {
379
3
        let pairs = parse_account_options(wire);
380
3
        if pairs.is_empty() {
381
            return;
382
3
        }
383
3
        let mut options: Vec<SelectOption> = vec![SelectOption {
384
3
            id: String::new(),
385
3
            label: "(none)".to_string(),
386
3
        }];
387
3
        options.extend(
388
3
            pairs
389
3
                .into_iter()
390
3
                .map(|(id, label)| SelectOption { id, label }),
391
        );
392
        // Try account-create form first (single Select field).
393
3
        if let Some(Modal::Form(form)) = self.overlays.top_mut() {
394
3
            match form.kind {
395
2
                FormKind::AccountCreate => {
396
2
                    inject_select_options(&mut form.fields, &options, false);
397
2
                }
398
1
                FormKind::TransactionCreate | FormKind::TransactionEdit => {
399
1
                    inject_splits_account_options(&mut form.fields, options);
400
1
                }
401
                _ => {}
402
            }
403
        }
404
3
    }
405

            
406
1
    fn deliver_commodity_options(&mut self, wire: &str) {
407
1
        let pairs = parse_commodity_options(wire);
408
1
        if pairs.is_empty() {
409
            return;
410
1
        }
411
1
        let options: Vec<SelectOption> = pairs
412
1
            .into_iter()
413
1
            .map(|(id, label)| SelectOption { id, label })
414
1
            .collect();
415
1
        if let Some(Modal::Form(form)) = self.overlays.top_mut() {
416
1
            match form.kind {
417
1
                FormKind::TransactionCreate | FormKind::TransactionEdit => {
418
1
                    inject_splits_commodity_options(&mut form.fields, options);
419
1
                }
420
                FormKind::CommodityConvert => {
421
                    inject_all_select_options(&mut form.fields, &options);
422
                }
423
                _ => {}
424
            }
425
        }
426
1
    }
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
2
    pub(super) fn route_reply(&mut self, id: i64, wire: &str) {
431
2
        match self.pending_routes.remove(&id) {
432
1
            Some(route) => self.deliver_reply(route, wire),
433
            None => {
434
1
                self.status = format!("[warn] orphan reply id={id}");
435
1
                for line in format_result(wire) {
436
1
                    self.console.push_scrollback(line);
437
1
                }
438
            }
439
        }
440
2
    }
441

            
442
2
    fn fail_inflight_fetches(&mut self, reason: &str) {
443
2
        self.pending_routes.clear();
444
6
        for tab in [
445
2
            &mut self.accounts,
446
2
            &mut self.transactions,
447
2
            &mut self.commodities,
448
2
        ] {
449
6
            if matches!(tab.state, Fetch::Loading { .. }) {
450
1
                tab.state = Fetch::Error(reason.to_string());
451
5
            }
452
        }
453
2
        if matches!(self.reports.state, Fetch::Loading { .. }) {
454
            self.reports.state = Fetch::Error(reason.to_string());
455
2
        }
456
4
        for (_, cell) in &mut self.config.entries {
457
4
            if matches!(cell, ConfigCell::Loading { .. }) {
458
                *cell = ConfigCell::Error(reason.to_string());
459
4
            }
460
        }
461
2
    }
462
}