Skip to main content

tui/
command.rs

1//! Palette command form builder.
2//!
3//! Converts a resolved [`CommandNode`] + parsed args into a nomiscript
4//! form string ready for submission via [`crate::app::App::submit_console_form`].
5//!
6//! Each eval-able leaf maps to a concrete native form; leaves that are
7//! not eval-able (reports, sql, ssh-key, config set) return `None` so
8//! the caller falls back to the existing modal / status behaviour.
9
10use cli_core::eval::escape_str;
11use cli_core::reports::coerce_date_arg;
12use cli_core::{CommandError, CommandNode};
13use sqlx::types::Uuid;
14use std::str::FromStr;
15
16use crate::tabs::reports::ReportKind;
17
18/// Describe a report request resolved from the palette.
19pub struct ReportRequest {
20    pub kind: ReportKind,
21    /// Pre-coerced RFC3339 string (start of day). Empty for Balance.
22    pub from: String,
23    /// Pre-coerced RFC3339 string (end of day). Empty for Balance.
24    pub to: String,
25    pub chart: String,
26}
27
28/// Try to build a `ReportRequest` from a `reports <kind>` palette command.
29///
30/// Balance ignores from/to; Activity and Breakdown require them.
31/// Returns `Ok(None)` when from/to are absent for date-needing reports
32/// (caller should open the params modal instead).
33///
34/// # Errors
35///
36/// Returns an error if date coercion fails for a date that was actually provided.
37pub fn build_report_request(
38    path: &[String],
39    args: &[(String, String)],
40) -> Result<Option<ReportRequest>, CommandError> {
41    let kind = match path_key(path).as_str() {
42        "reports balance" => ReportKind::Balance,
43        "reports activity" => ReportKind::Activity,
44        "reports breakdown" => ReportKind::Breakdown,
45        _ => return Ok(None),
46    };
47    let chart = opt_arg(args, "chart").unwrap_or_else(|| "bar".to_string());
48    if kind == ReportKind::Balance {
49        return Ok(Some(ReportRequest {
50            kind,
51            from: String::new(),
52            to: String::new(),
53            chart,
54        }));
55    }
56    let from_raw = opt_arg(args, "from");
57    let to_raw = opt_arg(args, "to");
58    match (from_raw, to_raw) {
59        (Some(f), Some(t)) => {
60            let from = coerce_date_arg(&f, false)?;
61            let to = coerce_date_arg(&t, true)?;
62            Ok(Some(ReportRequest {
63                kind,
64                from,
65                to,
66                chart,
67            }))
68        }
69        _ => Ok(None),
70    }
71}
72
73/// Attempt to build a nomiscript form for `node` given `path` and `args`.
74///
75/// `path` is the full resolved command path (e.g. `["account", "list"]`),
76/// used to disambiguate leaves that share a name across different parents.
77///
78/// Returns `Ok(Some(form))` for eval-able leaves, `Ok(None)` when the
79/// leaf is intentionally handled outside the eval path (modal / status),
80/// and `Err` when a required arg is missing or malformed.
81pub fn build_form(
82    _node: &CommandNode,
83    path: &[String],
84    args: &[(String, String)],
85) -> Result<Option<String>, CommandError> {
86    // Use the full path for unambiguous dispatch.
87    match path_key(path).as_str() {
88        "version" => Ok(Some("(get-version)".to_string())),
89
90        "account list" => Ok(Some("(list-accounts)".to_string())),
91        "account balance" => {
92            let account = require_uuid(args, "account")?;
93            Ok(Some(format!(
94                "(get-balances {})",
95                escape_str(&account.to_string())
96            )))
97        }
98        // account create / tag open the modal — not eval leaves.
99        "account create" | "account tag" => Ok(None),
100
101        "transaction list" => {
102            let account = opt_uuid(args, "account")?;
103            Ok(Some(format!(
104                "(list-transactions {})",
105                escape_str(&account)
106            )))
107        }
108        // transaction create / tag open the modal — not eval leaves.
109        "transaction create" | "transaction tag" => Ok(None),
110
111        "commodity list" => Ok(Some("(list-commodities)".to_string())),
112        // commodity create / convert open the modal — not eval leaves.
113        "commodity create" | "commodity convert" => Ok(None),
114
115        "config get" => {
116            let name = require_str(args, "name")?;
117            Ok(Some(format!("(get-config {})", escape_str(&name))))
118        }
119        // config set opens the modal — not an eval leaf.
120        "config set" => Ok(None),
121
122        // Reports route through build_report_request; sql/ssh-key have no TUI surface yet.
123        _ => Ok(None),
124    }
125}
126
127/// Flatten `path` into a space-joined key for matching.
128fn path_key(path: &[String]) -> String {
129    path.join(" ")
130}
131
132// ── Arg helpers ───────────────────────────────────────────────────────────────
133
134fn opt_arg(args: &[(String, String)], key: &str) -> Option<String> {
135    args.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
136}
137
138fn require_str(args: &[(String, String)], key: &str) -> Result<String, CommandError> {
139    opt_arg(args, key)
140        .filter(|v| !v.is_empty())
141        .ok_or_else(|| CommandError::Argument(format!("missing required arg: {key}")))
142}
143
144fn require_uuid(args: &[(String, String)], key: &str) -> Result<Uuid, CommandError> {
145    let s = require_str(args, key)?;
146    Uuid::from_str(&s).map_err(|_| CommandError::Argument(format!("invalid UUID for {key}: {s}")))
147}
148
149/// Optional UUID arg: absent/empty yields `""` (the native's "no filter" / "no
150/// parent" form); a present-but-malformed value is a hard arg error so it never
151/// reaches eval as a bogus form.
152fn opt_uuid(args: &[(String, String)], key: &str) -> Result<String, CommandError> {
153    match opt_arg(args, key).filter(|v| !v.is_empty()) {
154        None => Ok(String::new()),
155        Some(s) => Uuid::from_str(&s)
156            .map(|u| u.to_string())
157            .map_err(|_| CommandError::Argument(format!("invalid UUID for {key}: {s}"))),
158    }
159}
160
161#[cfg(test)]
162mod tests;