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//! ```
11//! use fraiseql_core::validation::{validate_min_age, validate_max_days_in_future, validate_date_range};
12//!
13//! // Validate birthdate is 18+ years old
14//! validate_min_age("1990-03-15", 18).unwrap();
15//!
16//! // Validate date is not more than 30 days in the future
17//! validate_max_days_in_future("2026-03-10", 30).unwrap();
18//!
19//! // Validate date is within range
20//! validate_date_range("2026-02-08", "2020-01-01", "2030-12-31").unwrap();
21//! ```
22
23use std::cmp::Ordering;
24
25use chrono::Datelike;
26
27use crate::error::{FraiseQLError, Result};
28
29/// Parse a date string in ISO 8601 format (YYYY-MM-DD).
30fn parse_date(date_str: &str) -> Result<(u32, u32, u32)> {
31    let parts: Vec<&str> = date_str.split('-').collect();
32    if parts.len() != 3 {
33        return Err(FraiseQLError::Validation {
34            message: format!("Invalid date format: '{}'. Expected YYYY-MM-DD", date_str),
35            path:    None,
36        });
37    }
38
39    let year = parts[0].parse::<u32>().map_err(|_| FraiseQLError::Validation {
40        message: format!("Invalid year: '{}'", parts[0]),
41        path:    None,
42    })?;
43
44    let month = parts[1].parse::<u32>().map_err(|_| FraiseQLError::Validation {
45        message: format!("Invalid month: '{}'", parts[1]),
46        path:    None,
47    })?;
48
49    let day = parts[2].parse::<u32>().map_err(|_| FraiseQLError::Validation {
50        message: format!("Invalid day: '{}'", parts[2]),
51        path:    None,
52    })?;
53
54    if !(1..=12).contains(&month) {
55        return Err(FraiseQLError::Validation {
56            message: format!("Month must be between 1 and 12, got {}", month),
57            path:    None,
58        });
59    }
60
61    let days_in_month = get_days_in_month(month, year);
62    if !(1..=days_in_month).contains(&day) {
63        return Err(FraiseQLError::Validation {
64            message: format!("Day must be between 1 and {}, got {}", days_in_month, day),
65            path:    None,
66        });
67    }
68
69    Ok((year, month, day))
70}
71
72/// Check if a year is a leap year.
73const fn is_leap_year(year: u32) -> bool {
74    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
75}
76
77/// Get the number of days in a month.
78const fn get_days_in_month(month: u32, year: u32) -> u32 {
79    match month {
80        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
81        4 | 6 | 9 | 11 => 30,
82        2 => {
83            if is_leap_year(year) {
84                29
85            } else {
86                28
87            }
88        },
89        _ => 0,
90    }
91}
92
93/// Get today's date as (year, month, day) in UTC.
94fn get_today() -> (u32, u32, u32) {
95    let today = chrono::Utc::now().date_naive();
96    (today.year_ce().1, today.month(), today.day())
97}
98
99/// Compare two dates: -1 if left < right, 0 if equal, 1 if left > right.
100fn compare_dates(left: (u32, u32, u32), right: (u32, u32, u32)) -> i32 {
101    match left.0.cmp(&right.0) {
102        Ordering::Less => -1,
103        Ordering::Greater => 1,
104        Ordering::Equal => match left.1.cmp(&right.1) {
105            Ordering::Less => -1,
106            Ordering::Greater => 1,
107            Ordering::Equal => match left.2.cmp(&right.2) {
108                Ordering::Less => -1,
109                Ordering::Greater => 1,
110                Ordering::Equal => 0,
111            },
112        },
113    }
114}
115
116/// Calculate the number of days between two dates (left - right).
117fn days_between(left: (u32, u32, u32), right: (u32, u32, u32)) -> i64 {
118    // Simple day count from year 0 to avoid floating point
119    let days_left = i64::from(left.0) * 365 + i64::from(left.1) * 31 + i64::from(left.2);
120    let days_right = i64::from(right.0) * 365 + i64::from(right.1) * 31 + i64::from(right.2);
121    days_left - days_right
122}
123
124/// Validate that a date is >= minimum date.
125///
126/// # Errors
127///
128/// Returns [`FraiseQLError::Validation`] if either date string is not valid
129/// ISO 8601 (YYYY-MM-DD) or if `date_str` is earlier than `min_date_str`.
130pub fn validate_min_date(date_str: &str, min_date_str: &str) -> Result<()> {
131    let date = parse_date(date_str)?;
132    let min_date = parse_date(min_date_str)?;
133
134    if compare_dates(date, min_date) < 0 {
135        return Err(FraiseQLError::Validation {
136            message: format!("Date '{}' must be >= '{}'", date_str, min_date_str),
137            path:    None,
138        });
139    }
140
141    Ok(())
142}
143
144/// Validate that a date is <= maximum date.
145///
146/// # Errors
147///
148/// Returns [`FraiseQLError::Validation`] if either date string is not valid
149/// ISO 8601 (YYYY-MM-DD) or if `date_str` is later than `max_date_str`.
150pub fn validate_max_date(date_str: &str, max_date_str: &str) -> Result<()> {
151    let date = parse_date(date_str)?;
152    let max_date = parse_date(max_date_str)?;
153
154    if compare_dates(date, max_date) > 0 {
155        return Err(FraiseQLError::Validation {
156            message: format!("Date '{}' must be <= '{}'", date_str, max_date_str),
157            path:    None,
158        });
159    }
160
161    Ok(())
162}
163
164/// Validate that a date is within a range (inclusive).
165///
166/// # Errors
167///
168/// Returns [`FraiseQLError::Validation`] if any date string is invalid, if
169/// `date_str` is before `min_date_str`, or if `date_str` is after `max_date_str`.
170pub fn validate_date_range(date_str: &str, min_date_str: &str, max_date_str: &str) -> Result<()> {
171    validate_min_date(date_str, min_date_str)?;
172    validate_max_date(date_str, max_date_str)?;
173    Ok(())
174}
175
176/// Validate that a person is at least `min_age` years old.
177///
178/// # Errors
179///
180/// Returns [`FraiseQLError::Validation`] if `date_str` is not valid ISO 8601
181/// (YYYY-MM-DD) or if the calculated age is less than `min_age`.
182pub fn validate_min_age(date_str: &str, min_age: u32) -> Result<()> {
183    let birth_date = parse_date(date_str)?;
184    let today = get_today();
185
186    // Calculate age
187    let mut age = today.0 - birth_date.0;
188    if (today.1, today.2) < (birth_date.1, birth_date.2) {
189        age -= 1;
190    }
191
192    if age < min_age {
193        return Err(FraiseQLError::Validation {
194            message: format!("Age must be at least {} years old, got {}", min_age, age),
195            path:    None,
196        });
197    }
198
199    Ok(())
200}
201
202/// Validate that a person is at most `max_age` years old.
203///
204/// # Errors
205///
206/// Returns [`FraiseQLError::Validation`] if `date_str` is not valid ISO 8601
207/// (YYYY-MM-DD) or if the calculated age exceeds `max_age`.
208pub fn validate_max_age(date_str: &str, max_age: u32) -> Result<()> {
209    let birth_date = parse_date(date_str)?;
210    let today = get_today();
211
212    // Calculate age
213    let mut age = today.0 - birth_date.0;
214    if (today.1, today.2) < (birth_date.1, birth_date.2) {
215        age -= 1;
216    }
217
218    if age > max_age {
219        return Err(FraiseQLError::Validation {
220            message: format!("Age must be at most {} years old, got {}", max_age, age),
221            path:    None,
222        });
223    }
224
225    Ok(())
226}
227
228/// Validate that a date is not more than `max_days` in the future.
229///
230/// # Errors
231///
232/// Returns [`FraiseQLError::Validation`] if `date_str` is not valid ISO 8601
233/// (YYYY-MM-DD) or if the date is more than `max_days` days in the future.
234pub fn validate_max_days_in_future(date_str: &str, max_days: i64) -> Result<()> {
235    let date = parse_date(date_str)?;
236    let today = get_today();
237
238    let days_diff = days_between(date, today);
239    if days_diff > max_days {
240        return Err(FraiseQLError::Validation {
241            message: format!(
242                "Date '{}' cannot be more than {} days in the future",
243                date_str, max_days
244            ),
245            path:    None,
246        });
247    }
248
249    Ok(())
250}
251
252/// Validate that a date is not more than `max_days` in the past.
253///
254/// # Errors
255///
256/// Returns [`FraiseQLError::Validation`] if `date_str` is not valid ISO 8601
257/// (YYYY-MM-DD) or if the date is more than `max_days` days in the past.
258pub fn validate_max_days_in_past(date_str: &str, max_days: i64) -> Result<()> {
259    let date = parse_date(date_str)?;
260    let today = get_today();
261
262    let days_diff = days_between(today, date);
263    if days_diff > max_days {
264        return Err(FraiseQLError::Validation {
265            message: format!(
266                "Date '{}' cannot be more than {} days in the past",
267                date_str, max_days
268            ),
269            path:    None,
270        });
271    }
272
273    Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
279
280    use chrono::Datelike;
281
282    use super::*;
283
284    // ── Helpers for time-independent tests ──────────────────────────────────
285
286    /// Returns "YYYY-MM-DD" for `years` years before today.
287    fn years_ago(years: u32) -> String {
288        let today = chrono::Utc::now().date_naive();
289        let y = today.year() - i32::try_from(years).unwrap_or(0);
290        format!("{y}-{:02}-{:02}", today.month(), today.day())
291    }
292
293    /// Returns "YYYY-MM-DD" for today.
294    fn today_str() -> String {
295        chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string()
296    }
297
298    // ── parse_date ───────────────────────────────────────────────────────────
299
300    #[test]
301    fn test_parse_date_valid() {
302        let result = parse_date("2026-02-08");
303        let parsed = result.unwrap_or_else(|e| panic!("valid date should parse: {e}"));
304        assert_eq!(parsed, (2026, 2, 8));
305    }
306
307    #[test]
308    fn test_parse_date_invalid_format() {
309        assert!(
310            matches!(parse_date("2026/02/08"), Err(FraiseQLError::Validation { .. })),
311            "slash-separated date should fail parsing"
312        );
313        assert!(
314            matches!(parse_date("02-08-2026"), Err(FraiseQLError::Validation { .. })),
315            "MM-DD-YYYY format should fail parsing"
316        );
317    }
318
319    #[test]
320    fn test_parse_date_invalid_month() {
321        assert!(
322            matches!(parse_date("2026-13-01"), Err(FraiseQLError::Validation { .. })),
323            "month 13 should fail validation"
324        );
325        assert!(
326            matches!(parse_date("2026-00-01"), Err(FraiseQLError::Validation { .. })),
327            "month 0 should fail validation"
328        );
329    }
330
331    #[test]
332    fn test_parse_date_invalid_day() {
333        assert!(
334            matches!(parse_date("2026-02-30"), Err(FraiseQLError::Validation { .. })),
335            "Feb 30 should fail validation"
336        );
337        assert!(
338            matches!(parse_date("2026-04-31"), Err(FraiseQLError::Validation { .. })),
339            "Apr 31 should fail validation"
340        );
341    }
342
343    // ── leap year / days in month ────────────────────────────────────────────
344
345    #[test]
346    fn test_leap_year_detection() {
347        assert!(is_leap_year(2024));
348        assert!(is_leap_year(2000));
349        assert!(!is_leap_year(1900));
350        assert!(!is_leap_year(2025));
351    }
352
353    #[test]
354    fn test_days_in_month() {
355        assert_eq!(get_days_in_month(1, 2026), 31);
356        assert_eq!(get_days_in_month(2, 2024), 29); // Leap year
357        assert_eq!(get_days_in_month(2, 2026), 28); // Non-leap year
358        assert_eq!(get_days_in_month(4, 2026), 30);
359    }
360
361    #[test]
362    fn test_february_leap_year_edge_case() {
363        parse_date("2024-02-29")
364            .unwrap_or_else(|e| panic!("Feb 29 on leap year should parse: {e}"));
365        assert!(
366            matches!(parse_date("2024-02-30"), Err(FraiseQLError::Validation { .. })),
367            "Feb 30 on leap year should fail"
368        );
369    }
370
371    #[test]
372    fn test_february_non_leap_year_edge_case() {
373        parse_date("2025-02-28")
374            .unwrap_or_else(|e| panic!("Feb 28 on non-leap year should parse: {e}"));
375        assert!(
376            matches!(parse_date("2025-02-29"), Err(FraiseQLError::Validation { .. })),
377            "Feb 29 on non-leap year should fail"
378        );
379    }
380
381    #[test]
382    fn test_year_2000_leap_year() {
383        assert!(is_leap_year(2000));
384        parse_date("2000-02-29").unwrap_or_else(|e| panic!("Feb 29 in 2000 should parse: {e}"));
385    }
386
387    #[test]
388    fn test_year_1900_not_leap_year() {
389        assert!(!is_leap_year(1900));
390        assert!(
391            matches!(parse_date("1900-02-29"), Err(FraiseQLError::Validation { .. })),
392            "Feb 29 in 1900 (not leap) should fail"
393        );
394    }
395
396    // ── compare_dates / days_between ────────────────────────────────────────
397
398    #[test]
399    fn test_compare_dates() {
400        assert!(compare_dates((2026, 2, 8), (2026, 2, 7)) > 0);
401        assert!(compare_dates((2026, 2, 7), (2026, 2, 8)) < 0);
402        assert_eq!(compare_dates((2026, 2, 8), (2026, 2, 8)), 0);
403        assert!(compare_dates((2026, 3, 1), (2026, 2, 28)) > 0);
404        assert!(compare_dates((2027, 1, 1), (2026, 12, 31)) > 0);
405    }
406
407    #[test]
408    fn test_days_between_same_date() {
409        assert_eq!(days_between((2026, 2, 8), (2026, 2, 8)), 0);
410    }
411
412    #[test]
413    fn test_days_between_year_difference() {
414        let diff = days_between((2027, 2, 8), (2026, 2, 8));
415        assert!(diff > 0);
416    }
417
418    // ── validate_min_date / validate_max_date / validate_date_range ─────────
419
420    #[test]
421    fn test_min_date_passes() {
422        validate_min_date("2026-02-08", "2026-02-01")
423            .unwrap_or_else(|e| panic!("date after min should pass: {e}"));
424        validate_min_date("2026-02-08", "2026-02-08")
425            .unwrap_or_else(|e| panic!("date equal to min should pass: {e}"));
426    }
427
428    #[test]
429    fn test_min_date_fails() {
430        let result = validate_min_date("2026-02-08", "2026-02-09");
431        assert!(
432            matches!(result, Err(FraiseQLError::Validation { .. })),
433            "date before min should fail, got: {result:?}"
434        );
435    }
436
437    #[test]
438    fn test_max_date_passes() {
439        validate_max_date("2026-02-08", "2026-02-15")
440            .unwrap_or_else(|e| panic!("date before max should pass: {e}"));
441        validate_max_date("2026-02-08", "2026-02-08")
442            .unwrap_or_else(|e| panic!("date equal to max should pass: {e}"));
443    }
444
445    #[test]
446    fn test_max_date_fails() {
447        let result = validate_max_date("2026-02-08", "2026-02-07");
448        assert!(
449            matches!(result, Err(FraiseQLError::Validation { .. })),
450            "date after max should fail, got: {result:?}"
451        );
452    }
453
454    #[test]
455    fn test_date_range_passes() {
456        validate_date_range("2026-02-08", "2026-01-01", "2026-12-31")
457            .unwrap_or_else(|e| panic!("date within range should pass: {e}"));
458    }
459
460    #[test]
461    fn test_date_range_fails_below_min() {
462        let result = validate_date_range("2025-12-31", "2026-01-01", "2026-12-31");
463        assert!(
464            matches!(result, Err(FraiseQLError::Validation { .. })),
465            "date below range should fail, got: {result:?}"
466        );
467    }
468
469    #[test]
470    fn test_date_range_fails_above_max() {
471        let result = validate_date_range("2027-01-01", "2026-01-01", "2026-12-31");
472        assert!(
473            matches!(result, Err(FraiseQLError::Validation { .. })),
474            "date above range should fail, got: {result:?}"
475        );
476    }
477
478    // ── validate_min_age / validate_max_age (time-independent) ──────────────
479
480    #[test]
481    fn test_min_age_passes_clearly_old_enough() {
482        // Born 50 years ago: definitely passes min_age = 18
483        validate_min_age(&years_ago(50), 18)
484            .unwrap_or_else(|e| panic!("50yo should pass min_age=18: {e}"));
485    }
486
487    #[test]
488    fn test_min_age_fails_too_young() {
489        // Born 5 years ago: cannot pass min_age = 18
490        let result = validate_min_age(&years_ago(5), 18);
491        assert!(
492            matches!(result, Err(FraiseQLError::Validation { .. })),
493            "5yo should fail min_age=18, got: {result:?}"
494        );
495    }
496
497    #[test]
498    fn test_min_age_birthday_today_exactly_18() {
499        // Born exactly 18 years ago today → passes min_age = 18
500        validate_min_age(&years_ago(18), 18)
501            .unwrap_or_else(|e| panic!("exactly 18yo should pass min_age=18: {e}"));
502    }
503
504    #[test]
505    fn test_max_age_passes_clearly_young_enough() {
506        // Born 5 years ago: definitely passes max_age = 18
507        validate_max_age(&years_ago(5), 18)
508            .unwrap_or_else(|e| panic!("5yo should pass max_age=18: {e}"));
509    }
510
511    #[test]
512    fn test_max_age_fails_too_old() {
513        // Born 100 years ago: cannot pass max_age = 90
514        let result = validate_max_age(&years_ago(100), 90);
515        assert!(
516            matches!(result, Err(FraiseQLError::Validation { .. })),
517            "100yo should fail max_age=90, got: {result:?}"
518        );
519    }
520
521    // ── validate_max_days_in_future / validate_max_days_in_past ─────────────
522
523    #[test]
524    fn test_max_days_in_future_today_passes() {
525        // Today is 0 days in the future — always passes
526        validate_max_days_in_future(&today_str(), 0)
527            .unwrap_or_else(|e| panic!("today should pass max_days_in_future=0: {e}"));
528    }
529
530    #[test]
531    fn test_max_days_in_future_past_date_passes() {
532        // A date in 2000 is never in the future
533        validate_max_days_in_future("2000-01-01", 0)
534            .unwrap_or_else(|e| panic!("past date should pass max_days_in_future: {e}"));
535    }
536
537    #[test]
538    fn test_max_days_in_future_far_future_fails() {
539        // Year 9999 is always more than 30 days in the future
540        let result = validate_max_days_in_future("9999-12-31", 30);
541        assert!(
542            matches!(result, Err(FraiseQLError::Validation { .. })),
543            "year 9999 should fail max_days_in_future=30, got: {result:?}"
544        );
545    }
546
547    #[test]
548    fn test_max_days_in_past_today_passes() {
549        // Today is 0 days in the past — always passes
550        validate_max_days_in_past(&today_str(), 0)
551            .unwrap_or_else(|e| panic!("today should pass max_days_in_past=0: {e}"));
552    }
553
554    #[test]
555    fn test_max_days_in_past_far_past_fails() {
556        // A date 50 years ago is more than 30 days in the past
557        let result = validate_max_days_in_past(&years_ago(50), 30);
558        assert!(
559            matches!(result, Err(FraiseQLError::Validation { .. })),
560            "50 years ago should fail max_days_in_past=30, got: {result:?}"
561        );
562    }
563}