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).
30pub(crate) fn 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.
73pub(crate) const 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.
78pub(crate) const 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.
100pub(crate) fn 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).
117pub(crate) fn 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}