Lines
97.29 %
Functions
47.73 %
Branches
100 %
//! Pure validation logic: extracts and validates form buffers into a [`FormSubmit`] payload.
use crate::widgets::Widget;
use super::{Form, FormKind, FormSubmit};
/// Validate the form's buffers and produce a dispatchable submit payload.
///
/// Returns `Err` with a human-readable message on any validation failure.
pub fn validate(form: &Form) -> Result<FormSubmit, String> {
match form.kind {
FormKind::ConfigSet => {
let key = form.field_buffer(0).to_string();
let value = form.field_buffer(1).to_string();
if key.is_empty() {
return Err("config key is required".to_string());
}
Ok(FormSubmit::ConfigSet { key, value })
FormKind::ReportParams { kind } => {
let from_s = form.field_buffer(0).to_string();
let to_s = form.field_buffer(1).to_string();
let chart_s = form.field_buffer(2).to_string();
let chart = if chart_s.is_empty() {
"bar".to_string()
} else {
chart_s
};
let from = cli_core::reports::coerce_date_arg(&from_s, false)
.map_err(|e| format!("invalid from date: {e}"))?;
let to = cli_core::reports::coerce_date_arg(&to_s, true)
.map_err(|e| format!("invalid to date: {e}"))?;
Ok(FormSubmit::Report {
kind,
from,
to,
chart,
})
FormKind::CommodityCreate => {
let symbol = form.field_buffer(0).to_string();
let name = form.field_buffer(1).to_string();
if symbol.is_empty() {
return Err("symbol is required".to_string());
if name.is_empty() {
return Err("name is required".to_string());
Ok(FormSubmit::CommodityCreate { symbol, name })
FormKind::AccountCreate => {
let name = form.field_buffer(0).to_string();
let parent_val = form.field_buffer(1).to_string();
return Err("account name is required".to_string());
let parent = if parent_val.is_empty() {
None
Some(parent_val)
Ok(FormSubmit::AccountCreate { name, parent })
FormKind::TransactionCreate => validate_transaction_create(form),
FormKind::TransactionEdit => validate_transaction_edit(form),
FormKind::AccountTag => {
return Err("tag name is required".to_string());
let account_id = form
.entity_id
.clone()
.ok_or_else(|| "account id missing".to_string())?;
Ok(FormSubmit::AccountTag {
account_id,
name,
value,
FormKind::TransactionTag => {
let transaction_id = form
.ok_or_else(|| "transaction id missing".to_string())?;
Ok(FormSubmit::TransactionTag {
transaction_id,
FormKind::CommodityConvert => validate_commodity_convert(form),
fn validate_commodity_convert(form: &Form) -> Result<FormSubmit, String> {
let amount_str = form.field_buffer(0).to_string();
let from = form.field_buffer(1).to_string();
let to = form.field_buffer(2).to_string();
let from_label = select_display(form, 1).unwrap_or_else(|| from.clone());
let to_label = select_display(form, 2).unwrap_or_else(|| to.clone());
if from.is_empty() {
return Err("from commodity is required".to_string());
if to.is_empty() {
return Err("to commodity is required".to_string());
if from == to {
return Err("from and to commodities must differ".to_string());
let parsed =
cli_core::forms::parse_amount(&amount_str).map_err(|e| format!("invalid amount: {e}"))?;
let amount_num_denom = cli_core::forms::amount_token(&parsed);
Ok(FormSubmit::CommodityConvert {
amount_num_denom,
amount_str,
from_label,
to_label,
fn select_display(form: &Form, field_idx: usize) -> Option<String> {
match form.fields.get(field_idx).map(|f| &f.widget) {
Some(Widget::Select(sw)) => Some(sw.display().to_string()),
_ => None,
fn extract_date_note_splits(
form: &Form,
) -> Result<(String, String, Vec<cli_core::forms::LogicalSplitInput>), String> {
let date = form.field_buffer(0).to_string();
let note = form.field_buffer(1).to_string();
let splits_field = form
.fields
.get(2)
.ok_or_else(|| "splits field missing".to_string())?;
let Widget::Splits(ref sw) = splits_field.widget else {
return Err("splits field has wrong widget type".to_string());
let rows = sw.rows();
if rows.is_empty() {
return Err("at least one split is required".to_string());
let splits = rows
.iter()
.enumerate()
.map(|(i, row)| {
let to_amount_val = row.to_amount.value();
let input = cli_core::forms::LogicalSplitInput {
from: row.from.value().to_string(),
to: row.to.value().to_string(),
from_commodity: row.from_commodity.value().to_string(),
to_commodity: row.to_commodity.value().to_string(),
amount: row.value.value().to_string(),
to_amount: if to_amount_val.is_empty() {
Some(to_amount_val.to_string())
},
cli_core::forms::row_complete(&input)
.map_err(|e| format!("split row {}: {e}", i + 1))?;
Ok(input)
.collect::<Result<Vec<_>, String>>()?;
Ok((date, note, splits))
fn validate_transaction_create(form: &Form) -> Result<FormSubmit, String> {
let (date, note, splits) = extract_date_note_splits(form)?;
Ok(FormSubmit::TransactionCreate { note, date, splits })
fn validate_transaction_edit(form: &Form) -> Result<FormSubmit, String> {
let id = form
Ok(FormSubmit::TransactionEdit {
id,
note,
date,
splits,
#[cfg(test)]
mod tests {
use super::{Form, FormSubmit, validate};
use crate::widgets::{AmountWidget, EditMode, SelectOption, Widget};
fn commodity_convert_form_with(
amount: &str,
from_id: &str,
from_label: &str,
to_id: &str,
to_label: &str,
) -> Form {
let mut form = Form::commodity_convert(EditMode::Emacs);
if let Widget::Amount(ref mut aw) = form.fields[0].widget {
*aw = AmountWidget::with_value(EditMode::Emacs, amount);
if let Widget::Select(ref mut sw) = form.fields[1].widget {
sw.set_options(vec![SelectOption {
id: from_id.to_string(),
label: from_label.to_string(),
}]);
if let Widget::Select(ref mut sw) = form.fields[2].widget {
id: to_id.to_string(),
label: to_label.to_string(),
form
#[test]
fn commodity_convert_form_has_three_fields() {
let form = Form::commodity_convert(EditMode::Emacs);
assert_eq!(form.fields.len(), 3);
assert_eq!(form.fields[0].label, "Amount");
assert_eq!(form.fields[1].label, "From");
assert_eq!(form.fields[2].label, "To");
fn validate_commodity_convert_empty_from_is_error() {
*aw = AmountWidget::with_value(EditMode::Emacs, "100");
let err = validate(&form).unwrap_err();
assert!(err.contains("from commodity is required"), "got: {err}");
fn validate_commodity_convert_empty_to_is_error() {
let form = commodity_convert_form_with("100", "uuid-from", "From", "", "");
assert!(err.contains("to commodity is required"), "got: {err}");
fn validate_commodity_convert_from_equals_to_is_error() {
let same = "550e8400-e29b-41d4-a716-446655440001";
let form = commodity_convert_form_with("100", same, "USD", same, "USD");
assert!(err.contains("must differ"), "got: {err}");
fn validate_commodity_convert_bad_amount_is_error() {
let form =
commodity_convert_form_with("not-a-number", "uuid-from", "From", "uuid-to", "To");
assert!(err.contains("invalid amount"), "got: {err}");
fn validate_commodity_convert_signed_and_zero_amounts_accepted() {
// The native handles zero and signed amounts; the form must not be stricter.
for (amount, expected) in [("0", "0"), ("-9/2", "-9/2"), ("-100", "-100")] {
let form = commodity_convert_form_with(amount, "uuid-a", "A", "uuid-b", "B");
match validate(&form).unwrap() {
FormSubmit::CommodityConvert {
amount_num_denom, ..
} => assert_eq!(amount_num_denom, expected, "for input {amount}"),
other => panic!("expected CommodityConvert, got: {other:?}"),
fn validate_commodity_convert_good_input_fractional() {
let from_id = "550e8400-e29b-41d4-a716-446655440001";
let to_id = "550e8400-e29b-41d4-a716-446655440002";
let form = commodity_convert_form_with("9/2", from_id, "USD", to_id, "EUR");
} => {
assert_eq!(amount_num_denom, "9/2");
assert_eq!(amount_str, "9/2");
assert_eq!(from, from_id);
assert_eq!(from_label, "USD");
assert_eq!(to, to_id);
assert_eq!(to_label, "EUR");
fn validate_commodity_convert_integer_amount_formats_without_denom() {
let form = commodity_convert_form_with("100", "uuid-a", "A", "uuid-b", "B");
} => assert_eq!(amount_num_denom, "100"),
fn validate_commodity_convert_decimal_amount_becomes_ratio() {
let form = commodity_convert_form_with("1.5", "uuid-a", "A", "uuid-b", "B");
} => assert_eq!(amount_num_denom, "3/2"),