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