Skip to main content

fraiseql_core/validation/
date_validators.rs

1//! Date and time validation for input fields.
2//!
3//! This module provides validators for common date/time constraints:
4//! - minDate, maxDate: Date range constraints
5//! - minAge, maxAge: Age constraints (calculated from today)
6//! - maxDaysInFuture, minDaysInPast: Relative date constraints
7//!
8//! # Examples
9//!
10//! ```ignore
11//! // Validate birthdate is 18+ years old
12//! validate_min_age("1990-03-15", 18)?;
13//!
14//! // Validate date is not more than 30 days in the future
15//! validate_max_days_in_future("2026-03-10", 30)?;
16//!
17//! // Validate date is within range
18//! validate_date_range("2026-02-08", "2020-01-01", "2030-12-31")?;
19//! ```
20
21use std::cmp::Ordering;
22
23use crate::error::{FraiseQLError, Result};
24
25/// Parse a date string in ISO 8601 format (YYYY-MM-DD).
26fn parse_date(date_str: &str) -> Result<(u32, u32, u32)> {
27    let parts: Vec<&str> = date_str.split('-').collect();
28    if parts.len() != 3 {
29        return Err(FraiseQLError::Validation {
30            message: format!("Invalid date format: '{}'. Expected YYYY-MM-DD", date_str),
31            path:    None,
32        });
33    }
34
35    let year = parts[0].parse::<u32>().map_err(|_| FraiseQLError::Validation {
36        message: format!("Invalid year: '{}'", parts[0]),
37        path:    None,
38    })?;
39
40    let month = parts[1].parse::<u32>().map_err(|_| FraiseQLError::Validation {
41        message: format!("Invalid month: '{}'", parts[1]),
42        path:    None,
43    })?;
44
45    let day = parts[2].parse::<u32>().map_err(|_| FraiseQLError::Validation {
46        message: format!("Invalid day: '{}'", parts[2]),
47        path:    None,
48    })?;
49
50    if !(1..=12).contains(&month) {
51        return Err(FraiseQLError::Validation {
52            message: format!("Month must be between 1 and 12, got {}", month),
53            path:    None,
54        });
55    }
56
57    let days_in_month = get_days_in_month(month, year);
58    if !(1..=days_in_month).contains(&day) {
59        return Err(FraiseQLError::Validation {
60            message: format!("Day must be between 1 and {}, got {}", days_in_month, day),
61            path:    None,
62        });
63    }
64
65    Ok((year, month, day))
66}
67
68/// Check if a year is a leap year.
69fn is_leap_year(year: u32) -> bool {
70    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
71}
72
73/// Get the number of days in a month.
74fn get_days_in_month(month: u32, year: u32) -> u32 {
75    match month {
76        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
77        4 | 6 | 9 | 11 => 30,
78        2 => {
79            if is_leap_year(year) {
80                29
81            } else {
82                28
83            }
84        },
85        _ => 0,
86    }
87}
88
89/// Get today's date as (year, month, day).
90/// For testing purposes, this can be overridden.
91fn get_today() -> (u32, u32, u32) {
92    // In a real implementation, this would use chrono or std::time
93    // For now, we'll use a fixed date for testing consistency
94    (2026, 2, 8)
95}
96
97/// Compare two dates: -1 if left < right, 0 if equal, 1 if left > right.
98fn compare_dates(left: (u32, u32, u32), right: (u32, u32, u32)) -> i32 {
99    match left.0.cmp(&right.0) {
100        Ordering::Less => -1,
101        Ordering::Greater => 1,
102        Ordering::Equal => match left.1.cmp(&right.1) {
103            Ordering::Less => -1,
104            Ordering::Greater => 1,
105            Ordering::Equal => match left.2.cmp(&right.2) {
106                Ordering::Less => -1,
107                Ordering::Greater => 1,
108                Ordering::Equal => 0,
109            },
110        },
111    }
112}
113
114/// Calculate the number of days between two dates (left - right).
115fn days_between(left: (u32, u32, u32), right: (u32, u32, u32)) -> i64 {
116    // Simple day count from year 0 to avoid floating point
117    let days_left = i64::from(left.0) * 365 + i64::from(left.1) * 31 + i64::from(left.2);
118    let days_right = i64::from(right.0) * 365 + i64::from(right.1) * 31 + i64::from(right.2);
119    days_left - days_right
120}
121
122/// Validate that a date is >= minimum date.
123pub fn validate_min_date(date_str: &str, min_date_str: &str) -> Result<()> {
124    let date = parse_date(date_str)?;
125    let min_date = parse_date(min_date_str)?;
126
127    if compare_dates(date, min_date) < 0 {
128        return Err(FraiseQLError::Validation {
129            message: format!("Date '{}' must be >= '{}'", date_str, min_date_str),
130            path:    None,
131        });
132    }
133
134    Ok(())
135}
136
137/// Validate that a date is <= maximum date.
138pub fn validate_max_date(date_str: &str, max_date_str: &str) -> Result<()> {
139    let date = parse_date(date_str)?;
140    let max_date = parse_date(max_date_str)?;
141
142    if compare_dates(date, max_date) > 0 {
143        return Err(FraiseQLError::Validation {
144            message: format!("Date '{}' must be <= '{}'", date_str, max_date_str),
145            path:    None,
146        });
147    }
148
149    Ok(())
150}
151
152/// Validate that a date is within a range (inclusive).
153pub fn validate_date_range(date_str: &str, min_date_str: &str, max_date_str: &str) -> Result<()> {
154    validate_min_date(date_str, min_date_str)?;
155    validate_max_date(date_str, max_date_str)?;
156    Ok(())
157}
158
159/// Validate that a person is at least min_age years old.
160pub fn validate_min_age(date_str: &str, min_age: u32) -> Result<()> {
161    let birth_date = parse_date(date_str)?;
162    let today = get_today();
163
164    // Calculate age
165    let mut age = today.0 - birth_date.0;
166    if (today.1, today.2) < (birth_date.1, birth_date.2) {
167        age -= 1;
168    }
169
170    if age < min_age {
171        return Err(FraiseQLError::Validation {
172            message: format!("Age must be at least {} years old, got {}", min_age, age),
173            path:    None,
174        });
175    }
176
177    Ok(())
178}
179
180/// Validate that a person is at most max_age years old.
181pub fn validate_max_age(date_str: &str, max_age: u32) -> Result<()> {
182    let birth_date = parse_date(date_str)?;
183    let today = get_today();
184
185    // Calculate age
186    let mut age = today.0 - birth_date.0;
187    if (today.1, today.2) < (birth_date.1, birth_date.2) {
188        age -= 1;
189    }
190
191    if age > max_age {
192        return Err(FraiseQLError::Validation {
193            message: format!("Age must be at most {} years old, got {}", max_age, age),
194            path:    None,
195        });
196    }
197
198    Ok(())
199}
200
201/// Validate that a date is not more than max_days in the future.
202pub fn validate_max_days_in_future(date_str: &str, max_days: i64) -> Result<()> {
203    let date = parse_date(date_str)?;
204    let today = get_today();
205
206    let days_diff = days_between(date, today);
207    if days_diff > max_days {
208        return Err(FraiseQLError::Validation {
209            message: format!(
210                "Date '{}' cannot be more than {} days in the future",
211                date_str, max_days
212            ),
213            path:    None,
214        });
215    }
216
217    Ok(())
218}
219
220/// Validate that a date is not more than max_days in the past.
221pub fn validate_max_days_in_past(date_str: &str, max_days: i64) -> Result<()> {
222    let date = parse_date(date_str)?;
223    let today = get_today();
224
225    let days_diff = days_between(today, date);
226    if days_diff > max_days {
227        return Err(FraiseQLError::Validation {
228            message: format!(
229                "Date '{}' cannot be more than {} days in the past",
230                date_str, max_days
231            ),
232            path:    None,
233        });
234    }
235
236    Ok(())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_parse_date_valid() {
245        let result = parse_date("2026-02-08");
246        assert!(result.is_ok());
247        assert_eq!(result.unwrap(), (2026, 2, 8));
248    }
249
250    #[test]
251    fn test_parse_date_invalid_format() {
252        assert!(parse_date("2026/02/08").is_err());
253        assert!(parse_date("02-08-2026").is_err());
254    }
255
256    #[test]
257    fn test_parse_date_invalid_month() {
258        assert!(parse_date("2026-13-01").is_err());
259        assert!(parse_date("2026-00-01").is_err());
260    }
261
262    #[test]
263    fn test_parse_date_invalid_day() {
264        assert!(parse_date("2026-02-30").is_err());
265        assert!(parse_date("2026-04-31").is_err());
266    }
267
268    #[test]
269    fn test_leap_year_detection() {
270        assert!(is_leap_year(2024));
271        assert!(is_leap_year(2000));
272        assert!(!is_leap_year(1900));
273        assert!(!is_leap_year(2025));
274    }
275
276    #[test]
277    fn test_days_in_month() {
278        assert_eq!(get_days_in_month(1, 2026), 31);
279        assert_eq!(get_days_in_month(2, 2024), 29); // Leap year
280        assert_eq!(get_days_in_month(2, 2026), 28); // Non-leap year
281        assert_eq!(get_days_in_month(4, 2026), 30);
282    }
283
284    #[test]
285    fn test_compare_dates() {
286        assert!(compare_dates((2026, 2, 8), (2026, 2, 7)) > 0);
287        assert!(compare_dates((2026, 2, 7), (2026, 2, 8)) < 0);
288        assert_eq!(compare_dates((2026, 2, 8), (2026, 2, 8)), 0);
289        assert!(compare_dates((2026, 3, 1), (2026, 2, 28)) > 0);
290        assert!(compare_dates((2027, 1, 1), (2026, 12, 31)) > 0);
291    }
292
293    #[test]
294    fn test_min_date_passes() {
295        assert!(validate_min_date("2026-02-08", "2026-02-01").is_ok());
296        assert!(validate_min_date("2026-02-08", "2026-02-08").is_ok());
297    }
298
299    #[test]
300    fn test_min_date_fails() {
301        assert!(validate_min_date("2026-02-08", "2026-02-09").is_err());
302    }
303
304    #[test]
305    fn test_max_date_passes() {
306        assert!(validate_max_date("2026-02-08", "2026-02-15").is_ok());
307        assert!(validate_max_date("2026-02-08", "2026-02-08").is_ok());
308    }
309
310    #[test]
311    fn test_max_date_fails() {
312        assert!(validate_max_date("2026-02-08", "2026-02-07").is_err());
313    }
314
315    #[test]
316    fn test_date_range_passes() {
317        assert!(validate_date_range("2026-02-08", "2026-01-01", "2026-12-31").is_ok());
318    }
319
320    #[test]
321    fn test_date_range_fails_below_min() {
322        assert!(validate_date_range("2025-12-31", "2026-01-01", "2026-12-31").is_err());
323    }
324
325    #[test]
326    fn test_date_range_fails_above_max() {
327        assert!(validate_date_range("2027-01-01", "2026-01-01", "2026-12-31").is_err());
328    }
329
330    #[test]
331    fn test_min_age_passes() {
332        // Today is 2026-02-08, person born 2000-01-01 is 26 years old
333        assert!(validate_min_age("2000-01-01", 25).is_ok());
334        assert!(validate_min_age("2000-01-01", 26).is_ok());
335    }
336
337    #[test]
338    fn test_min_age_fails() {
339        // Person born 2010-03-15 is 15 years old (hasn't turned 16 yet)
340        assert!(validate_min_age("2010-03-15", 16).is_err());
341    }
342
343    #[test]
344    fn test_min_age_birthday_today() {
345        // Today is 2026-02-08, person born 2008-02-08 is exactly 18 years old
346        assert!(validate_min_age("2008-02-08", 18).is_ok());
347    }
348
349    #[test]
350    fn test_min_age_before_birthday_this_year() {
351        // Today is 2026-02-08, person born 2008-03-15 is 17 (not yet 18)
352        assert!(validate_min_age("2008-03-15", 18).is_err());
353    }
354
355    #[test]
356    fn test_max_age_passes() {
357        // Today is 2026-02-08, person born 2010-01-01 is 16 years old
358        assert!(validate_max_age("2010-01-01", 17).is_ok());
359        assert!(validate_max_age("2010-01-01", 16).is_ok());
360    }
361
362    #[test]
363    fn test_max_age_fails() {
364        // Person born 1990-01-01 is 36 years old
365        assert!(validate_max_age("1990-01-01", 35).is_err());
366    }
367
368    #[test]
369    fn test_max_days_in_future_passes() {
370        // 2026-02-08 (today) + 30 days = 2026-03-10
371        assert!(validate_max_days_in_future("2026-02-10", 30).is_ok());
372    }
373
374    #[test]
375    fn test_max_days_in_future_fails() {
376        // Date more than 30 days in future should fail
377        assert!(validate_max_days_in_future("2026-03-15", 30).is_err());
378    }
379
380    #[test]
381    fn test_max_days_in_past_passes() {
382        // 2026-02-08 (today) - 30 days = 2026-01-09
383        assert!(validate_max_days_in_past("2026-02-01", 30).is_ok());
384    }
385
386    #[test]
387    fn test_max_days_in_past_fails() {
388        // Date more than 30 days in past should fail
389        assert!(validate_max_days_in_past("2026-01-01", 30).is_err());
390    }
391
392    #[test]
393    fn test_days_between_same_date() {
394        assert_eq!(days_between((2026, 2, 8), (2026, 2, 8)), 0);
395    }
396
397    #[test]
398    fn test_days_between_year_difference() {
399        let diff = days_between((2027, 2, 8), (2026, 2, 8));
400        assert!(diff > 0);
401    }
402
403    #[test]
404    fn test_february_leap_year_edge_case() {
405        // 2024 is a leap year, so Feb has 29 days
406        assert!(parse_date("2024-02-29").is_ok());
407        assert!(parse_date("2024-02-30").is_err());
408    }
409
410    #[test]
411    fn test_february_non_leap_year_edge_case() {
412        // 2025 is not a leap year, so Feb has 28 days
413        assert!(parse_date("2025-02-28").is_ok());
414        assert!(parse_date("2025-02-29").is_err());
415    }
416
417    #[test]
418    fn test_year_2000_leap_year() {
419        // 2000 is divisible by 400, so it's a leap year
420        assert!(is_leap_year(2000));
421        assert!(parse_date("2000-02-29").is_ok());
422    }
423
424    #[test]
425    fn test_year_1900_not_leap_year() {
426        // 1900 is divisible by 100 but not 400, so not a leap year
427        assert!(!is_leap_year(1900));
428        assert!(parse_date("1900-02-29").is_err());
429    }
430
431    #[test]
432    fn test_age_calculation_before_birthday() {
433        // Today is 2026-02-08
434        // Person born 2000-05-15 is 25 (not yet 26)
435        assert!(validate_min_age("2000-05-15", 26).is_err());
436        assert!(validate_min_age("2000-05-15", 25).is_ok());
437    }
438
439    #[test]
440    fn test_age_calculation_after_birthday() {
441        // Today is 2026-02-08
442        // Person born 2000-01-15 is 26 (already had their birthday)
443        assert!(validate_min_age("2000-01-15", 26).is_ok());
444    }
445}