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

            
10
use cli_core::eval::escape_str;
11
use cli_core::reports::coerce_date_arg;
12
use cli_core::{CommandError, CommandNode};
13
use sqlx::types::Uuid;
14
use std::str::FromStr;
15

            
16
use crate::tabs::reports::ReportKind;
17

            
18
/// Describe a report request resolved from the palette.
19
pub 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.
37
8
pub fn build_report_request(
38
8
    path: &[String],
39
8
    args: &[(String, String)],
40
8
) -> Result<Option<ReportRequest>, CommandError> {
41
8
    let kind = match path_key(path).as_str() {
42
8
        "reports balance" => ReportKind::Balance,
43
6
        "reports activity" => ReportKind::Activity,
44
2
        "reports breakdown" => ReportKind::Breakdown,
45
        _ => return Ok(None),
46
    };
47
8
    let chart = opt_arg(args, "chart").unwrap_or_else(|| "bar".to_string());
48
8
    if kind == ReportKind::Balance {
49
2
        return Ok(Some(ReportRequest {
50
2
            kind,
51
2
            from: String::new(),
52
2
            to: String::new(),
53
2
            chart,
54
2
        }));
55
6
    }
56
6
    let from_raw = opt_arg(args, "from");
57
6
    let to_raw = opt_arg(args, "to");
58
6
    match (from_raw, to_raw) {
59
4
        (Some(f), Some(t)) => {
60
4
            let from = coerce_date_arg(&f, false)?;
61
3
            let to = coerce_date_arg(&t, true)?;
62
3
            Ok(Some(ReportRequest {
63
3
                kind,
64
3
                from,
65
3
                to,
66
3
                chart,
67
3
            }))
68
        }
69
2
        _ => Ok(None),
70
    }
71
8
}
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.
81
26
pub fn build_form(
82
26
    _node: &CommandNode,
83
26
    path: &[String],
84
26
    args: &[(String, String)],
85
26
) -> Result<Option<String>, CommandError> {
86
    // Use the full path for unambiguous dispatch.
87
26
    match path_key(path).as_str() {
88
26
        "version" => Ok(Some("(get-version)".to_string())),
89

            
90
23
        "account list" => Ok(Some("(list-accounts)".to_string())),
91
21
        "account balance" => {
92
4
            let account = require_uuid(args, "account")?;
93
1
            Ok(Some(format!(
94
1
                "(get-balances {})",
95
1
                escape_str(&account.to_string())
96
1
            )))
97
        }
98
        // account create / tag open the modal — not eval leaves.
99
17
        "account create" | "account tag" => Ok(None),
100

            
101
14
        "transaction list" => {
102
3
            let account = opt_uuid(args, "account")?;
103
2
            Ok(Some(format!(
104
2
                "(list-transactions {})",
105
2
                escape_str(&account)
106
2
            )))
107
        }
108
        // transaction create / tag open the modal — not eval leaves.
109
11
        "transaction create" | "transaction tag" => Ok(None),
110

            
111
8
        "commodity list" => Ok(Some("(list-commodities)".to_string())),
112
        // commodity create / convert open the modal — not eval leaves.
113
7
        "commodity create" | "commodity convert" => Ok(None),
114

            
115
5
        "config get" => {
116
2
            let name = require_str(args, "name")?;
117
1
            Ok(Some(format!("(get-config {})", escape_str(&name))))
118
        }
119
        // config set opens the modal — not an eval leaf.
120
3
        "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
26
}
126

            
127
/// Flatten `path` into a space-joined key for matching.
128
34
fn path_key(path: &[String]) -> String {
129
34
    path.join(" ")
130
34
}
131

            
132
// ── Arg helpers ───────────────────────────────────────────────────────────────
133

            
134
29
fn opt_arg(args: &[(String, String)], key: &str) -> Option<String> {
135
29
    args.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
136
29
}
137

            
138
6
fn require_str(args: &[(String, String)], key: &str) -> Result<String, CommandError> {
139
6
    opt_arg(args, key)
140
6
        .filter(|v| !v.is_empty())
141
6
        .ok_or_else(|| CommandError::Argument(format!("missing required arg: {key}")))
142
6
}
143

            
144
4
fn require_uuid(args: &[(String, String)], key: &str) -> Result<Uuid, CommandError> {
145
4
    let s = require_str(args, key)?;
146
3
    Uuid::from_str(&s).map_err(|_| CommandError::Argument(format!("invalid UUID for {key}: {s}")))
147
4
}
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.
152
3
fn opt_uuid(args: &[(String, String)], key: &str) -> Result<String, CommandError> {
153
3
    match opt_arg(args, key).filter(|v| !v.is_empty()) {
154
1
        None => Ok(String::new()),
155
2
        Some(s) => Uuid::from_str(&s)
156
2
            .map(|u| u.to_string())
157
2
            .map_err(|_| CommandError::Argument(format!("invalid UUID for {key}: {s}"))),
158
    }
159
3
}
160

            
161
#[cfg(test)]
162
mod tests;