mx20022_validate/rules/
amount.rs1use crate::error::{Severity, ValidationError};
10use crate::rules::Rule;
11
12pub struct AmountFormatRule;
35
36const MAX_INTEGER_DIGITS: usize = 18;
38const MAX_FRACTIONAL_DIGITS: usize = 5;
40
41impl Rule for AmountFormatRule {
42 fn id(&self) -> &'static str {
43 "AMOUNT_FORMAT"
44 }
45
46 fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
47 match validate_amount(value) {
48 Ok(()) => vec![],
49 Err(msg) => {
50 vec![ValidationError::new(
51 path,
52 Severity::Error,
53 "AMOUNT_FORMAT",
54 msg,
55 )]
56 }
57 }
58 }
59}
60
61fn validate_amount(value: &str) -> Result<(), String> {
62 if value.is_empty() {
63 return Err("Amount must not be empty".to_owned());
64 }
65
66 let stripped = value.strip_prefix('+').unwrap_or(value);
69
70 if value.starts_with('-') {
72 return Err(format!("Amount must be positive (> 0), got: `{value}`"));
73 }
74
75 let (integer_part, fractional_part) = match stripped.split_once('.') {
77 Some((int, frac)) => (int, Some(frac)),
78 None => (stripped, None),
79 };
80
81 if integer_part.is_empty() {
83 return Err(format!("Amount has no integer part: `{value}`"));
84 }
85
86 if !integer_part.chars().all(|c| c.is_ascii_digit()) {
87 return Err(format!(
88 "Amount contains non-numeric characters in integer part: `{value}`"
89 ));
90 }
91
92 let integer_digits = integer_part.len();
95 if integer_digits > MAX_INTEGER_DIGITS {
96 return Err(format!(
97 "Amount integer part has {integer_digits} digits, maximum is {MAX_INTEGER_DIGITS}: `{value}`"
98 ));
99 }
100
101 if let Some(frac) = fractional_part {
103 if frac.is_empty() {
104 return Err(format!("Amount has trailing decimal point: `{value}`"));
105 }
106 if !frac.chars().all(|c| c.is_ascii_digit()) {
107 return Err(format!(
108 "Amount contains non-numeric characters in fractional part: `{value}`"
109 ));
110 }
111 if frac.len() > MAX_FRACTIONAL_DIGITS {
112 return Err(format!(
113 "Amount has {} fractional digits, maximum is {MAX_FRACTIONAL_DIGITS}: `{value}`",
114 frac.len()
115 ));
116 }
117 }
118
119 let parsed: f64 = value
122 .parse()
123 .map_err(|_| format!("Amount is not a valid decimal number: `{value}`"))?;
124 if parsed <= 0.0 {
125 return Err(format!("Amount must be greater than zero, got: `{value}`"));
126 }
127
128 Ok(())
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::rules::Rule;
135
136 const VALID_AMOUNTS: &[&str] = &[
137 "1",
138 "1.0",
139 "1.00",
140 "1000.00",
141 "999999999999999999", "0.12345", "1000000000.12345",
144 "0.00001",
145 "+1.00", "1.1",
147 ];
148
149 const INVALID_AMOUNTS: &[&str] = &[
150 "-1.00", "0", "0.00", "0.00000", "1.123456", "1234567890123456789", "abc", "1.2.3", "", "1.", ".5", "-0.01", ];
163
164 #[test]
165 fn valid_amounts_pass() {
166 let rule = AmountFormatRule;
167 for amount in VALID_AMOUNTS {
168 let errors = rule.validate(amount, "/test");
169 assert!(
170 errors.is_empty(),
171 "Expected no errors for valid amount `{amount}`, got: {errors:?}"
172 );
173 }
174 }
175
176 #[test]
177 fn invalid_amounts_fail() {
178 let rule = AmountFormatRule;
179 for amount in INVALID_AMOUNTS {
180 let errors = rule.validate(amount, "/test");
181 assert!(
182 !errors.is_empty(),
183 "Expected errors for invalid amount `{amount}`"
184 );
185 }
186 }
187
188 #[test]
189 fn error_has_correct_rule_id_and_path() {
190 let rule = AmountFormatRule;
191 let errors = rule.validate("-1.00", "/Document/Amt");
192 assert_eq!(errors.len(), 1);
193 assert_eq!(errors[0].rule_id, "AMOUNT_FORMAT");
194 assert_eq!(errors[0].path, "/Document/Amt");
195 assert_eq!(errors[0].severity, Severity::Error);
196 }
197
198 #[test]
199 fn rule_id_is_amount_format() {
200 assert_eq!(AmountFormatRule.id(), "AMOUNT_FORMAT");
201 }
202
203 #[test]
204 fn zero_is_rejected() {
205 let rule = AmountFormatRule;
206 let errors = rule.validate("0", "/test");
207 assert!(!errors.is_empty());
208 assert!(
209 errors[0].message.contains("greater than zero") || errors[0].message.contains("> 0"),
210 "Expected zero message, got: {}",
211 errors[0].message
212 );
213 }
214
215 #[test]
216 fn negative_is_rejected() {
217 let rule = AmountFormatRule;
218 let errors = rule.validate("-100.00", "/test");
219 assert!(!errors.is_empty());
220 assert!(
221 errors[0].message.contains("positive"),
222 "Expected positive message, got: {}",
223 errors[0].message
224 );
225 }
226
227 #[test]
228 fn too_many_fractional_digits_rejected() {
229 let rule = AmountFormatRule;
230 let errors = rule.validate("1.123456", "/test");
231 assert!(!errors.is_empty());
232 assert!(
233 errors[0].message.contains("fractional"),
234 "Expected fractional message, got: {}",
235 errors[0].message
236 );
237 }
238
239 #[test]
240 fn too_many_integer_digits_rejected() {
241 let rule = AmountFormatRule;
242 let errors = rule.validate("1234567890123456789", "/test"); assert!(!errors.is_empty());
244 assert!(
245 errors[0].message.contains("integer"),
246 "Expected integer digits message, got: {}",
247 errors[0].message
248 );
249 }
250
251 #[test]
252 fn non_numeric_rejected() {
253 let rule = AmountFormatRule;
254 let errors = rule.validate("abc", "/test");
255 assert!(!errors.is_empty());
256 }
257
258 #[test]
259 fn empty_rejected() {
260 let rule = AmountFormatRule;
261 let errors = rule.validate("", "/test");
262 assert!(!errors.is_empty());
263 assert!(errors[0].message.contains("empty"));
264 }
265
266 #[test]
267 fn exactly_18_integer_digits_passes() {
268 let rule = AmountFormatRule;
269 let errors = rule.validate("999999999999999999", "/test"); assert!(errors.is_empty(), "18 integer digits should be valid");
271 }
272
273 #[test]
274 fn exactly_5_fractional_digits_passes() {
275 let rule = AmountFormatRule;
276 let errors = rule.validate("1.12345", "/test"); assert!(errors.is_empty(), "5 fractional digits should be valid");
278 }
279}