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