1
//! Pure validation logic: extracts and validates form buffers into a [`FormSubmit`] payload.
2

            
3
use crate::widgets::Widget;
4

            
5
use 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.
10
31
pub fn validate(form: &Form) -> Result<FormSubmit, String> {
11
31
    match form.kind {
12
        FormKind::ConfigSet => {
13
2
            let key = form.field_buffer(0).to_string();
14
2
            let value = form.field_buffer(1).to_string();
15
2
            if key.is_empty() {
16
1
                return Err("config key is required".to_string());
17
1
            }
18
1
            Ok(FormSubmit::ConfigSet { key, value })
19
        }
20
4
        FormKind::ReportParams { kind } => {
21
4
            let from_s = form.field_buffer(0).to_string();
22
4
            let to_s = form.field_buffer(1).to_string();
23
4
            let chart_s = form.field_buffer(2).to_string();
24
4
            let chart = if chart_s.is_empty() {
25
3
                "bar".to_string()
26
            } else {
27
1
                chart_s
28
            };
29
4
            let from = cli_core::reports::coerce_date_arg(&from_s, false)
30
4
                .map_err(|e| format!("invalid from date: {e}"))?;
31
3
            let to = cli_core::reports::coerce_date_arg(&to_s, true)
32
3
                .map_err(|e| format!("invalid to date: {e}"))?;
33
2
            Ok(FormSubmit::Report {
34
2
                kind,
35
2
                from,
36
2
                to,
37
2
                chart,
38
2
            })
39
        }
40
        FormKind::CommodityCreate => {
41
3
            let symbol = form.field_buffer(0).to_string();
42
3
            let name = form.field_buffer(1).to_string();
43
3
            if symbol.is_empty() {
44
1
                return Err("symbol is required".to_string());
45
2
            }
46
2
            if name.is_empty() {
47
1
                return Err("name is required".to_string());
48
1
            }
49
1
            Ok(FormSubmit::CommodityCreate { symbol, name })
50
        }
51
        FormKind::AccountCreate => {
52
3
            let name = form.field_buffer(0).to_string();
53
3
            let parent_val = form.field_buffer(1).to_string();
54
3
            if name.is_empty() {
55
1
                return Err("account name is required".to_string());
56
2
            }
57
2
            let parent = if parent_val.is_empty() {
58
1
                None
59
            } else {
60
1
                Some(parent_val)
61
            };
62
2
            Ok(FormSubmit::AccountCreate { name, parent })
63
        }
64
4
        FormKind::TransactionCreate => validate_transaction_create(form),
65
1
        FormKind::TransactionEdit => validate_transaction_edit(form),
66
        FormKind::AccountTag => {
67
2
            let name = form.field_buffer(0).to_string();
68
2
            let value = form.field_buffer(1).to_string();
69
2
            if name.is_empty() {
70
1
                return Err("tag name is required".to_string());
71
1
            }
72
1
            let account_id = form
73
1
                .entity_id
74
1
                .clone()
75
1
                .ok_or_else(|| "account id missing".to_string())?;
76
1
            Ok(FormSubmit::AccountTag {
77
1
                account_id,
78
1
                name,
79
1
                value,
80
1
            })
81
        }
82
        FormKind::TransactionTag => {
83
2
            let name = form.field_buffer(0).to_string();
84
2
            let value = form.field_buffer(1).to_string();
85
2
            if name.is_empty() {
86
1
                return Err("tag name is required".to_string());
87
1
            }
88
1
            let transaction_id = form
89
1
                .entity_id
90
1
                .clone()
91
1
                .ok_or_else(|| "transaction id missing".to_string())?;
92
1
            Ok(FormSubmit::TransactionTag {
93
1
                transaction_id,
94
1
                name,
95
1
                value,
96
1
            })
97
        }
98
10
        FormKind::CommodityConvert => validate_commodity_convert(form),
99
    }
100
31
}
101

            
102
10
fn validate_commodity_convert(form: &Form) -> Result<FormSubmit, String> {
103
10
    let amount_str = form.field_buffer(0).to_string();
104
10
    let from = form.field_buffer(1).to_string();
105
10
    let to = form.field_buffer(2).to_string();
106
10
    let from_label = select_display(form, 1).unwrap_or_else(|| from.clone());
107
10
    let to_label = select_display(form, 2).unwrap_or_else(|| to.clone());
108
10
    if from.is_empty() {
109
1
        return Err("from commodity is required".to_string());
110
9
    }
111
9
    if to.is_empty() {
112
1
        return Err("to commodity is required".to_string());
113
8
    }
114
8
    if from == to {
115
1
        return Err("from and to commodities must differ".to_string());
116
7
    }
117
6
    let parsed =
118
7
        cli_core::forms::parse_amount(&amount_str).map_err(|e| format!("invalid amount: {e}"))?;
119
6
    let amount_num_denom = cli_core::forms::amount_token(&parsed);
120
6
    Ok(FormSubmit::CommodityConvert {
121
6
        amount_num_denom,
122
6
        amount_str,
123
6
        from,
124
6
        from_label,
125
6
        to,
126
6
        to_label,
127
6
    })
128
10
}
129

            
130
20
fn select_display(form: &Form, field_idx: usize) -> Option<String> {
131
20
    match form.fields.get(field_idx).map(|f| &f.widget) {
132
20
        Some(Widget::Select(sw)) => Some(sw.display().to_string()),
133
        _ => None,
134
    }
135
20
}
136

            
137
5
fn extract_date_note_splits(
138
5
    form: &Form,
139
5
) -> Result<(String, String, Vec<cli_core::forms::LogicalSplitInput>), String> {
140
    use crate::widgets::Widget;
141
5
    let date = form.field_buffer(0).to_string();
142
5
    let note = form.field_buffer(1).to_string();
143

            
144
5
    let splits_field = form
145
5
        .fields
146
5
        .get(2)
147
5
        .ok_or_else(|| "splits field missing".to_string())?;
148

            
149
5
    let Widget::Splits(ref sw) = splits_field.widget else {
150
        return Err("splits field has wrong widget type".to_string());
151
    };
152

            
153
5
    let rows = sw.rows();
154
5
    if rows.is_empty() {
155
        return Err("at least one split is required".to_string());
156
5
    }
157

            
158
5
    let splits = rows
159
5
        .iter()
160
5
        .enumerate()
161
5
        .map(|(i, row)| {
162
5
            let to_amount_val = row.to_amount.value();
163
5
            let input = cli_core::forms::LogicalSplitInput {
164
5
                from: row.from.value().to_string(),
165
5
                to: row.to.value().to_string(),
166
5
                from_commodity: row.from_commodity.value().to_string(),
167
5
                to_commodity: row.to_commodity.value().to_string(),
168
5
                amount: row.value.value().to_string(),
169
5
                to_amount: if to_amount_val.is_empty() {
170
4
                    None
171
                } else {
172
1
                    Some(to_amount_val.to_string())
173
                },
174
            };
175
5
            cli_core::forms::row_complete(&input)
176
5
                .map_err(|e| format!("split row {}: {e}", i + 1))?;
177
3
            Ok(input)
178
5
        })
179
5
        .collect::<Result<Vec<_>, String>>()?;
180

            
181
3
    Ok((date, note, splits))
182
5
}
183

            
184
4
fn validate_transaction_create(form: &Form) -> Result<FormSubmit, String> {
185
4
    let (date, note, splits) = extract_date_note_splits(form)?;
186
2
    Ok(FormSubmit::TransactionCreate { note, date, splits })
187
4
}
188

            
189
1
fn validate_transaction_edit(form: &Form) -> Result<FormSubmit, String> {
190
1
    let id = form
191
1
        .entity_id
192
1
        .clone()
193
1
        .ok_or_else(|| "transaction id missing".to_string())?;
194
1
    let (date, note, splits) = extract_date_note_splits(form)?;
195
1
    Ok(FormSubmit::TransactionEdit {
196
1
        id,
197
1
        note,
198
1
        date,
199
1
        splits,
200
1
    })
201
1
}
202

            
203
#[cfg(test)]
204
mod tests {
205
    use super::{Form, FormSubmit, validate};
206
    use crate::widgets::{AmountWidget, EditMode, SelectOption, Widget};
207

            
208
9
    fn commodity_convert_form_with(
209
9
        amount: &str,
210
9
        from_id: &str,
211
9
        from_label: &str,
212
9
        to_id: &str,
213
9
        to_label: &str,
214
9
    ) -> Form {
215
9
        let mut form = Form::commodity_convert(EditMode::Emacs);
216
9
        if let Widget::Amount(ref mut aw) = form.fields[0].widget {
217
9
            *aw = AmountWidget::with_value(EditMode::Emacs, amount);
218
9
        }
219
9
        if let Widget::Select(ref mut sw) = form.fields[1].widget {
220
9
            sw.set_options(vec![SelectOption {
221
9
                id: from_id.to_string(),
222
9
                label: from_label.to_string(),
223
9
            }]);
224
9
        }
225
9
        if let Widget::Select(ref mut sw) = form.fields[2].widget {
226
9
            sw.set_options(vec![SelectOption {
227
9
                id: to_id.to_string(),
228
9
                label: to_label.to_string(),
229
9
            }]);
230
9
        }
231
9
        form
232
9
    }
233

            
234
    #[test]
235
1
    fn commodity_convert_form_has_three_fields() {
236
1
        let form = Form::commodity_convert(EditMode::Emacs);
237
1
        assert_eq!(form.fields.len(), 3);
238
1
        assert_eq!(form.fields[0].label, "Amount");
239
1
        assert_eq!(form.fields[1].label, "From");
240
1
        assert_eq!(form.fields[2].label, "To");
241
1
    }
242

            
243
    #[test]
244
1
    fn validate_commodity_convert_empty_from_is_error() {
245
1
        let mut form = Form::commodity_convert(EditMode::Emacs);
246
1
        if let Widget::Amount(ref mut aw) = form.fields[0].widget {
247
1
            *aw = AmountWidget::with_value(EditMode::Emacs, "100");
248
1
        }
249
1
        let err = validate(&form).unwrap_err();
250
1
        assert!(err.contains("from commodity is required"), "got: {err}");
251
1
    }
252

            
253
    #[test]
254
1
    fn validate_commodity_convert_empty_to_is_error() {
255
1
        let form = commodity_convert_form_with("100", "uuid-from", "From", "", "");
256
1
        let err = validate(&form).unwrap_err();
257
1
        assert!(err.contains("to commodity is required"), "got: {err}");
258
1
    }
259

            
260
    #[test]
261
1
    fn validate_commodity_convert_from_equals_to_is_error() {
262
1
        let same = "550e8400-e29b-41d4-a716-446655440001";
263
1
        let form = commodity_convert_form_with("100", same, "USD", same, "USD");
264
1
        let err = validate(&form).unwrap_err();
265
1
        assert!(err.contains("must differ"), "got: {err}");
266
1
    }
267

            
268
    #[test]
269
1
    fn validate_commodity_convert_bad_amount_is_error() {
270
1
        let form =
271
1
            commodity_convert_form_with("not-a-number", "uuid-from", "From", "uuid-to", "To");
272
1
        let err = validate(&form).unwrap_err();
273
1
        assert!(err.contains("invalid amount"), "got: {err}");
274
1
    }
275

            
276
    #[test]
277
1
    fn validate_commodity_convert_signed_and_zero_amounts_accepted() {
278
        // The native handles zero and signed amounts; the form must not be stricter.
279
3
        for (amount, expected) in [("0", "0"), ("-9/2", "-9/2"), ("-100", "-100")] {
280
3
            let form = commodity_convert_form_with(amount, "uuid-a", "A", "uuid-b", "B");
281
3
            match validate(&form).unwrap() {
282
                FormSubmit::CommodityConvert {
283
3
                    amount_num_denom, ..
284
3
                } => assert_eq!(amount_num_denom, expected, "for input {amount}"),
285
                other => panic!("expected CommodityConvert, got: {other:?}"),
286
            }
287
        }
288
1
    }
289

            
290
    #[test]
291
1
    fn validate_commodity_convert_good_input_fractional() {
292
1
        let from_id = "550e8400-e29b-41d4-a716-446655440001";
293
1
        let to_id = "550e8400-e29b-41d4-a716-446655440002";
294
1
        let form = commodity_convert_form_with("9/2", from_id, "USD", to_id, "EUR");
295
1
        match validate(&form).unwrap() {
296
            FormSubmit::CommodityConvert {
297
1
                amount_num_denom,
298
1
                amount_str,
299
1
                from,
300
1
                from_label,
301
1
                to,
302
1
                to_label,
303
            } => {
304
1
                assert_eq!(amount_num_denom, "9/2");
305
1
                assert_eq!(amount_str, "9/2");
306
1
                assert_eq!(from, from_id);
307
1
                assert_eq!(from_label, "USD");
308
1
                assert_eq!(to, to_id);
309
1
                assert_eq!(to_label, "EUR");
310
            }
311
            other => panic!("expected CommodityConvert, got: {other:?}"),
312
        }
313
1
    }
314

            
315
    #[test]
316
1
    fn validate_commodity_convert_integer_amount_formats_without_denom() {
317
1
        let form = commodity_convert_form_with("100", "uuid-a", "A", "uuid-b", "B");
318
1
        match validate(&form).unwrap() {
319
            FormSubmit::CommodityConvert {
320
1
                amount_num_denom, ..
321
1
            } => assert_eq!(amount_num_denom, "100"),
322
            other => panic!("expected CommodityConvert, got: {other:?}"),
323
        }
324
1
    }
325

            
326
    #[test]
327
1
    fn validate_commodity_convert_decimal_amount_becomes_ratio() {
328
1
        let form = commodity_convert_form_with("1.5", "uuid-a", "A", "uuid-b", "B");
329
1
        match validate(&form).unwrap() {
330
            FormSubmit::CommodityConvert {
331
1
                amount_num_denom, ..
332
1
            } => assert_eq!(amount_num_denom, "3/2"),
333
            other => panic!("expected CommodityConvert, got: {other:?}"),
334
        }
335
1
    }
336
}