Skip to main content

reinhardt_forms/fields/
date_field.rs

1use crate::field::{FieldError, FieldResult, FormField, Widget};
2use chrono::{Datelike, NaiveDate};
3
4/// DateField for date input
5#[derive(Debug, Clone)]
6pub struct DateField {
7	/// The field name used as the form data key.
8	pub name: String,
9	/// Optional human-readable label for display.
10	pub label: Option<String>,
11	/// Whether this field must be filled in.
12	pub required: bool,
13	/// Optional help text displayed alongside the field.
14	pub help_text: Option<String>,
15	/// The widget type used for rendering this field.
16	pub widget: Widget,
17	/// Optional initial (default) value for the field.
18	pub initial: Option<serde_json::Value>,
19	/// Accepted date format strings (strftime patterns).
20	pub input_formats: Vec<String>,
21	/// Whether to apply locale-aware formatting.
22	pub localize: bool,
23	/// The locale to use for formatting (e.g., "en_US").
24	pub locale: Option<String>,
25}
26
27impl DateField {
28	/// Create a new DateField with the given name
29	///
30	/// # Examples
31	///
32	/// ```
33	/// use reinhardt_forms::fields::DateField;
34	///
35	/// let field = DateField::new("birth_date".to_string());
36	/// assert_eq!(field.name, "birth_date");
37	/// assert!(field.required);
38	/// ```
39	pub fn new(name: String) -> Self {
40		Self {
41			name,
42			label: None,
43			required: true,
44			help_text: None,
45			widget: Widget::DateInput,
46			initial: None,
47			input_formats: vec![
48				"%Y-%m-%d".to_string(),  // 2025-01-15
49				"%m/%d/%Y".to_string(),  // 01/15/2025
50				"%b %d %Y".to_string(),  // Jan 15 2025
51				"%b %d, %Y".to_string(), // Jan 15, 2025
52				"%d %b %Y".to_string(),  // 15 Jan 2025
53				"%d %b, %Y".to_string(), // 15 Jan, 2025
54				"%B %d %Y".to_string(),  // January 15 2025
55				"%B %d, %Y".to_string(), // January 15, 2025
56				"%d %B %Y".to_string(),  // 15 January 2025
57				"%d %B, %Y".to_string(), // 15 January, 2025
58			],
59			localize: false,
60			locale: None,
61		}
62	}
63	/// Enable localization for this field
64	///
65	/// # Examples
66	///
67	/// ```
68	/// use reinhardt_forms::fields::DateField;
69	///
70	/// let field = DateField::new("date".to_string()).with_localize(true);
71	/// assert!(field.localize);
72	/// ```
73	pub fn with_localize(mut self, localize: bool) -> Self {
74		self.localize = localize;
75		self
76	}
77	/// Set the locale for this field
78	///
79	/// # Examples
80	///
81	/// ```
82	/// use reinhardt_forms::fields::DateField;
83	///
84	/// let field = DateField::new("date".to_string()).with_locale("en_US".to_string());
85	/// assert_eq!(field.locale, Some("en_US".to_string()));
86	/// ```
87	pub fn with_locale(mut self, locale: String) -> Self {
88		self.locale = Some(locale);
89		self
90	}
91
92	fn parse_date(&self, s: &str) -> Result<NaiveDate, String> {
93		for format in &self.input_formats {
94			if let Ok(date) = NaiveDate::parse_from_str(s, format) {
95				// Reject dates with years outside the 4-digit range (1000-9999)
96				// to prevent ambiguous 2-digit year interpretations.
97				let year = date.year();
98				if !(1000..=9999).contains(&year) {
99					continue;
100				}
101				return Ok(date);
102			}
103		}
104		Err("Enter a valid date with a 4-digit year".to_string())
105	}
106}
107
108impl FormField for DateField {
109	fn name(&self) -> &str {
110		&self.name
111	}
112
113	fn label(&self) -> Option<&str> {
114		self.label.as_deref()
115	}
116
117	fn required(&self) -> bool {
118		self.required
119	}
120
121	fn help_text(&self) -> Option<&str> {
122		self.help_text.as_deref()
123	}
124
125	fn widget(&self) -> &Widget {
126		&self.widget
127	}
128
129	fn initial(&self) -> Option<&serde_json::Value> {
130		self.initial.as_ref()
131	}
132
133	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
134		match value {
135			None if self.required => Err(FieldError::required(None)),
136			None => Ok(serde_json::Value::Null),
137			Some(v) => {
138				let s = v
139					.as_str()
140					.ok_or_else(|| FieldError::Invalid("Expected string".to_string()))?;
141
142				let s = s.trim();
143
144				if s.is_empty() {
145					if self.required {
146						return Err(FieldError::required(None));
147					}
148					return Ok(serde_json::Value::Null);
149				}
150
151				let date = self.parse_date(s).map_err(FieldError::Validation)?;
152
153				// Return in ISO 8601 format
154				Ok(serde_json::json!(date.format("%Y-%m-%d").to_string()))
155			}
156		}
157	}
158}
159
160#[cfg(test)]
161mod tests {
162	use super::*;
163	use rstest::rstest;
164
165	#[test]
166	fn test_date_field_required() {
167		let field = DateField::new("date".to_string());
168
169		// Required field rejects None
170		assert!(field.clean(None).is_err());
171
172		// Required field rejects empty string
173		assert!(field.clean(Some(&serde_json::json!(""))).is_err());
174	}
175
176	#[test]
177	fn test_date_field_not_required() {
178		let mut field = DateField::new("date".to_string());
179		field.required = false;
180
181		// Not required accepts None
182		assert_eq!(field.clean(None).unwrap(), serde_json::Value::Null);
183
184		// Not required accepts empty string
185		assert_eq!(
186			field.clean(Some(&serde_json::json!(""))).unwrap(),
187			serde_json::Value::Null
188		);
189	}
190
191	#[test]
192	fn test_date_field_iso_format() {
193		let field = DateField::new("date".to_string());
194
195		// Standard ISO 8601 format (YYYY-MM-DD)
196		let result = field.clean(Some(&serde_json::json!("2025-01-15"))).unwrap();
197		assert_eq!(result, serde_json::json!("2025-01-15"));
198	}
199
200	#[test]
201	fn test_date_field_us_format() {
202		let field = DateField::new("date".to_string());
203
204		// US format (MM/DD/YYYY)
205		let result = field.clean(Some(&serde_json::json!("01/15/2025"))).unwrap();
206		assert_eq!(result, serde_json::json!("2025-01-15"));
207
208		// 2-digit year format (MM/DD/YY) is rejected to avoid ambiguity
209		assert!(field.clean(Some(&serde_json::json!("01/15/25"))).is_err());
210	}
211
212	#[test]
213	fn test_date_field_month_name_formats() {
214		let field = DateField::new("date".to_string());
215
216		// Abbreviated month (Jan 15 2025)
217		let result = field
218			.clean(Some(&serde_json::json!("Jan 15 2025")))
219			.unwrap();
220		assert_eq!(result, serde_json::json!("2025-01-15"));
221
222		// Abbreviated month with comma (Jan 15, 2025)
223		let result = field
224			.clean(Some(&serde_json::json!("Jan 15, 2025")))
225			.unwrap();
226		assert_eq!(result, serde_json::json!("2025-01-15"));
227
228		// Full month name (January 15 2025)
229		let result = field
230			.clean(Some(&serde_json::json!("January 15 2025")))
231			.unwrap();
232		assert_eq!(result, serde_json::json!("2025-01-15"));
233
234		// Full month with comma (January 15, 2025)
235		let result = field
236			.clean(Some(&serde_json::json!("January 15, 2025")))
237			.unwrap();
238		assert_eq!(result, serde_json::json!("2025-01-15"));
239	}
240
241	#[test]
242	fn test_date_field_day_first_formats() {
243		let field = DateField::new("date".to_string());
244
245		// Day first with abbreviated month (15 Jan 2025)
246		let result = field
247			.clean(Some(&serde_json::json!("15 Jan 2025")))
248			.unwrap();
249		assert_eq!(result, serde_json::json!("2025-01-15"));
250
251		// Day first with full month (15 January 2025)
252		let result = field
253			.clean(Some(&serde_json::json!("15 January 2025")))
254			.unwrap();
255		assert_eq!(result, serde_json::json!("2025-01-15"));
256	}
257
258	#[test]
259	fn test_date_field_invalid_format() {
260		let field = DateField::new("date".to_string());
261
262		// Invalid date format
263		assert!(field.clean(Some(&serde_json::json!("not a date"))).is_err());
264		assert!(field.clean(Some(&serde_json::json!("2025/13/01"))).is_err());
265		assert!(field.clean(Some(&serde_json::json!("2025-00-01"))).is_err());
266	}
267
268	#[test]
269	fn test_date_field_whitespace_trimming() {
270		let field = DateField::new("date".to_string());
271
272		// Should trim whitespace
273		let result = field
274			.clean(Some(&serde_json::json!("  2025-01-15  ")))
275			.unwrap();
276		assert_eq!(result, serde_json::json!("2025-01-15"));
277	}
278
279	#[test]
280	fn test_date_field_invalid_dates() {
281		let field = DateField::new("date".to_string());
282
283		// Invalid month
284		assert!(field.clean(Some(&serde_json::json!("2025-13-01"))).is_err());
285
286		// Invalid day
287		assert!(field.clean(Some(&serde_json::json!("2025-01-32"))).is_err());
288
289		// February 30th doesn't exist
290		assert!(field.clean(Some(&serde_json::json!("2025-02-30"))).is_err());
291	}
292
293	#[test]
294	fn test_date_field_leap_year() {
295		let field = DateField::new("date".to_string());
296
297		// Feb 29 in leap year (2024)
298		let result = field.clean(Some(&serde_json::json!("2024-02-29"))).unwrap();
299		assert_eq!(result, serde_json::json!("2024-02-29"));
300
301		// Feb 29 in non-leap year (2025)
302		assert!(field.clean(Some(&serde_json::json!("2025-02-29"))).is_err());
303	}
304
305	#[test]
306	fn test_date_field_localize() {
307		let field = DateField::new("date".to_string()).with_localize(true);
308		assert!(field.localize);
309	}
310
311	#[test]
312	fn test_date_field_locale() {
313		let field = DateField::new("date".to_string()).with_locale("en_US".to_string());
314		assert_eq!(field.locale, Some("en_US".to_string()));
315	}
316
317	#[test]
318	fn test_date_field_widget() {
319		let field = DateField::new("date".to_string());
320		assert!(matches!(field.widget(), &Widget::DateInput));
321	}
322
323	#[test]
324	fn test_date_field_name() {
325		let field = DateField::new("birth_date".to_string());
326		assert_eq!(field.name(), "birth_date");
327	}
328
329	#[rstest]
330	#[case("01/15/25")]
331	#[case("12/31/99")]
332	#[case("06/15/00")]
333	fn test_date_field_rejects_two_digit_years(#[case] input: &str) {
334		// Arrange
335		let field = DateField::new("date".to_string());
336
337		// Act
338		let result = field.clean(Some(&serde_json::json!(input)));
339
340		// Assert
341		assert!(
342			result.is_err(),
343			"Expected 2-digit year input '{}' to be rejected, got: {:?}",
344			input,
345			result,
346		);
347	}
348
349	#[rstest]
350	#[case("01/15/2025", "2025-01-15")]
351	#[case("12/31/1999", "1999-12-31")]
352	#[case("2024-02-29", "2024-02-29")]
353	fn test_date_field_accepts_four_digit_years(#[case] input: &str, #[case] expected: &str) {
354		// Arrange
355		let field = DateField::new("date".to_string());
356
357		// Act
358		let result = field.clean(Some(&serde_json::json!(input)));
359
360		// Assert
361		assert_eq!(
362			result.unwrap(),
363			serde_json::json!(expected),
364			"Expected 4-digit year input '{}' to parse as '{}'",
365			input,
366			expected,
367		);
368	}
369}