1use chrono::{
11 Datelike, Duration, IsoWeek, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike,
12 Weekday,
13};
14use regex::Regex;
15use thiserror::Error;
16
17#[derive(Debug, Error, PartialEq, Eq)]
19pub enum DateMathError {
20 #[error("invalid date math expression: {0}")]
21 InvalidExpression(String),
22
23 #[error("invalid duration unit: {0}")]
24 InvalidUnit(String),
25
26 #[error("invalid number in expression: {0}")]
27 InvalidNumber(String),
28
29 #[error("invalid weekday: {0}")]
30 InvalidWeekday(String),
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DateBase {
36 Today,
38 Now,
40 Time,
42 Date,
44 Week,
46 Year,
48 Literal(NaiveDate),
50 WeekStart,
52 WeekEnd,
54 IsoWeek { year: i32, week: u32 },
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum DateOffset {
61 None,
63 Duration { amount: i64, unit: DurationUnit },
65 Weekday { weekday: Weekday, direction: Direction },
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DurationUnit {
72 Minutes,
73 Hours,
74 Days,
75 Weeks,
76 Months,
77 Years,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Direction {
83 Previous, Next, }
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct DateExpr {
90 pub base: DateBase,
91 pub offset: DateOffset,
92 pub format: Option<String>,
93}
94
95pub fn parse_date_expr(input: &str) -> Result<DateExpr, DateMathError> {
103 let input = input.trim();
104
105 let (expr_part, format) = if let Some(idx) = input.find('|') {
107 let (e, f) = input.split_at(idx);
108 (e.trim(), Some(f[1..].trim().to_string()))
109 } else {
110 (input, None)
111 };
112
113 let re = Regex::new(r"^([\w-]+)\s*([+-])?\s*(\w+)?$").expect("valid regex");
117
118 if let Some(caps) = re.captures(expr_part) {
119 let base_str = &caps[1];
120 let base = parse_base(base_str)?;
121
122 let offset = if let (Some(op), Some(operand)) = (caps.get(2), caps.get(3)) {
123 let op_str = op.as_str();
124 let operand_str = operand.as_str();
125 parse_offset(op_str, operand_str)?
126 } else {
127 DateOffset::None
128 };
129
130 Ok(DateExpr { base, offset, format })
131 } else {
132 Err(DateMathError::InvalidExpression(input.to_string()))
133 }
134}
135
136fn parse_base(s: &str) -> Result<DateBase, DateMathError> {
137 match s.to_lowercase().as_str() {
138 "today" => Ok(DateBase::Today),
139 "now" => Ok(DateBase::Now),
140 "time" => Ok(DateBase::Time),
141 "date" => Ok(DateBase::Date),
142 "week" => Ok(DateBase::Week),
143 "year" => Ok(DateBase::Year),
144 "week_start" => Ok(DateBase::WeekStart),
145 "week_end" => Ok(DateBase::WeekEnd),
146 _ => {
147 if let Some(iso_week) = parse_iso_week_notation(s) {
149 return Ok(iso_week);
150 }
151 if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
153 return Ok(DateBase::Literal(date));
154 }
155 Err(DateMathError::InvalidExpression(format!("unknown base: {s}")))
156 }
157 }
158}
159
160fn parse_iso_week_notation(s: &str) -> Option<DateBase> {
162 let re = Regex::new(r"^(\d{4})-[Ww](\d{1,2})$").expect("valid regex");
163 if let Some(caps) = re.captures(s) {
164 let year: i32 = caps[1].parse().ok()?;
165 let week: u32 = caps[2].parse().ok()?;
166 if (1..=53).contains(&week) {
168 return Some(DateBase::IsoWeek { year, week });
169 }
170 }
171 None
172}
173
174fn parse_offset(op: &str, operand: &str) -> Result<DateOffset, DateMathError> {
175 let direction = match op {
176 "+" => Direction::Next,
177 "-" => Direction::Previous,
178 _ => {
179 return Err(DateMathError::InvalidExpression(format!(
180 "invalid operator: {op}"
181 )));
182 }
183 };
184
185 if let Ok(weekday) = parse_weekday(operand) {
187 return Ok(DateOffset::Weekday { weekday, direction });
188 }
189
190 let re = Regex::new(r"^(\d+)([dmMyhwY])$").expect("valid regex");
192 if let Some(caps) = re.captures(operand) {
193 let amount: i64 = caps[1]
194 .parse()
195 .map_err(|_| DateMathError::InvalidNumber(caps[1].to_string()))?;
196
197 let unit = match &caps[2] {
198 "m" => DurationUnit::Minutes,
199 "h" => DurationUnit::Hours,
200 "d" => DurationUnit::Days,
201 "w" => DurationUnit::Weeks,
202 "M" => DurationUnit::Months,
203 "y" | "Y" => DurationUnit::Years,
204 u => return Err(DateMathError::InvalidUnit(u.to_string())),
205 };
206
207 let signed_amount = match direction {
208 Direction::Next => amount,
209 Direction::Previous => -amount,
210 };
211
212 return Ok(DateOffset::Duration { amount: signed_amount, unit });
213 }
214
215 Err(DateMathError::InvalidExpression(format!("invalid offset: {operand}")))
216}
217
218fn parse_weekday(s: &str) -> Result<Weekday, DateMathError> {
219 match s.to_lowercase().as_str() {
220 "monday" | "mon" => Ok(Weekday::Mon),
221 "tuesday" | "tue" => Ok(Weekday::Tue),
222 "wednesday" | "wed" => Ok(Weekday::Wed),
223 "thursday" | "thu" => Ok(Weekday::Thu),
224 "friday" | "fri" => Ok(Weekday::Fri),
225 "saturday" | "sat" => Ok(Weekday::Sat),
226 "sunday" | "sun" => Ok(Weekday::Sun),
227 _ => Err(DateMathError::InvalidWeekday(s.to_string())),
228 }
229}
230
231pub fn evaluate_date_expr(expr: &DateExpr) -> String {
233 let now = Local::now();
234 let today = now.date_naive();
235 let current_time = now.time();
236
237 match expr.base {
238 DateBase::Today | DateBase::Date => {
239 let date = apply_date_offset(today, &expr.offset);
240 format_date(date, expr.format.as_deref())
241 }
242 DateBase::Now => {
243 let datetime = apply_datetime_offset(now.naive_local(), &expr.offset);
244 format_datetime(datetime, expr.format.as_deref())
245 }
246 DateBase::Time => {
247 let time = apply_time_offset(current_time, &expr.offset);
248 format_time(time, expr.format.as_deref())
249 }
250 DateBase::Week => {
251 let date = apply_date_offset(today, &expr.offset);
252 format_week(date.iso_week(), expr.format.as_deref())
253 }
254 DateBase::Year => {
255 let date = apply_date_offset(today, &expr.offset);
256 format_year(date, expr.format.as_deref())
257 }
258 DateBase::Literal(base_date) => {
259 let date = apply_date_offset(base_date, &expr.offset);
260 format_date(date, expr.format.as_deref())
261 }
262 DateBase::WeekStart => {
263 let monday = get_week_start(today);
264 let date = apply_date_offset(monday, &expr.offset);
265 format_date(date, expr.format.as_deref())
266 }
267 DateBase::WeekEnd => {
268 let sunday = get_week_end(today);
269 let date = apply_date_offset(sunday, &expr.offset);
270 format_date(date, expr.format.as_deref())
271 }
272 DateBase::IsoWeek { year, week } => {
273 let monday =
275 NaiveDate::from_isoywd_opt(year, week, Weekday::Mon).unwrap_or(today);
276 let date = apply_date_offset(monday, &expr.offset);
277 format_date(date, expr.format.as_deref())
278 }
279 }
280}
281
282fn get_week_start(date: NaiveDate) -> NaiveDate {
284 let days_from_monday = date.weekday().num_days_from_monday() as i64;
285 date - Duration::days(days_from_monday)
286}
287
288fn get_week_end(date: NaiveDate) -> NaiveDate {
290 let days_to_sunday = 6 - date.weekday().num_days_from_monday() as i64;
291 date + Duration::days(days_to_sunday)
292}
293
294fn apply_date_offset(date: NaiveDate, offset: &DateOffset) -> NaiveDate {
295 match offset {
296 DateOffset::None => date,
297 DateOffset::Duration { amount, unit } => match unit {
298 DurationUnit::Days => date + Duration::days(*amount),
299 DurationUnit::Weeks => date + Duration::weeks(*amount),
300 DurationUnit::Months => add_months(date, *amount),
301 DurationUnit::Years => add_months(date, amount * 12),
302 DurationUnit::Hours | DurationUnit::Minutes => date, },
304 DateOffset::Weekday { weekday, direction } => {
305 find_relative_weekday(date, *weekday, *direction)
306 }
307 }
308}
309
310fn apply_datetime_offset(dt: NaiveDateTime, offset: &DateOffset) -> NaiveDateTime {
311 match offset {
312 DateOffset::None => dt,
313 DateOffset::Duration { amount, unit } => match unit {
314 DurationUnit::Minutes => dt + Duration::minutes(*amount),
315 DurationUnit::Hours => dt + Duration::hours(*amount),
316 DurationUnit::Days => dt + Duration::days(*amount),
317 DurationUnit::Weeks => dt + Duration::weeks(*amount),
318 DurationUnit::Months => {
319 let new_date = add_months(dt.date(), *amount);
320 NaiveDateTime::new(new_date, dt.time())
321 }
322 DurationUnit::Years => {
323 let new_date = add_months(dt.date(), amount * 12);
324 NaiveDateTime::new(new_date, dt.time())
325 }
326 },
327 DateOffset::Weekday { weekday, direction } => {
328 let new_date = find_relative_weekday(dt.date(), *weekday, *direction);
329 NaiveDateTime::new(new_date, dt.time())
330 }
331 }
332}
333
334fn apply_time_offset(time: NaiveTime, offset: &DateOffset) -> NaiveTime {
335 match offset {
336 DateOffset::None => time,
337 DateOffset::Duration { amount, unit } => match unit {
338 DurationUnit::Minutes => {
339 let secs = time.num_seconds_from_midnight() as i64 + amount * 60;
340 let normalized = secs.rem_euclid(86400) as u32;
341 NaiveTime::from_num_seconds_from_midnight_opt(normalized, 0)
342 .unwrap_or(time)
343 }
344 DurationUnit::Hours => {
345 let secs = time.num_seconds_from_midnight() as i64 + amount * 3600;
346 let normalized = secs.rem_euclid(86400) as u32;
347 NaiveTime::from_num_seconds_from_midnight_opt(normalized, 0)
348 .unwrap_or(time)
349 }
350 _ => time, },
352 DateOffset::Weekday { .. } => time, }
354}
355
356fn add_months(date: NaiveDate, months: i64) -> NaiveDate {
357 let year = date.year() as i64;
358 let month = date.month() as i64;
359 let day = date.day();
360
361 let total_months = year * 12 + month - 1 + months;
362 let new_year = (total_months / 12) as i32;
363 let new_month = (total_months % 12 + 1) as u32;
364
365 let max_day = days_in_month(new_year, new_month);
367 let new_day = day.min(max_day);
368
369 NaiveDate::from_ymd_opt(new_year, new_month, new_day).unwrap_or(date)
370}
371
372fn days_in_month(year: i32, month: u32) -> u32 {
373 match month {
374 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
375 4 | 6 | 9 | 11 => 30,
376 2 => {
377 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
378 29
379 } else {
380 28
381 }
382 }
383 _ => 30,
384 }
385}
386
387fn find_relative_weekday(
388 date: NaiveDate,
389 target: Weekday,
390 direction: Direction,
391) -> NaiveDate {
392 let current = date.weekday();
393
394 match direction {
395 Direction::Previous => {
396 let days_diff = (current.num_days_from_monday() as i64
398 - target.num_days_from_monday() as i64
399 + 7)
400 % 7;
401 let days_back = if days_diff == 0 { 7 } else { days_diff };
402 date - Duration::days(days_back)
403 }
404 Direction::Next => {
405 let days_diff = (target.num_days_from_monday() as i64
407 - current.num_days_from_monday() as i64
408 + 7)
409 % 7;
410 let days_forward = if days_diff == 0 { 7 } else { days_diff };
411 date + Duration::days(days_forward)
412 }
413 }
414}
415
416fn format_date(date: NaiveDate, format: Option<&str>) -> String {
417 let fmt = format.unwrap_or("%Y-%m-%d");
418 date.format(fmt).to_string()
419}
420
421fn format_datetime(dt: NaiveDateTime, format: Option<&str>) -> String {
422 let fmt = format.unwrap_or("%Y-%m-%dT%H:%M:%S");
423 dt.format(fmt).to_string()
424}
425
426fn format_time(time: NaiveTime, format: Option<&str>) -> String {
427 let fmt = format.unwrap_or("%H:%M");
428 time.format(fmt).to_string()
429}
430
431fn format_week(week: IsoWeek, format: Option<&str>) -> String {
432 match format {
433 Some(fmt) => {
436 let date = NaiveDate::from_isoywd_opt(week.year(), week.week(), Weekday::Mon)
438 .unwrap_or_else(|| Local::now().date_naive());
439 date.format(fmt).to_string()
440 }
441 None => week.week().to_string(),
443 }
444}
445
446fn format_year(date: NaiveDate, format: Option<&str>) -> String {
447 let fmt = format.unwrap_or("%Y");
448 date.format(fmt).to_string()
449}
450
451fn looks_like_iso_date(s: &str) -> bool {
453 if s.len() < 10 {
455 return false;
456 }
457 let bytes = s.as_bytes();
458 bytes[0].is_ascii_digit()
460 && bytes[1].is_ascii_digit()
461 && bytes[2].is_ascii_digit()
462 && bytes[3].is_ascii_digit()
463 && bytes[4] == b'-'
464 && bytes[5].is_ascii_digit()
465 && bytes[6].is_ascii_digit()
466 && bytes[7] == b'-'
467 && bytes[8].is_ascii_digit()
468 && bytes[9].is_ascii_digit()
469}
470
471fn looks_like_iso_week(s: &str) -> bool {
473 if s.len() < 7 {
475 return false;
476 }
477 let bytes = s.as_bytes();
478 bytes[0].is_ascii_digit()
480 && bytes[1].is_ascii_digit()
481 && bytes[2].is_ascii_digit()
482 && bytes[3].is_ascii_digit()
483 && bytes[4] == b'-'
484 && (bytes[5] == b'W' || bytes[5] == b'w')
485 && bytes[6].is_ascii_digit()
486 && (s.len() == 7 || (s.len() >= 8 && bytes[7].is_ascii_digit()))
487}
488
489pub fn is_date_expr(s: &str) -> bool {
495 let s = s.trim();
496 let lower = s.to_lowercase();
497
498 if lower.starts_with("today")
501 || lower.starts_with("now")
502 || lower.starts_with("time")
503 || lower.starts_with("date")
504 || lower.starts_with("week")
505 || lower.starts_with("year")
506 {
507 return true;
508 }
509
510 let base_part = if let Some(idx) = s.find(['+', '|']) {
512 s[..idx].trim()
513 } else if let Some(idx) = s.rfind(" -") {
514 s[..idx].trim()
516 } else {
517 s
518 };
519
520 looks_like_iso_date(base_part) || looks_like_iso_week(base_part)
522}
523
524pub fn try_evaluate_date_expr(s: &str) -> Option<String> {
526 if is_date_expr(s) {
527 parse_date_expr(s).ok().map(|e| evaluate_date_expr(&e))
528 } else {
529 None
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn test_parse_simple_today() {
539 let expr = parse_date_expr("today").unwrap();
540 assert_eq!(expr.base, DateBase::Today);
541 assert_eq!(expr.offset, DateOffset::None);
542 assert!(expr.format.is_none());
543 }
544
545 #[test]
546 fn test_parse_today_plus_days() {
547 let expr = parse_date_expr("today + 1d").unwrap();
548 assert_eq!(expr.base, DateBase::Today);
549 assert_eq!(
550 expr.offset,
551 DateOffset::Duration { amount: 1, unit: DurationUnit::Days }
552 );
553 }
554
555 #[test]
556 fn test_parse_today_minus_weeks() {
557 let expr = parse_date_expr("today - 2w").unwrap();
558 assert_eq!(expr.base, DateBase::Today);
559 assert_eq!(
560 expr.offset,
561 DateOffset::Duration { amount: -2, unit: DurationUnit::Weeks }
562 );
563 }
564
565 #[test]
566 fn test_parse_now_with_format() {
567 let expr = parse_date_expr("now | %H:%M").unwrap();
568 assert_eq!(expr.base, DateBase::Now);
569 assert_eq!(expr.format, Some("%H:%M".to_string()));
570 }
571
572 #[test]
573 fn test_parse_weekday_previous() {
574 let expr = parse_date_expr("today - monday").unwrap();
575 assert_eq!(expr.base, DateBase::Today);
576 assert_eq!(
577 expr.offset,
578 DateOffset::Weekday { weekday: Weekday::Mon, direction: Direction::Previous }
579 );
580 }
581
582 #[test]
583 fn test_parse_weekday_next() {
584 let expr = parse_date_expr("today + friday").unwrap();
585 assert_eq!(expr.base, DateBase::Today);
586 assert_eq!(
587 expr.offset,
588 DateOffset::Weekday { weekday: Weekday::Fri, direction: Direction::Next }
589 );
590 }
591
592 #[test]
593 fn test_parse_months() {
594 let expr = parse_date_expr("today + 3M").unwrap();
595 assert_eq!(
596 expr.offset,
597 DateOffset::Duration { amount: 3, unit: DurationUnit::Months }
598 );
599 }
600
601 #[test]
602 fn test_parse_hours() {
603 let expr = parse_date_expr("now + 2h").unwrap();
604 assert_eq!(
605 expr.offset,
606 DateOffset::Duration { amount: 2, unit: DurationUnit::Hours }
607 );
608 }
609
610 #[test]
611 fn test_evaluate_today() {
612 let expr =
613 DateExpr { base: DateBase::Today, offset: DateOffset::None, format: None };
614 let result = evaluate_date_expr(&expr);
615 assert!(result.len() == 10);
617 assert!(result.chars().nth(4) == Some('-'));
618 }
619
620 #[test]
621 fn test_evaluate_today_plus_one_day() {
622 let expr = parse_date_expr("today + 1d").unwrap();
623 let result = evaluate_date_expr(&expr);
624
625 let today = Local::now().date_naive();
626 let tomorrow = today + Duration::days(1);
627 assert_eq!(result, tomorrow.format("%Y-%m-%d").to_string());
628 }
629
630 #[test]
631 fn test_evaluate_with_format() {
632 let expr = parse_date_expr("today | %A").unwrap();
633 let result = evaluate_date_expr(&expr);
634 let valid_days = [
636 "Monday",
637 "Tuesday",
638 "Wednesday",
639 "Thursday",
640 "Friday",
641 "Saturday",
642 "Sunday",
643 ];
644 assert!(valid_days.contains(&result.as_str()));
645 }
646
647 #[test]
648 fn test_add_months_overflow() {
649 let date = NaiveDate::from_ymd_opt(2023, 1, 31).unwrap();
651 let result = add_months(date, 1);
652 assert_eq!(result, NaiveDate::from_ymd_opt(2023, 2, 28).unwrap());
653 }
654
655 #[test]
656 fn test_add_months_leap_year() {
657 let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
659 let result = add_months(date, 1);
660 assert_eq!(result, NaiveDate::from_ymd_opt(2024, 2, 29).unwrap());
661 }
662
663 #[test]
664 fn test_is_date_expr() {
665 assert!(is_date_expr("today"));
666 assert!(is_date_expr("TODAY"));
667 assert!(is_date_expr("today + 1d"));
668 assert!(is_date_expr("now"));
669 assert!(is_date_expr("time - 2h"));
670 assert!(!is_date_expr("some_var"));
671 assert!(!is_date_expr("{{today}}"));
672 }
673
674 #[test]
675 fn test_try_evaluate() {
676 assert!(try_evaluate_date_expr("today").is_some());
677 assert!(try_evaluate_date_expr("not_a_date").is_none());
678 }
679
680 #[test]
681 fn test_parse_week() {
682 let expr = parse_date_expr("week").unwrap();
683 assert_eq!(expr.base, DateBase::Week);
684 assert_eq!(expr.offset, DateOffset::None);
685 }
686
687 #[test]
688 fn test_evaluate_week() {
689 let expr = parse_date_expr("week").unwrap();
690 let result = evaluate_date_expr(&expr);
691 let week_num: u32 = result.parse().unwrap();
693 assert!((1..=53).contains(&week_num));
694 }
695
696 #[test]
697 fn test_evaluate_week_with_format() {
698 let expr = parse_date_expr("week | %Y-W%V").unwrap();
699 let result = evaluate_date_expr(&expr);
700 assert!(result.contains("-W"));
702 assert!(result.len() >= 8); }
704
705 #[test]
706 fn test_week_with_offset() {
707 let expr = parse_date_expr("week + 1w").unwrap();
708 let result = evaluate_date_expr(&expr);
709 let week_num: u32 = result.parse().unwrap();
711 assert!((1..=53).contains(&week_num));
712 }
713
714 #[test]
715 fn test_parse_year() {
716 let expr = parse_date_expr("year").unwrap();
717 assert_eq!(expr.base, DateBase::Year);
718 }
719
720 #[test]
721 fn test_evaluate_year() {
722 let expr = parse_date_expr("year").unwrap();
723 let result = evaluate_date_expr(&expr);
724 assert_eq!(result.len(), 4);
726 let year: i32 = result.parse().unwrap();
727 assert!((2020..=2100).contains(&year));
728 }
729
730 #[test]
731 fn test_is_date_expr_week_year() {
732 assert!(is_date_expr("week"));
733 assert!(is_date_expr("WEEK"));
734 assert!(is_date_expr("week + 1w"));
735 assert!(is_date_expr("year"));
736 assert!(is_date_expr("year - 1y"));
737 }
738
739 #[test]
742 fn test_parse_iso_date_literal() {
743 let expr = parse_date_expr("2025-01-15").unwrap();
744 assert_eq!(
745 expr.base,
746 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
747 );
748 assert_eq!(expr.offset, DateOffset::None);
749 assert!(expr.format.is_none());
750 }
751
752 #[test]
753 fn test_parse_iso_date_with_offset() {
754 let expr = parse_date_expr("2025-01-15 + 7d").unwrap();
755 assert_eq!(
756 expr.base,
757 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
758 );
759 assert_eq!(
760 expr.offset,
761 DateOffset::Duration { amount: 7, unit: DurationUnit::Days }
762 );
763 }
764
765 #[test]
766 fn test_parse_iso_date_minus_offset() {
767 let expr = parse_date_expr("2025-01-15 - 3d").unwrap();
768 assert_eq!(
769 expr.base,
770 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
771 );
772 assert_eq!(
773 expr.offset,
774 DateOffset::Duration { amount: -3, unit: DurationUnit::Days }
775 );
776 }
777
778 #[test]
779 fn test_parse_iso_date_with_weekday() {
780 let expr = parse_date_expr("2025-01-15 - monday").unwrap();
781 assert_eq!(
782 expr.base,
783 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
784 );
785 assert_eq!(
786 expr.offset,
787 DateOffset::Weekday { weekday: Weekday::Mon, direction: Direction::Previous }
788 );
789 }
790
791 #[test]
792 fn test_parse_iso_date_with_format() {
793 let expr = parse_date_expr("2025-01-15 | %A").unwrap();
794 assert_eq!(
795 expr.base,
796 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
797 );
798 assert_eq!(expr.format, Some("%A".to_string()));
799 }
800
801 #[test]
802 fn test_evaluate_iso_date_literal() {
803 let expr = parse_date_expr("2025-01-15").unwrap();
804 let result = evaluate_date_expr(&expr);
805 assert_eq!(result, "2025-01-15");
806 }
807
808 #[test]
809 fn test_evaluate_iso_date_plus_days() {
810 let expr = parse_date_expr("2025-01-15 + 7d").unwrap();
811 let result = evaluate_date_expr(&expr);
812 assert_eq!(result, "2025-01-22");
813 }
814
815 #[test]
816 fn test_evaluate_iso_date_minus_days() {
817 let expr = parse_date_expr("2025-01-15 - 5d").unwrap();
818 let result = evaluate_date_expr(&expr);
819 assert_eq!(result, "2025-01-10");
820 }
821
822 #[test]
823 fn test_evaluate_iso_date_plus_weeks() {
824 let expr = parse_date_expr("2025-01-15 + 2w").unwrap();
825 let result = evaluate_date_expr(&expr);
826 assert_eq!(result, "2025-01-29");
827 }
828
829 #[test]
830 fn test_evaluate_iso_date_plus_months() {
831 let expr = parse_date_expr("2025-01-15 + 1M").unwrap();
832 let result = evaluate_date_expr(&expr);
833 assert_eq!(result, "2025-02-15");
834 }
835
836 #[test]
837 fn test_evaluate_iso_date_with_format() {
838 let expr = parse_date_expr("2025-01-15 | %A").unwrap();
839 let result = evaluate_date_expr(&expr);
840 assert_eq!(result, "Wednesday"); }
842
843 #[test]
844 fn test_evaluate_iso_date_weekday_offset() {
845 let expr = parse_date_expr("2025-01-15 - monday").unwrap();
847 let result = evaluate_date_expr(&expr);
848 assert_eq!(result, "2025-01-13");
849 }
850
851 #[test]
852 fn test_evaluate_iso_date_next_weekday() {
853 let expr = parse_date_expr("2025-01-15 + friday").unwrap();
855 let result = evaluate_date_expr(&expr);
856 assert_eq!(result, "2025-01-17");
857 }
858
859 #[test]
860 fn test_is_date_expr_iso_literal() {
861 assert!(is_date_expr("2025-01-15"));
862 assert!(is_date_expr("2025-01-15 + 7d"));
863 assert!(is_date_expr("2025-01-15 - 3d"));
864 assert!(is_date_expr("2025-01-15 | %A"));
865 assert!(is_date_expr("1999-12-31"));
866 assert!(!is_date_expr("2025-1-15")); assert!(!is_date_expr("25-01-15")); }
869
870 #[test]
871 fn test_try_evaluate_iso_date() {
872 assert_eq!(try_evaluate_date_expr("2025-01-15"), Some("2025-01-15".to_string()));
873 assert_eq!(
874 try_evaluate_date_expr("2025-01-15 + 1d"),
875 Some("2025-01-16".to_string())
876 );
877 }
878
879 #[test]
880 fn test_invalid_iso_date() {
881 assert!(parse_date_expr("2025-13-45").is_err());
883 assert!(parse_date_expr("not-a-date").is_err());
884 }
885
886 #[test]
889 fn test_parse_week_start() {
890 let expr = parse_date_expr("week_start").unwrap();
891 assert_eq!(expr.base, DateBase::WeekStart);
892 assert_eq!(expr.offset, DateOffset::None);
893 }
894
895 #[test]
896 fn test_parse_week_end() {
897 let expr = parse_date_expr("week_end").unwrap();
898 assert_eq!(expr.base, DateBase::WeekEnd);
899 assert_eq!(expr.offset, DateOffset::None);
900 }
901
902 #[test]
903 fn test_parse_week_start_with_offset() {
904 let expr = parse_date_expr("week_start + 1w").unwrap();
905 assert_eq!(expr.base, DateBase::WeekStart);
906 assert_eq!(
907 expr.offset,
908 DateOffset::Duration { amount: 1, unit: DurationUnit::Weeks }
909 );
910 }
911
912 #[test]
913 fn test_evaluate_week_start() {
914 let expr = parse_date_expr("week_start").unwrap();
916 let result = evaluate_date_expr(&expr);
917 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
918 assert_eq!(date.weekday(), Weekday::Mon);
919 }
920
921 #[test]
922 fn test_evaluate_week_end() {
923 let expr = parse_date_expr("week_end").unwrap();
925 let result = evaluate_date_expr(&expr);
926 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
927 assert_eq!(date.weekday(), Weekday::Sun);
928 }
929
930 #[test]
931 fn test_week_start_and_end_same_week() {
932 let start_expr = parse_date_expr("week_start").unwrap();
934 let end_expr = parse_date_expr("week_end").unwrap();
935 let start =
936 NaiveDate::parse_from_str(&evaluate_date_expr(&start_expr), "%Y-%m-%d")
937 .unwrap();
938 let end = NaiveDate::parse_from_str(&evaluate_date_expr(&end_expr), "%Y-%m-%d")
939 .unwrap();
940 assert_eq!((end - start).num_days(), 6);
941 }
942
943 #[test]
944 fn test_week_start_next_week() {
945 let this_week = parse_date_expr("week_start").unwrap();
947 let next_week = parse_date_expr("week_start + 1w").unwrap();
948 let this_monday =
949 NaiveDate::parse_from_str(&evaluate_date_expr(&this_week), "%Y-%m-%d")
950 .unwrap();
951 let next_monday =
952 NaiveDate::parse_from_str(&evaluate_date_expr(&next_week), "%Y-%m-%d")
953 .unwrap();
954 assert_eq!((next_monday - this_monday).num_days(), 7);
955 }
956
957 #[test]
960 fn test_parse_iso_week_notation() {
961 let expr = parse_date_expr("2025-W01").unwrap();
962 assert_eq!(expr.base, DateBase::IsoWeek { year: 2025, week: 1 });
963 assert_eq!(expr.offset, DateOffset::None);
964 }
965
966 #[test]
967 fn test_parse_iso_week_notation_lowercase() {
968 let expr = parse_date_expr("2025-w15").unwrap();
969 assert_eq!(expr.base, DateBase::IsoWeek { year: 2025, week: 15 });
970 }
971
972 #[test]
973 fn test_parse_iso_week_with_offset() {
974 let expr = parse_date_expr("2025-W01 + 6d").unwrap();
975 assert_eq!(expr.base, DateBase::IsoWeek { year: 2025, week: 1 });
976 assert_eq!(
977 expr.offset,
978 DateOffset::Duration { amount: 6, unit: DurationUnit::Days }
979 );
980 }
981
982 #[test]
983 fn test_evaluate_iso_week_monday() {
984 let expr = parse_date_expr("2025-W01").unwrap();
986 let result = evaluate_date_expr(&expr);
987 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
988 assert_eq!(date.weekday(), Weekday::Mon);
989 assert_eq!(result, "2024-12-30");
991 }
992
993 #[test]
994 fn test_evaluate_iso_week_sunday() {
995 let expr = parse_date_expr("2025-W01 + 6d").unwrap();
997 let result = evaluate_date_expr(&expr);
998 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
999 assert_eq!(date.weekday(), Weekday::Sun);
1000 assert_eq!(result, "2025-01-05");
1001 }
1002
1003 #[test]
1004 fn test_evaluate_iso_week_specific() {
1005 let expr = parse_date_expr("2025-W03").unwrap();
1007 let result = evaluate_date_expr(&expr);
1008 assert_eq!(result, "2025-01-13");
1009 }
1010
1011 #[test]
1012 fn test_iso_week_all_days() {
1013 let monday = evaluate_date_expr(&parse_date_expr("2025-W03").unwrap());
1015 let tuesday = evaluate_date_expr(&parse_date_expr("2025-W03 + 1d").unwrap());
1016 let wednesday = evaluate_date_expr(&parse_date_expr("2025-W03 + 2d").unwrap());
1017 let thursday = evaluate_date_expr(&parse_date_expr("2025-W03 + 3d").unwrap());
1018 let friday = evaluate_date_expr(&parse_date_expr("2025-W03 + 4d").unwrap());
1019 let saturday = evaluate_date_expr(&parse_date_expr("2025-W03 + 5d").unwrap());
1020 let sunday = evaluate_date_expr(&parse_date_expr("2025-W03 + 6d").unwrap());
1021
1022 assert_eq!(monday, "2025-01-13");
1023 assert_eq!(tuesday, "2025-01-14");
1024 assert_eq!(wednesday, "2025-01-15");
1025 assert_eq!(thursday, "2025-01-16");
1026 assert_eq!(friday, "2025-01-17");
1027 assert_eq!(saturday, "2025-01-18");
1028 assert_eq!(sunday, "2025-01-19");
1029 }
1030
1031 #[test]
1032 fn test_iso_week_with_format() {
1033 let expr = parse_date_expr("2025-W03 | %A").unwrap();
1034 let result = evaluate_date_expr(&expr);
1035 assert_eq!(result, "Monday");
1036 }
1037
1038 #[test]
1039 fn test_is_date_expr_week_start_end() {
1040 assert!(is_date_expr("week_start"));
1041 assert!(is_date_expr("week_end"));
1042 assert!(is_date_expr("week_start + 1w"));
1043 assert!(is_date_expr("week_end - 1d"));
1044 }
1045
1046 #[test]
1047 fn test_is_date_expr_iso_week() {
1048 assert!(is_date_expr("2025-W01"));
1049 assert!(is_date_expr("2025-w15"));
1050 assert!(is_date_expr("2025-W01 + 6d"));
1051 assert!(is_date_expr("2025-W52 | %A"));
1052 assert!(!is_date_expr("2025-W")); assert!(!is_date_expr("W01")); }
1055
1056 #[test]
1057 fn test_try_evaluate_iso_week() {
1058 assert_eq!(try_evaluate_date_expr("2025-W03"), Some("2025-01-13".to_string()));
1059 assert_eq!(
1060 try_evaluate_date_expr("2025-W03 + 6d"),
1061 Some("2025-01-19".to_string())
1062 );
1063 }
1064
1065 #[test]
1066 fn test_invalid_iso_week() {
1067 assert!(parse_date_expr("2025-W00").is_err());
1069 assert!(parse_date_expr("2025-W54").is_err());
1071 }
1072}