1
//! Pure validators shared between TUI and emacs clients.
2

            
3
use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, TimeZone, Utc};
4
use num_rational::Rational64;
5

            
6
/// Unvalidated form-level representation of one from→to split row.
7
#[derive(Debug, Clone, PartialEq)]
8
pub struct LogicalSplitInput {
9
    pub from: String,
10
    pub to: String,
11
    pub from_commodity: String,
12
    pub to_commodity: String,
13
    pub amount: String,
14
    /// Required iff `from_commodity != to_commodity`.
15
    pub to_amount: Option<String>,
16
}
17

            
18
/// Parse an amount string as an exact rational number.
19
///
20
/// Accepts integers (`"5"`), explicit ratios (`"1/3"`), and decimals
21
/// (`"153.81"` → `15381/100`, `"-2.5"` → `-5/2`), with an optional leading `-`.
22
/// The decimal path mirrors `web::pages::transaction::util::parse_amount_to_rational`
23
/// (digit-shift, no floating-point intermediary); that helper is bound to axum
24
/// `StatusCode`/`Json` errors and cannot be reused verbatim here.
25
233
pub fn parse_amount(s: &str) -> Result<Rational64, String> {
26
233
    let trimmed = s.trim();
27
233
    if trimmed.is_empty() {
28
2
        return Err("empty amount".to_string());
29
231
    }
30
231
    if let Some((num_str, denom_str)) = trimmed.split_once('/') {
31
38
        let n: i64 = num_str
32
38
            .parse()
33
38
            .map_err(|e| format!("invalid numerator in '{s}': {e}"))?;
34
38
        let d: i64 = denom_str
35
38
            .parse()
36
38
            .map_err(|e| format!("invalid denominator in '{s}': {e}"))?;
37
38
        if d == 0 {
38
1
            return Err(format!("zero denominator in '{s}'"));
39
37
        }
40
37
        return ratio_checked(n, d, s);
41
193
    }
42
193
    if trimmed.contains('.') {
43
18
        return parse_decimal(trimmed, s);
44
175
    }
45
175
    let n: i64 = trimmed
46
175
        .parse()
47
175
        .map_err(|e| format!("invalid amount '{s}': {e}"))?;
48
160
    ratio_checked(n, 1, s)
49
233
}
50

            
51
/// `Rational64::new` normalizes sign/gcd by negating operands, which overflows
52
/// (panicking in debug, wrapping in release) when either is `i64::MIN`. Reject
53
/// those magnitudes before constructing the ratio.
54
214
fn ratio_checked(numer: i64, denom: i64, original: &str) -> Result<Rational64, String> {
55
214
    if numer == i64::MIN || denom == i64::MIN {
56
3
        return Err(format!("amount magnitude too large in '{original}'"));
57
211
    }
58
211
    Ok(Rational64::new(numer, denom))
59
214
}
60

            
61
/// Format a rational as a nomiscript amount token: a bare integer when the
62
/// denominator is 1, otherwise `numer/denom`. Single source for the token shape
63
/// shared by the transaction payload, the convert form, and reverse-lowering.
64
130
pub fn amount_token(r: &Rational64) -> String {
65
130
    if *r.denom() == 1 {
66
82
        r.numer().to_string()
67
    } else {
68
48
        format!("{}/{}", r.numer(), r.denom())
69
    }
70
130
}
71

            
72
18
fn parse_decimal(trimmed: &str, original: &str) -> Result<Rational64, String> {
73
18
    if trimmed.matches('.').count() > 1 {
74
1
        return Err(format!(
75
1
            "invalid amount '{original}': multiple decimal points"
76
1
        ));
77
17
    }
78
17
    let dot_pos = trimmed
79
17
        .find('.')
80
17
        .ok_or_else(|| format!("invalid amount '{original}'"))?;
81
17
    let decimals = trimmed.len() - dot_pos - 1;
82
61
    let without_dot: String = trimmed.chars().filter(|c| *c != '.').collect();
83
17
    let numer: i64 = without_dot
84
17
        .parse()
85
17
        .map_err(|e| format!("invalid amount '{original}': {e}"))?;
86
17
    let scale = u32::try_from(decimals)
87
17
        .map_err(|_| format!("invalid amount '{original}': too many decimals"))?;
88
17
    let denom = 10_i64
89
17
        .checked_pow(scale)
90
17
        .ok_or_else(|| format!("invalid amount '{original}': decimal scale overflows i64"))?;
91
17
    ratio_checked(numer, denom, original)
92
18
}
93

            
94
/// Parse a date string: RFC3339 passes through; `YYYY-MM-DDTHH:MM` (T-separated, no seconds)
95
/// and bare `YYYY-MM-DD` (midnight UTC) are also accepted.
96
101
pub fn validate_date(s: &str) -> Result<DateTime<Utc>, String> {
97
101
    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
98
1
        return Ok(dt.with_timezone(&Utc));
99
100
    }
100
100
    if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M") {
101
95
        return Ok(naive.and_utc());
102
5
    }
103
1
    let date =
104
5
        NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| format!("invalid date '{s}': {e}"))?;
105
1
    let naive = date
106
1
        .and_hms_opt(0, 0, 0)
107
1
        .ok_or_else(|| format!("invalid time for date '{s}'"))?;
108
1
    Ok(Utc.from_utc_datetime(&naive))
109
101
}
110

            
111
/// Current instant formatted as `YYYY-MM-DDTHH:MM` (T-separated, no seconds, UTC).
112
///
113
/// This is the default template for new transaction date fields. The format is
114
/// accepted by both `validate_date` and the server's `parse_flexible_date`.
115
#[must_use]
116
287
pub fn now_template() -> String {
117
287
    Utc::now().format("%Y-%m-%dT%H:%M").to_string()
118
287
}
119

            
120
/// Add `delta_days` to the date in `s`, preserving the time-of-day component.
121
///
122
/// Returns `None` (leaving the caller's buffer untouched) when `s` does not parse
123
/// as a date — stepping must never destroy in-progress user input — or when the
124
/// resulting instant would overflow the representable range.
125
#[must_use]
126
69
pub fn step_date(s: &str, delta_days: i64) -> Option<String> {
127
69
    let base = validate_date(s).ok()?;
128
67
    let delta = Duration::try_days(delta_days)?;
129
67
    let stepped = base.checked_add_signed(delta)?;
130
67
    Some(stepped.format("%Y-%m-%dT%H:%M").to_string())
131
69
}
132

            
133
/// Validate that a split row is complete enough to submit.
134
///
135
/// Checks: both account and both commodity fields non-empty; `amount` parses
136
/// and is positive; for a cross-commodity row `to_amount` is present, parseable
137
/// and positive; for a same-commodity row `to_amount` must be absent. The
138
/// positivity / cross-vs-same guards mirror `server::logical` so the form
139
/// rejects a bad row early rather than failing late at lowering.
140
76
pub fn row_complete(row: &LogicalSplitInput) -> Result<(), String> {
141
76
    if row.from.is_empty() {
142
14
        return Err("from account is required".to_string());
143
62
    }
144
62
    if row.to.is_empty() {
145
1
        return Err("to account is required".to_string());
146
61
    }
147
61
    if row.from_commodity.is_empty() {
148
1
        return Err("from commodity is required".to_string());
149
60
    }
150
60
    if row.to_commodity.is_empty() {
151
        return Err("to commodity is required".to_string());
152
60
    }
153
60
    let value = parse_amount(&row.amount)?;
154
59
    if value <= Rational64::new(0, 1) {
155
2
        return Err("split value must be positive".to_string());
156
57
    }
157
57
    let to_amount_set = matches!(&row.to_amount, Some(s) if !s.is_empty());
158
57
    if row.from_commodity != row.to_commodity {
159
29
        match row.to_amount.as_deref().filter(|s| !s.is_empty()) {
160
14
            None => return Err("to_amount is required for cross-commodity splits".to_string()),
161
15
            Some(s) => {
162
15
                let to_amount = parse_amount(s)?;
163
15
                if to_amount <= Rational64::new(0, 1) {
164
1
                    return Err("to_amount must be positive".to_string());
165
14
                }
166
            }
167
        }
168
28
    } else if to_amount_set {
169
1
        return Err("to_amount is only valid for cross-currency splits".to_string());
170
27
    }
171
41
    Ok(())
172
76
}