Lines
99.1 %
Functions
60.53 %
Branches
100 %
//! Pure validators shared between TUI and emacs clients.
use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc};
use num_rational::Rational64;
/// Unvalidated form-level representation of one from→to split row.
#[derive(Debug, Clone, PartialEq)]
pub struct LogicalSplitInput {
pub from: String,
pub to: String,
pub from_commodity: String,
pub to_commodity: String,
pub amount: String,
/// Required iff `from_commodity != to_commodity`.
pub to_amount: Option<String>,
}
/// Parse an amount string as an exact rational number.
///
/// Accepts integers (`"5"`), explicit ratios (`"1/3"`), and decimals
/// (`"153.81"` → `15381/100`, `"-2.5"` → `-5/2`), with an optional leading `-`.
/// The decimal path mirrors `web::pages::transaction::util::parse_amount_to_rational`
/// (digit-shift, no floating-point intermediary); that helper is bound to axum
/// `StatusCode`/`Json` errors and cannot be reused verbatim here.
pub fn parse_amount(s: &str) -> Result<Rational64, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("empty amount".to_string());
if let Some((num_str, denom_str)) = trimmed.split_once('/') {
let n: i64 = num_str
.parse()
.map_err(|e| format!("invalid numerator in '{s}': {e}"))?;
let d: i64 = denom_str
.map_err(|e| format!("invalid denominator in '{s}': {e}"))?;
if d == 0 {
return Err(format!("zero denominator in '{s}'"));
return ratio_checked(n, d, s);
if trimmed.contains('.') {
return parse_decimal(trimmed, s);
let n: i64 = trimmed
.map_err(|e| format!("invalid amount '{s}': {e}"))?;
ratio_checked(n, 1, s)
/// `Rational64::new` normalizes sign/gcd by negating operands, which overflows
/// (panicking in debug, wrapping in release) when either is `i64::MIN`. Reject
/// those magnitudes before constructing the ratio.
fn ratio_checked(numer: i64, denom: i64, original: &str) -> Result<Rational64, String> {
if numer == i64::MIN || denom == i64::MIN {
return Err(format!("amount magnitude too large in '{original}'"));
Ok(Rational64::new(numer, denom))
/// Format a rational as a nomiscript amount token: a bare integer when the
/// denominator is 1, otherwise `numer/denom`. Single source for the token shape
/// shared by the transaction payload, the convert form, and reverse-lowering.
pub fn amount_token(r: &Rational64) -> String {
if *r.denom() == 1 {
r.numer().to_string()
} else {
format!("{}/{}", r.numer(), r.denom())
fn parse_decimal(trimmed: &str, original: &str) -> Result<Rational64, String> {
if trimmed.matches('.').count() > 1 {
return Err(format!(
"invalid amount '{original}': multiple decimal points"
));
let dot_pos = trimmed
.find('.')
.ok_or_else(|| format!("invalid amount '{original}'"))?;
let decimals = trimmed.len() - dot_pos - 1;
let without_dot: String = trimmed.chars().filter(|c| *c != '.').collect();
let numer: i64 = without_dot
.map_err(|e| format!("invalid amount '{original}': {e}"))?;
let scale = u32::try_from(decimals)
.map_err(|_| format!("invalid amount '{original}': too many decimals"))?;
let denom = 10_i64
.checked_pow(scale)
.ok_or_else(|| format!("invalid amount '{original}': decimal scale overflows i64"))?;
ratio_checked(numer, denom, original)
/// Parse a date string: RFC3339 passes through; `YYYY-MM-DDTHH:MM` (T-separated, no seconds)
/// and bare `YYYY-MM-DD` (midnight UTC) are also accepted.
pub fn validate_date(s: &str) -> Result<DateTime<Utc>, String> {
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M") {
return Ok(naive.and_utc());
let date =
NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| format!("invalid date '{s}': {e}"))?;
let naive = date
.and_hms_opt(0, 0, 0)
.ok_or_else(|| format!("invalid time for date '{s}'"))?;
Ok(Utc.from_utc_datetime(&naive))
/// Current instant formatted as `YYYY-MM-DDTHH:MM` (T-separated, no seconds, UTC).
/// This is the default template for new transaction date fields. The format is
/// accepted by both `validate_date` and the server's `parse_flexible_date`.
#[must_use]
pub fn now_template() -> String {
Utc::now().format("%Y-%m-%dT%H:%M").to_string()
/// Add `delta_days` to the date in `s`, preserving the time-of-day component.
/// Returns `None` (leaving the caller's buffer untouched) when `s` does not parse
/// as a date — stepping must never destroy in-progress user input — or when the
/// resulting instant would overflow the representable range.
pub fn step_date(s: &str, delta_days: i64) -> Option<String> {
let base = validate_date(s).ok()?;
let delta = Duration::try_days(delta_days)?;
let stepped = base.checked_add_signed(delta)?;
Some(stepped.format("%Y-%m-%dT%H:%M").to_string())
/// Validate that a split row is complete enough to submit.
/// Checks: both account and both commodity fields non-empty; `amount` parses
/// and is positive; for a cross-commodity row `to_amount` is present, parseable
/// and positive; for a same-commodity row `to_amount` must be absent. The
/// positivity / cross-vs-same guards mirror `server::logical` so the form
/// rejects a bad row early rather than failing late at lowering.
pub fn row_complete(row: &LogicalSplitInput) -> Result<(), String> {
if row.from.is_empty() {
return Err("from account is required".to_string());
if row.to.is_empty() {
return Err("to account is required".to_string());
if row.from_commodity.is_empty() {
return Err("from commodity is required".to_string());
if row.to_commodity.is_empty() {
return Err("to commodity is required".to_string());
let value = parse_amount(&row.amount)?;
if value <= Rational64::new(0, 1) {
return Err("split value must be positive".to_string());
let to_amount_set = matches!(&row.to_amount, Some(s) if !s.is_empty());
if row.from_commodity != row.to_commodity {
match row.to_amount.as_deref().filter(|s| !s.is_empty()) {
None => return Err("to_amount is required for cross-commodity splits".to_string()),
Some(s) => {
let to_amount = parse_amount(s)?;
if to_amount <= Rational64::new(0, 1) {
return Err("to_amount must be positive".to_string());
} else if to_amount_set {
return Err("to_amount is only valid for cross-currency splits".to_string());
Ok(())