Skip to main content

reinhardt_forms/fields/
decimal_field.rs

1use crate::field::{FieldError, FieldResult, FormField, Widget};
2use std::str::FromStr;
3
4/// DecimalField for decimal number input with digit and precision validation.
5///
6/// **Precision Note**: This field stores values internally as `f64` (IEEE 754),
7/// which provides approximately 15-17 significant decimal digits of precision.
8/// All digit count and decimal place validations are performed on the **string
9/// representation** before conversion to `f64`, ensuring accurate constraint
10/// enforcement even for values that cannot be exactly represented in binary
11/// floating-point.
12///
13/// For applications requiring exact decimal arithmetic (e.g., financial
14/// calculations), consider using `rust_decimal::Decimal` in your application
15/// layer after form validation.
16pub struct DecimalField {
17	pub name: String,
18	pub label: Option<String>,
19	pub required: bool,
20	pub help_text: Option<String>,
21	pub widget: Widget,
22	pub initial: Option<serde_json::Value>,
23	pub max_value: Option<f64>,
24	pub min_value: Option<f64>,
25	pub max_digits: Option<usize>,
26	pub decimal_places: Option<usize>,
27	pub localize: bool,
28	pub locale: Option<String>,
29	pub use_thousands_separator: bool,
30}
31
32impl DecimalField {
33	/// Create a new DecimalField
34	///
35	/// # Examples
36	///
37	/// ```
38	/// use reinhardt_forms::fields::DecimalField;
39	///
40	/// let field = DecimalField::new("price".to_string());
41	/// assert_eq!(field.name, "price");
42	/// assert!(field.required);
43	/// ```
44	pub fn new(name: String) -> Self {
45		Self {
46			name,
47			label: None,
48			required: true,
49			help_text: None,
50			widget: Widget::NumberInput,
51			initial: None,
52			max_value: None,
53			min_value: None,
54			max_digits: None,
55			decimal_places: None,
56			localize: false,
57			locale: None,
58			use_thousands_separator: false,
59		}
60	}
61	pub fn with_localize(mut self, localize: bool) -> Self {
62		self.localize = localize;
63		self
64	}
65	pub fn with_locale(mut self, locale: String) -> Self {
66		self.locale = Some(locale);
67		self
68	}
69	pub fn with_thousands_separator(mut self, use_separator: bool) -> Self {
70		self.use_thousands_separator = use_separator;
71		self
72	}
73
74	fn validate_decimal(&self, s: &str) -> Result<f64, String> {
75		let num = f64::from_str(s).map_err(|_| "Enter a number".to_string())?;
76
77		if !num.is_finite() {
78			return Err("Enter a valid number".to_string());
79		}
80
81		// Reject leading zeros (e.g., "007", "00.5") to avoid ambiguous input.
82		// Allowed patterns: "0", "0.5", "-0", "-0.5"
83		let integer_part = s.trim_start_matches('-');
84		let digits_before_dot = integer_part.split('.').next().unwrap_or(integer_part);
85		if digits_before_dot.len() > 1 && digits_before_dot.starts_with('0') {
86			return Err("Enter a number without leading zeros".to_string());
87		}
88
89		// Check total digits
90		if let Some(max_digits) = self.max_digits {
91			let parts: Vec<&str> = s.split('.').collect();
92			let total_digits =
93				parts[0].trim_start_matches('-').len() + parts.get(1).map(|p| p.len()).unwrap_or(0);
94			if total_digits > max_digits {
95				return Err(format!(
96					"Ensure that there are no more than {} digits in total",
97					max_digits
98				));
99			}
100		}
101
102		// Check decimal places
103		if let Some(decimal_places) = self.decimal_places {
104			let parts: Vec<&str> = s.split('.').collect();
105			if let Some(decimals) = parts.get(1)
106				&& decimals.len() > decimal_places
107			{
108				return Err(format!(
109					"Ensure that there are no more than {} decimal places",
110					decimal_places
111				));
112			}
113		}
114
115		Ok(num)
116	}
117}
118
119impl FormField for DecimalField {
120	fn name(&self) -> &str {
121		&self.name
122	}
123
124	fn label(&self) -> Option<&str> {
125		self.label.as_deref()
126	}
127
128	fn required(&self) -> bool {
129		self.required
130	}
131
132	fn help_text(&self) -> Option<&str> {
133		self.help_text.as_deref()
134	}
135
136	fn widget(&self) -> &Widget {
137		&self.widget
138	}
139
140	fn initial(&self) -> Option<&serde_json::Value> {
141		self.initial.as_ref()
142	}
143
144	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
145		match value {
146			None if self.required => Err(FieldError::required(None)),
147			None => Ok(serde_json::Value::Null),
148			Some(v) => {
149				// Convert to string representation for precise validation,
150				// then parse to f64 for range checks. This ensures digit count
151				// and decimal place validation is done on the original string
152				// rather than on a potentially imprecise f64 representation.
153				let (num, str_repr) = if let Some(s) = v.as_str() {
154					let s = s.trim();
155
156					if s.is_empty() {
157						if self.required {
158							return Err(FieldError::required(None));
159						}
160						return Ok(serde_json::Value::Null);
161					}
162
163					let n = self.validate_decimal(s).map_err(FieldError::Validation)?;
164					(n, s.to_string())
165				} else if let Some(f) = v.as_f64() {
166					if !f.is_finite() {
167						return Err(FieldError::Validation("Enter a valid number".to_string()));
168					}
169					(f, format!("{}", f))
170				} else if let Some(i) = v.as_i64() {
171					(i as f64, format!("{}", i))
172				} else {
173					return Err(FieldError::Invalid("Expected number or string".to_string()));
174				};
175
176				// Validate digit/decimal constraints on string representation
177				// when value was provided as a number (string inputs are already
178				// validated in validate_decimal)
179				if !v.is_string() {
180					if let Some(max_digits) = self.max_digits {
181						let parts: Vec<&str> = str_repr.split('.').collect();
182						let total_digits = parts[0].trim_start_matches('-').len()
183							+ parts.get(1).map(|p| p.len()).unwrap_or(0);
184						if total_digits > max_digits {
185							return Err(FieldError::Validation(format!(
186								"Ensure that there are no more than {} digits in total",
187								max_digits
188							)));
189						}
190					}
191
192					if let Some(decimal_places) = self.decimal_places {
193						let parts: Vec<&str> = str_repr.split('.').collect();
194						if let Some(decimals) = parts.get(1)
195							&& decimals.len() > decimal_places
196						{
197							return Err(FieldError::Validation(format!(
198								"Ensure that there are no more than {} decimal places",
199								decimal_places
200							)));
201						}
202					}
203				}
204
205				// Validate range
206				if let Some(max) = self.max_value
207					&& num > max
208				{
209					return Err(FieldError::Validation(format!(
210						"Ensure this value is less than or equal to {}",
211						max
212					)));
213				}
214
215				if let Some(min) = self.min_value
216					&& num < min
217				{
218					return Err(FieldError::Validation(format!(
219						"Ensure this value is greater than or equal to {}",
220						min
221					)));
222				}
223
224				Ok(serde_json::json!(num))
225			}
226		}
227	}
228}
229
230#[cfg(test)]
231mod tests {
232	use super::*;
233	use rstest::rstest;
234
235	#[test]
236	fn test_decimalfield_basic() {
237		let field = DecimalField::new("amount".to_string());
238
239		assert_eq!(
240			field.clean(Some(&serde_json::json!(3.15))).unwrap(),
241			serde_json::json!(3.15)
242		);
243		assert_eq!(
244			field.clean(Some(&serde_json::json!("3.15"))).unwrap(),
245			serde_json::json!(3.15)
246		);
247	}
248
249	#[test]
250	fn test_decimalfield_max_digits() {
251		let mut field = DecimalField::new("amount".to_string());
252		field.max_digits = Some(5);
253		field.decimal_places = Some(2);
254
255		assert!(field.clean(Some(&serde_json::json!("123.45"))).is_ok());
256		assert!(matches!(
257			field.clean(Some(&serde_json::json!("1234.567"))),
258			Err(FieldError::Validation(_))
259		));
260		assert!(matches!(
261			field.clean(Some(&serde_json::json!("123.456"))),
262			Err(FieldError::Validation(_))
263		));
264	}
265
266	#[test]
267	fn test_decimalfield_range() {
268		let mut field = DecimalField::new("amount".to_string());
269		field.min_value = Some(0.0);
270		field.max_value = Some(100.0);
271
272		assert!(field.clean(Some(&serde_json::json!(50.0))).is_ok());
273		assert!(matches!(
274			field.clean(Some(&serde_json::json!(-1.0))),
275			Err(FieldError::Validation(_))
276		));
277		assert!(matches!(
278			field.clean(Some(&serde_json::json!(101.0))),
279			Err(FieldError::Validation(_))
280		));
281	}
282
283	#[test]
284	fn test_decimalfield_required() {
285		let field = DecimalField::new("price".to_string());
286
287		// Required field rejects None
288		assert!(field.clean(None).is_err());
289
290		// Required field rejects empty string
291		assert!(field.clean(Some(&serde_json::json!(""))).is_err());
292	}
293
294	#[test]
295	fn test_decimalfield_not_required() {
296		let mut field = DecimalField::new("price".to_string());
297		field.required = false;
298
299		// Not required accepts None
300		assert_eq!(field.clean(None).unwrap(), serde_json::Value::Null);
301
302		// Not required accepts empty string
303		assert_eq!(
304			field.clean(Some(&serde_json::json!(""))).unwrap(),
305			serde_json::Value::Null
306		);
307	}
308
309	#[test]
310	fn test_decimalfield_integer_input() {
311		let field = DecimalField::new("amount".to_string());
312
313		// Integer as number
314		assert_eq!(
315			field.clean(Some(&serde_json::json!(42))).unwrap(),
316			serde_json::json!(42.0)
317		);
318
319		// Integer as string
320		assert_eq!(
321			field.clean(Some(&serde_json::json!("42"))).unwrap(),
322			serde_json::json!(42.0)
323		);
324	}
325
326	#[test]
327	fn test_decimalfield_negative_numbers() {
328		let mut field = DecimalField::new("amount".to_string());
329		field.required = false;
330
331		assert_eq!(
332			field.clean(Some(&serde_json::json!(-3.15))).unwrap(),
333			serde_json::json!(-3.15)
334		);
335		assert_eq!(
336			field.clean(Some(&serde_json::json!("-3.15"))).unwrap(),
337			serde_json::json!(-3.15)
338		);
339	}
340
341	#[test]
342	fn test_decimalfield_whitespace_trimming() {
343		let field = DecimalField::new("amount".to_string());
344
345		assert_eq!(
346			field.clean(Some(&serde_json::json!("  3.15  "))).unwrap(),
347			serde_json::json!(3.15)
348		);
349	}
350
351	#[test]
352	fn test_decimalfield_invalid_input() {
353		let field = DecimalField::new("amount".to_string());
354
355		// Non-numeric string
356		assert!(field.clean(Some(&serde_json::json!("abc"))).is_err());
357
358		// Multiple decimal points
359		assert!(field.clean(Some(&serde_json::json!("3.15.15"))).is_err());
360	}
361
362	#[test]
363	fn test_decimalfield_infinity_nan() {
364		let field = DecimalField::new("amount".to_string());
365
366		// Infinity is rejected
367		assert!(
368			field
369				.clean(Some(&serde_json::json!(f64::INFINITY)))
370				.is_err()
371		);
372
373		// NaN is rejected
374		assert!(field.clean(Some(&serde_json::json!(f64::NAN))).is_err());
375	}
376
377	#[test]
378	fn test_decimalfield_max_digits_exact() {
379		let mut field = DecimalField::new("amount".to_string());
380		field.max_digits = Some(5);
381
382		// Exactly 5 digits should pass
383		assert!(field.clean(Some(&serde_json::json!("12345"))).is_ok());
384		assert!(field.clean(Some(&serde_json::json!("123.45"))).is_ok());
385		assert!(field.clean(Some(&serde_json::json!("12.345"))).is_ok());
386	}
387
388	#[test]
389	fn test_decimalfield_decimal_places_exact() {
390		let mut field = DecimalField::new("amount".to_string());
391		field.decimal_places = Some(2);
392
393		// Exactly 2 decimal places should pass
394		assert!(field.clean(Some(&serde_json::json!("123.45"))).is_ok());
395
396		// 1 decimal place should pass (less than max)
397		assert!(field.clean(Some(&serde_json::json!("123.4"))).is_ok());
398
399		// 3 decimal places should fail
400		assert!(field.clean(Some(&serde_json::json!("123.456"))).is_err());
401	}
402
403	#[test]
404	fn test_decimalfield_max_value_exact() {
405		let mut field = DecimalField::new("amount".to_string());
406		field.max_value = Some(100.0);
407
408		// Exactly max value should pass
409		assert!(field.clean(Some(&serde_json::json!(100.0))).is_ok());
410
411		// Just above max should fail
412		assert!(field.clean(Some(&serde_json::json!(100.1))).is_err());
413	}
414
415	#[test]
416	fn test_decimalfield_min_value_exact() {
417		let mut field = DecimalField::new("amount".to_string());
418		field.min_value = Some(0.0);
419
420		// Exactly min value should pass
421		assert!(field.clean(Some(&serde_json::json!(0.0))).is_ok());
422
423		// Just below min should fail
424		assert!(field.clean(Some(&serde_json::json!(-0.1))).is_err());
425	}
426
427	#[test]
428	fn test_decimalfield_combined_constraints() {
429		let mut field = DecimalField::new("amount".to_string());
430		field.min_value = Some(0.0);
431		field.max_value = Some(999.99);
432		field.max_digits = Some(5);
433		field.decimal_places = Some(2);
434
435		// Valid values
436		assert!(field.clean(Some(&serde_json::json!("0.00"))).is_ok());
437		assert!(field.clean(Some(&serde_json::json!("123.45"))).is_ok());
438		assert!(field.clean(Some(&serde_json::json!("999.99"))).is_ok());
439
440		// Exceeds max value
441		assert!(field.clean(Some(&serde_json::json!("1000.00"))).is_err());
442
443		// Below min value
444		assert!(field.clean(Some(&serde_json::json!("-0.01"))).is_err());
445
446		// Too many decimal places
447		assert!(field.clean(Some(&serde_json::json!("123.456"))).is_err());
448
449		// Too many total digits
450		assert!(field.clean(Some(&serde_json::json!("1234.56"))).is_err());
451	}
452
453	#[test]
454	fn test_decimalfield_localize_option() {
455		let field = DecimalField::new("amount".to_string()).with_localize(true);
456		assert!(field.localize);
457	}
458
459	#[test]
460	fn test_decimalfield_locale_option() {
461		let field = DecimalField::new("amount".to_string()).with_locale("en_US".to_string());
462		assert_eq!(field.locale, Some("en_US".to_string()));
463	}
464
465	#[test]
466	fn test_decimalfield_thousands_separator() {
467		let field = DecimalField::new("amount".to_string()).with_thousands_separator(true);
468		assert!(field.use_thousands_separator);
469	}
470
471	#[test]
472	fn test_decimalfield_widget() {
473		let field = DecimalField::new("amount".to_string());
474		assert!(matches!(field.widget(), &Widget::NumberInput));
475	}
476
477	#[rstest]
478	#[case("007", true)]
479	#[case("00.5", true)]
480	#[case("00", true)]
481	#[case("01", true)]
482	#[case("0123.45", true)]
483	#[case("0", false)]
484	#[case("0.5", false)]
485	#[case("7", false)]
486	#[case("123", false)]
487	#[case("10.5", false)]
488	#[case("-0", false)]
489	#[case("-0.5", false)]
490	#[case("-007", true)]
491	fn test_decimalfield_leading_zeros(#[case] input: &str, #[case] should_reject: bool) {
492		// Arrange
493		let field = DecimalField::new("amount".to_string());
494
495		// Act
496		let result = field.clean(Some(&serde_json::json!(input)));
497
498		// Assert
499		if should_reject {
500			assert!(
501				matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("leading zeros")),
502				"Expected leading zeros rejection for input '{}', got: {:?}",
503				input,
504				result,
505			);
506		} else {
507			assert!(
508				result.is_ok(),
509				"Expected valid input '{}' to succeed, got: {:?}",
510				input,
511				result,
512			);
513		}
514	}
515}