Skip to main content

tui/form/
validate.rs

1//! Pure validation logic: extracts and validates form buffers into a [`FormSubmit`] payload.
2
3use crate::widgets::Widget;
4
5use super::{Form, FormKind, FormSubmit};
6
7/// Validate the form's buffers and produce a dispatchable submit payload.
8///
9/// Returns `Err` with a human-readable message on any validation failure.
10pub fn validate(form: &Form) -> Result<FormSubmit, String> {
11    match form.kind {
12        FormKind::ConfigSet => {
13            let key = form.field_buffer(0).to_string();
14            let value = form.field_buffer(1).to_string();
15            if key.is_empty() {
16                return Err("config key is required".to_string());
17            }
18            Ok(FormSubmit::ConfigSet { key, value })
19        }
20        FormKind::ReportParams { kind } => {
21            let from_s = form.field_buffer(0).to_string();
22            let to_s = form.field_buffer(1).to_string();
23            let chart_s = form.field_buffer(2).to_string();
24            let chart = if chart_s.is_empty() {
25                "bar".to_string()
26            } else {
27                chart_s
28            };
29            let from = cli_core::reports::coerce_date_arg(&from_s, false)
30                .map_err(|e| format!("invalid from date: {e}"))?;
31            let to = cli_core::reports::coerce_date_arg(&to_s, true)
32                .map_err(|e| format!("invalid to date: {e}"))?;
33            Ok(FormSubmit::Report {
34                kind,
35                from,
36                to,
37                chart,
38            })
39        }
40        FormKind::CommodityCreate => {
41            let symbol = form.field_buffer(0).to_string();
42            let name = form.field_buffer(1).to_string();
43            if symbol.is_empty() {
44                return Err("symbol is required".to_string());
45            }
46            if name.is_empty() {
47                return Err("name is required".to_string());
48            }
49            Ok(FormSubmit::CommodityCreate { symbol, name })
50        }
51        FormKind::AccountCreate => {
52            let name = form.field_buffer(0).to_string();
53            let parent_val = form.field_buffer(1).to_string();
54            if name.is_empty() {
55                return Err("account name is required".to_string());
56            }
57            let parent = if parent_val.is_empty() {
58                None
59            } else {
60                Some(parent_val)
61            };
62            Ok(FormSubmit::AccountCreate { name, parent })
63        }
64        FormKind::TransactionCreate => validate_transaction_create(form),
65        FormKind::TransactionEdit => validate_transaction_edit(form),
66        FormKind::AccountTag => {
67            let name = form.field_buffer(0).to_string();
68            let value = form.field_buffer(1).to_string();
69            if name.is_empty() {
70                return Err("tag name is required".to_string());
71            }
72            let account_id = form
73                .entity_id
74                .clone()
75                .ok_or_else(|| "account id missing".to_string())?;
76            Ok(FormSubmit::AccountTag {
77                account_id,
78                name,
79                value,
80            })
81        }
82        FormKind::TransactionTag => {
83            let name = form.field_buffer(0).to_string();
84            let value = form.field_buffer(1).to_string();
85            if name.is_empty() {
86                return Err("tag name is required".to_string());
87            }
88            let transaction_id = form
89                .entity_id
90                .clone()
91                .ok_or_else(|| "transaction id missing".to_string())?;
92            Ok(FormSubmit::TransactionTag {
93                transaction_id,
94                name,
95                value,
96            })
97        }
98        FormKind::CommodityConvert => validate_commodity_convert(form),
99    }
100}
101
102fn validate_commodity_convert(form: &Form) -> Result<FormSubmit, String> {
103    let amount_str = form.field_buffer(0).to_string();
104    let from = form.field_buffer(1).to_string();
105    let to = form.field_buffer(2).to_string();
106    let from_label = select_display(form, 1).unwrap_or_else(|| from.clone());
107    let to_label = select_display(form, 2).unwrap_or_else(|| to.clone());
108    if from.is_empty() {
109        return Err("from commodity is required".to_string());
110    }
111    if to.is_empty() {
112        return Err("to commodity is required".to_string());
113    }
114    if from == to {
115        return Err("from and to commodities must differ".to_string());
116    }
117    let parsed =
118        cli_core::forms::parse_amount(&amount_str).map_err(|e| format!("invalid amount: {e}"))?;
119    let amount_num_denom = cli_core::forms::amount_token(&parsed);
120    Ok(FormSubmit::CommodityConvert {
121        amount_num_denom,
122        amount_str,
123        from,
124        from_label,
125        to,
126        to_label,
127    })
128}
129
130fn select_display(form: &Form, field_idx: usize) -> Option<String> {
131    match form.fields.get(field_idx).map(|f| &f.widget) {
132        Some(Widget::Select(sw)) => Some(sw.display().to_string()),
133        _ => None,
134    }
135}
136
137fn extract_date_note_splits(
138    form: &Form,
139) -> Result<(String, String, Vec<cli_core::forms::LogicalSplitInput>), String> {
140    use crate::widgets::Widget;
141    let date = form.field_buffer(0).to_string();
142    let note = form.field_buffer(1).to_string();
143
144    let splits_field = form
145        .fields
146        .get(2)
147        .ok_or_else(|| "splits field missing".to_string())?;
148
149    let Widget::Splits(ref sw) = splits_field.widget else {
150        return Err("splits field has wrong widget type".to_string());
151    };
152
153    let rows = sw.rows();
154    if rows.is_empty() {
155        return Err("at least one split is required".to_string());
156    }
157
158    let splits = rows
159        .iter()
160        .enumerate()
161        .map(|(i, row)| {
162            let to_amount_val = row.to_amount.value();
163            let input = cli_core::forms::LogicalSplitInput {
164                from: row.from.value().to_string(),
165                to: row.to.value().to_string(),
166                from_commodity: row.from_commodity.value().to_string(),
167                to_commodity: row.to_commodity.value().to_string(),
168                amount: row.value.value().to_string(),
169                to_amount: if to_amount_val.is_empty() {
170                    None
171                } else {
172                    Some(to_amount_val.to_string())
173                },
174            };
175            cli_core::forms::row_complete(&input)
176                .map_err(|e| format!("split row {}: {e}", i + 1))?;
177            Ok(input)
178        })
179        .collect::<Result<Vec<_>, String>>()?;
180
181    Ok((date, note, splits))
182}
183
184fn validate_transaction_create(form: &Form) -> Result<FormSubmit, String> {
185    let (date, note, splits) = extract_date_note_splits(form)?;
186    Ok(FormSubmit::TransactionCreate { note, date, splits })
187}
188
189fn validate_transaction_edit(form: &Form) -> Result<FormSubmit, String> {
190    let id = form
191        .entity_id
192        .clone()
193        .ok_or_else(|| "transaction id missing".to_string())?;
194    let (date, note, splits) = extract_date_note_splits(form)?;
195    Ok(FormSubmit::TransactionEdit {
196        id,
197        note,
198        date,
199        splits,
200    })
201}
202
203#[cfg(test)]
204mod tests {
205    use super::{Form, FormSubmit, validate};
206    use crate::widgets::{AmountWidget, EditMode, SelectOption, Widget};
207
208    fn commodity_convert_form_with(
209        amount: &str,
210        from_id: &str,
211        from_label: &str,
212        to_id: &str,
213        to_label: &str,
214    ) -> Form {
215        let mut form = Form::commodity_convert(EditMode::Emacs);
216        if let Widget::Amount(ref mut aw) = form.fields[0].widget {
217            *aw = AmountWidget::with_value(EditMode::Emacs, amount);
218        }
219        if let Widget::Select(ref mut sw) = form.fields[1].widget {
220            sw.set_options(vec![SelectOption {
221                id: from_id.to_string(),
222                label: from_label.to_string(),
223            }]);
224        }
225        if let Widget::Select(ref mut sw) = form.fields[2].widget {
226            sw.set_options(vec![SelectOption {
227                id: to_id.to_string(),
228                label: to_label.to_string(),
229            }]);
230        }
231        form
232    }
233
234    #[test]
235    fn commodity_convert_form_has_three_fields() {
236        let form = Form::commodity_convert(EditMode::Emacs);
237        assert_eq!(form.fields.len(), 3);
238        assert_eq!(form.fields[0].label, "Amount");
239        assert_eq!(form.fields[1].label, "From");
240        assert_eq!(form.fields[2].label, "To");
241    }
242
243    #[test]
244    fn validate_commodity_convert_empty_from_is_error() {
245        let mut form = Form::commodity_convert(EditMode::Emacs);
246        if let Widget::Amount(ref mut aw) = form.fields[0].widget {
247            *aw = AmountWidget::with_value(EditMode::Emacs, "100");
248        }
249        let err = validate(&form).unwrap_err();
250        assert!(err.contains("from commodity is required"), "got: {err}");
251    }
252
253    #[test]
254    fn validate_commodity_convert_empty_to_is_error() {
255        let form = commodity_convert_form_with("100", "uuid-from", "From", "", "");
256        let err = validate(&form).unwrap_err();
257        assert!(err.contains("to commodity is required"), "got: {err}");
258    }
259
260    #[test]
261    fn validate_commodity_convert_from_equals_to_is_error() {
262        let same = "550e8400-e29b-41d4-a716-446655440001";
263        let form = commodity_convert_form_with("100", same, "USD", same, "USD");
264        let err = validate(&form).unwrap_err();
265        assert!(err.contains("must differ"), "got: {err}");
266    }
267
268    #[test]
269    fn validate_commodity_convert_bad_amount_is_error() {
270        let form =
271            commodity_convert_form_with("not-a-number", "uuid-from", "From", "uuid-to", "To");
272        let err = validate(&form).unwrap_err();
273        assert!(err.contains("invalid amount"), "got: {err}");
274    }
275
276    #[test]
277    fn validate_commodity_convert_signed_and_zero_amounts_accepted() {
278        // The native handles zero and signed amounts; the form must not be stricter.
279        for (amount, expected) in [("0", "0"), ("-9/2", "-9/2"), ("-100", "-100")] {
280            let form = commodity_convert_form_with(amount, "uuid-a", "A", "uuid-b", "B");
281            match validate(&form).unwrap() {
282                FormSubmit::CommodityConvert {
283                    amount_num_denom, ..
284                } => assert_eq!(amount_num_denom, expected, "for input {amount}"),
285                other => panic!("expected CommodityConvert, got: {other:?}"),
286            }
287        }
288    }
289
290    #[test]
291    fn validate_commodity_convert_good_input_fractional() {
292        let from_id = "550e8400-e29b-41d4-a716-446655440001";
293        let to_id = "550e8400-e29b-41d4-a716-446655440002";
294        let form = commodity_convert_form_with("9/2", from_id, "USD", to_id, "EUR");
295        match validate(&form).unwrap() {
296            FormSubmit::CommodityConvert {
297                amount_num_denom,
298                amount_str,
299                from,
300                from_label,
301                to,
302                to_label,
303            } => {
304                assert_eq!(amount_num_denom, "9/2");
305                assert_eq!(amount_str, "9/2");
306                assert_eq!(from, from_id);
307                assert_eq!(from_label, "USD");
308                assert_eq!(to, to_id);
309                assert_eq!(to_label, "EUR");
310            }
311            other => panic!("expected CommodityConvert, got: {other:?}"),
312        }
313    }
314
315    #[test]
316    fn validate_commodity_convert_integer_amount_formats_without_denom() {
317        let form = commodity_convert_form_with("100", "uuid-a", "A", "uuid-b", "B");
318        match validate(&form).unwrap() {
319            FormSubmit::CommodityConvert {
320                amount_num_denom, ..
321            } => assert_eq!(amount_num_denom, "100"),
322            other => panic!("expected CommodityConvert, got: {other:?}"),
323        }
324    }
325
326    #[test]
327    fn validate_commodity_convert_decimal_amount_becomes_ratio() {
328        let form = commodity_convert_form_with("1.5", "uuid-a", "A", "uuid-b", "B");
329        match validate(&form).unwrap() {
330            FormSubmit::CommodityConvert {
331                amount_num_denom, ..
332            } => assert_eq!(amount_num_denom, "3/2"),
333            other => panic!("expected CommodityConvert, got: {other:?}"),
334        }
335    }
336}