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