1use std::cmp::Ordering;
24
25use chrono::Datelike;
26
27use crate::error::{FraiseQLError, Result};
28
29fn 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
72const 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
77const 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
93fn get_today() -> (u32, u32, u32) {
95 let today = chrono::Utc::now().date_naive();
96 (today.year_ce().1, today.month(), today.day())
97}
98
99fn 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
116fn days_between(left: (u32, u32, u32), right: (u32, u32, u32)) -> i64 {
118 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
124pub 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
144pub 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
164pub 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
176pub 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 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
202pub 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 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
228pub 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
252pub 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)] use chrono::Datelike;
281
282 use super::*;
283
284 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 fn today_str() -> String {
295 chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string()
296 }
297
298 #[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 #[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); assert_eq!(get_days_in_month(2, 2026), 28); 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 #[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 #[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 #[test]
481 fn test_min_age_passes_clearly_old_enough() {
482 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 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 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 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 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 #[test]
524 fn test_max_days_in_future_today_passes() {
525 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 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 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 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 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}