1use crate::widgets::Widget;
4
5use super::{Form, FormKind, FormSubmit};
6
7pub 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 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}