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 let now = Local::now();
250 let today = now.date_naive();
251 let current_time = now.time();
252
253 match expr.base {
254 DateBase::Today | DateBase::Date => {
255 let date = apply_date_offset(today, &expr.offset);
256 format_date(date, expr.format.as_deref())
257 }
258 DateBase::Now => {
259 let datetime = apply_datetime_offset(now.naive_local(), &expr.offset);
260 format_datetime(datetime, expr.format.as_deref())
261 }
262 DateBase::Time => {
263 let time = apply_time_offset(current_time, &expr.offset);
264 format_time(time, expr.format.as_deref())
265 }
266 DateBase::Week => {
267 let date = apply_date_offset(today, &expr.offset);
268 format_week(date.iso_week(), expr.format.as_deref())
269 }
270 DateBase::Year => {
271 let date = apply_date_offset(today, &expr.offset);
272 format_year(date, expr.format.as_deref())
273 }
274 DateBase::Literal(base_date) => {
275 let date = apply_date_offset(base_date, &expr.offset);
276 format_date(date, expr.format.as_deref())
277 }
278 DateBase::WeekStart => {
279 let monday = get_week_start(today);
280 let date = apply_date_offset(monday, &expr.offset);
281 format_date(date, expr.format.as_deref())
282 }
283 DateBase::WeekEnd => {
284 let sunday = get_week_end(today);
285 let date = apply_date_offset(sunday, &expr.offset);
286 format_date(date, expr.format.as_deref())
287 }
288 DateBase::IsoWeek { year, week } => {
289 let monday =
291 NaiveDate::from_isoywd_opt(year, week, Weekday::Mon).unwrap_or(today);
292 let date = apply_date_offset(monday, &expr.offset);
293 format_date(date, expr.format.as_deref())
294 }
295 DateBase::Tomorrow => {
296 let tomorrow = today + Duration::days(1);
297 let date = apply_date_offset(tomorrow, &expr.offset);
298 format_date(date, expr.format.as_deref())
299 }
300 DateBase::Yesterday => {
301 let yesterday = today - Duration::days(1);
302 let date = apply_date_offset(yesterday, &expr.offset);
303 format_date(date, expr.format.as_deref())
304 }
305 DateBase::NextWeek => {
306 let next_week_iso = today + Duration::weeks(1);
307 let date = apply_date_offset(next_week_iso, &expr.offset);
308 format_week(date.iso_week(), expr.format.as_deref())
309 }
310 DateBase::LastWeek => {
311 let last_week_iso = today - Duration::weeks(1);
312 let date = apply_date_offset(last_week_iso, &expr.offset);
313 format_week(date.iso_week(), expr.format.as_deref())
314 }
315 }
316}
317
318fn get_week_start(date: NaiveDate) -> NaiveDate {
320 let days_from_monday = date.weekday().num_days_from_monday() as i64;
321 date - Duration::days(days_from_monday)
322}
323
324fn get_week_end(date: NaiveDate) -> NaiveDate {
326 let days_to_sunday = 6 - date.weekday().num_days_from_monday() as i64;
327 date + Duration::days(days_to_sunday)
328}
329
330fn apply_date_offset(date: NaiveDate, offset: &DateOffset) -> NaiveDate {
331 match offset {
332 DateOffset::None => date,
333 DateOffset::Duration { amount, unit } => match unit {
334 DurationUnit::Days => date + Duration::days(*amount),
335 DurationUnit::Weeks => date + Duration::weeks(*amount),
336 DurationUnit::Months => add_months(date, *amount),
337 DurationUnit::Years => add_months(date, amount * 12),
338 DurationUnit::Hours | DurationUnit::Minutes => date, },
340 DateOffset::Weekday { weekday, direction } => {
341 find_relative_weekday(date, *weekday, *direction)
342 }
343 }
344}
345
346fn apply_datetime_offset(dt: NaiveDateTime, offset: &DateOffset) -> NaiveDateTime {
347 match offset {
348 DateOffset::None => dt,
349 DateOffset::Duration { amount, unit } => match unit {
350 DurationUnit::Minutes => dt + Duration::minutes(*amount),
351 DurationUnit::Hours => dt + Duration::hours(*amount),
352 DurationUnit::Days => dt + Duration::days(*amount),
353 DurationUnit::Weeks => dt + Duration::weeks(*amount),
354 DurationUnit::Months => {
355 let new_date = add_months(dt.date(), *amount);
356 NaiveDateTime::new(new_date, dt.time())
357 }
358 DurationUnit::Years => {
359 let new_date = add_months(dt.date(), amount * 12);
360 NaiveDateTime::new(new_date, dt.time())
361 }
362 },
363 DateOffset::Weekday { weekday, direction } => {
364 let new_date = find_relative_weekday(dt.date(), *weekday, *direction);
365 NaiveDateTime::new(new_date, dt.time())
366 }
367 }
368}
369
370fn apply_time_offset(time: NaiveTime, offset: &DateOffset) -> NaiveTime {
371 match offset {
372 DateOffset::None => time,
373 DateOffset::Duration { amount, unit } => match unit {
374 DurationUnit::Minutes => {
375 let secs = time.num_seconds_from_midnight() as i64 + amount * 60;
376 let normalized = secs.rem_euclid(86400) as u32;
377 NaiveTime::from_num_seconds_from_midnight_opt(normalized, 0)
378 .unwrap_or(time)
379 }
380 DurationUnit::Hours => {
381 let secs = time.num_seconds_from_midnight() as i64 + amount * 3600;
382 let normalized = secs.rem_euclid(86400) as u32;
383 NaiveTime::from_num_seconds_from_midnight_opt(normalized, 0)
384 .unwrap_or(time)
385 }
386 _ => time, },
388 DateOffset::Weekday { .. } => time, }
390}
391
392fn add_months(date: NaiveDate, months: i64) -> NaiveDate {
393 let year = date.year() as i64;
394 let month = date.month() as i64;
395 let day = date.day();
396
397 let total_months = year * 12 + month - 1 + months;
398 let new_year = (total_months / 12) as i32;
399 let new_month = (total_months % 12 + 1) as u32;
400
401 let max_day = days_in_month(new_year, new_month);
403 let new_day = day.min(max_day);
404
405 NaiveDate::from_ymd_opt(new_year, new_month, new_day).unwrap_or(date)
406}
407
408fn days_in_month(year: i32, month: u32) -> u32 {
409 match month {
410 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
411 4 | 6 | 9 | 11 => 30,
412 2 => {
413 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
414 29
415 } else {
416 28
417 }
418 }
419 _ => 30,
420 }
421}
422
423fn find_relative_weekday(
424 date: NaiveDate,
425 target: Weekday,
426 direction: Direction,
427) -> NaiveDate {
428 let current = date.weekday();
429
430 match direction {
431 Direction::Previous => {
432 let days_diff = (current.num_days_from_monday() as i64
434 - target.num_days_from_monday() as i64
435 + 7)
436 % 7;
437 let days_back = if days_diff == 0 { 7 } else { days_diff };
438 date - Duration::days(days_back)
439 }
440 Direction::Next => {
441 let days_diff = (target.num_days_from_monday() as i64
443 - current.num_days_from_monday() as i64
444 + 7)
445 % 7;
446 let days_forward = if days_diff == 0 { 7 } else { days_diff };
447 date + Duration::days(days_forward)
448 }
449 }
450}
451
452fn format_date(date: NaiveDate, format: Option<&str>) -> String {
453 let fmt = format.unwrap_or("%Y-%m-%d");
454 date.format(fmt).to_string()
455}
456
457fn format_datetime(dt: NaiveDateTime, format: Option<&str>) -> String {
458 let fmt = format.unwrap_or("%Y-%m-%dT%H:%M:%S");
459 dt.format(fmt).to_string()
460}
461
462fn format_time(time: NaiveTime, format: Option<&str>) -> String {
463 let fmt = format.unwrap_or("%H:%M");
464 time.format(fmt).to_string()
465}
466
467fn format_week(week: IsoWeek, format: Option<&str>) -> String {
468 match format {
469 Some(fmt) => {
472 let date = NaiveDate::from_isoywd_opt(week.year(), week.week(), Weekday::Mon)
474 .unwrap_or_else(|| Local::now().date_naive());
475 date.format(fmt).to_string()
476 }
477 None => week.week().to_string(),
479 }
480}
481
482fn format_year(date: NaiveDate, format: Option<&str>) -> String {
483 let fmt = format.unwrap_or("%Y");
484 date.format(fmt).to_string()
485}
486
487fn looks_like_iso_date(s: &str) -> bool {
489 if s.len() < 10 {
491 return false;
492 }
493 let bytes = s.as_bytes();
494 bytes[0].is_ascii_digit()
496 && bytes[1].is_ascii_digit()
497 && bytes[2].is_ascii_digit()
498 && bytes[3].is_ascii_digit()
499 && bytes[4] == b'-'
500 && bytes[5].is_ascii_digit()
501 && bytes[6].is_ascii_digit()
502 && bytes[7] == b'-'
503 && bytes[8].is_ascii_digit()
504 && bytes[9].is_ascii_digit()
505}
506
507fn looks_like_iso_week(s: &str) -> bool {
509 if s.len() < 7 {
511 return false;
512 }
513 let bytes = s.as_bytes();
514 bytes[0].is_ascii_digit()
516 && bytes[1].is_ascii_digit()
517 && bytes[2].is_ascii_digit()
518 && bytes[3].is_ascii_digit()
519 && bytes[4] == b'-'
520 && (bytes[5] == b'W' || bytes[5] == b'w')
521 && bytes[6].is_ascii_digit()
522 && (s.len() == 7 || (s.len() >= 8 && bytes[7].is_ascii_digit()))
523}
524
525pub fn is_date_expr(s: &str) -> bool {
531 let s = s.trim();
532 let lower = s.to_lowercase();
533
534 if lower.starts_with("today")
537 || lower.starts_with("now")
538 || lower.starts_with("time")
539 || lower.starts_with("date")
540 || lower.starts_with("week")
541 || lower.starts_with("year")
542 || lower.starts_with("tomorrow")
543 || lower.starts_with("yesterday")
544 || lower.starts_with("next_week")
545 || lower.starts_with("last_week")
546 || lower.starts_with("next week")
547 || lower.starts_with("last week")
548 {
549 return true;
550 }
551
552 let base_part = if let Some(idx) = s.find(['+', '|']) {
554 s[..idx].trim()
555 } else if let Some(idx) = s.rfind(" -") {
556 s[..idx].trim()
558 } else {
559 s
560 };
561
562 looks_like_iso_date(base_part) || looks_like_iso_week(base_part)
564}
565
566pub fn try_evaluate_date_expr(s: &str) -> Option<String> {
568 if is_date_expr(s) {
569 parse_date_expr(s).ok().map(|e| evaluate_date_expr(&e))
570 } else {
571 None
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_parse_simple_today() {
581 let expr = parse_date_expr("today").unwrap();
582 assert_eq!(expr.base, DateBase::Today);
583 assert_eq!(expr.offset, DateOffset::None);
584 assert!(expr.format.is_none());
585 }
586
587 #[test]
588 fn test_parse_today_plus_days() {
589 let expr = parse_date_expr("today + 1d").unwrap();
590 assert_eq!(expr.base, DateBase::Today);
591 assert_eq!(
592 expr.offset,
593 DateOffset::Duration { amount: 1, unit: DurationUnit::Days }
594 );
595 }
596
597 #[test]
598 fn test_parse_today_minus_weeks() {
599 let expr = parse_date_expr("today - 2w").unwrap();
600 assert_eq!(expr.base, DateBase::Today);
601 assert_eq!(
602 expr.offset,
603 DateOffset::Duration { amount: -2, unit: DurationUnit::Weeks }
604 );
605 }
606
607 #[test]
608 fn test_parse_now_with_format() {
609 let expr = parse_date_expr("now | %H:%M").unwrap();
610 assert_eq!(expr.base, DateBase::Now);
611 assert_eq!(expr.format, Some("%H:%M".to_string()));
612 }
613
614 #[test]
615 fn test_parse_weekday_previous() {
616 let expr = parse_date_expr("today - monday").unwrap();
617 assert_eq!(expr.base, DateBase::Today);
618 assert_eq!(
619 expr.offset,
620 DateOffset::Weekday { weekday: Weekday::Mon, direction: Direction::Previous }
621 );
622 }
623
624 #[test]
625 fn test_parse_weekday_next() {
626 let expr = parse_date_expr("today + friday").unwrap();
627 assert_eq!(expr.base, DateBase::Today);
628 assert_eq!(
629 expr.offset,
630 DateOffset::Weekday { weekday: Weekday::Fri, direction: Direction::Next }
631 );
632 }
633
634 #[test]
635 fn test_parse_months() {
636 let expr = parse_date_expr("today + 3M").unwrap();
637 assert_eq!(
638 expr.offset,
639 DateOffset::Duration { amount: 3, unit: DurationUnit::Months }
640 );
641 }
642
643 #[test]
644 fn test_parse_hours() {
645 let expr = parse_date_expr("now + 2h").unwrap();
646 assert_eq!(
647 expr.offset,
648 DateOffset::Duration { amount: 2, unit: DurationUnit::Hours }
649 );
650 }
651
652 #[test]
653 fn test_evaluate_today() {
654 let expr =
655 DateExpr { base: DateBase::Today, offset: DateOffset::None, format: None };
656 let result = evaluate_date_expr(&expr);
657 assert!(result.len() == 10);
659 assert!(result.chars().nth(4) == Some('-'));
660 }
661
662 #[test]
663 fn test_evaluate_today_plus_one_day() {
664 let expr = parse_date_expr("today + 1d").unwrap();
665 let result = evaluate_date_expr(&expr);
666
667 let today = Local::now().date_naive();
668 let tomorrow = today + Duration::days(1);
669 assert_eq!(result, tomorrow.format("%Y-%m-%d").to_string());
670 }
671
672 #[test]
673 fn test_evaluate_with_format() {
674 let expr = parse_date_expr("today | %A").unwrap();
675 let result = evaluate_date_expr(&expr);
676 let valid_days = [
678 "Monday",
679 "Tuesday",
680 "Wednesday",
681 "Thursday",
682 "Friday",
683 "Saturday",
684 "Sunday",
685 ];
686 assert!(valid_days.contains(&result.as_str()));
687 }
688
689 #[test]
690 fn test_add_months_overflow() {
691 let date = NaiveDate::from_ymd_opt(2023, 1, 31).unwrap();
693 let result = add_months(date, 1);
694 assert_eq!(result, NaiveDate::from_ymd_opt(2023, 2, 28).unwrap());
695 }
696
697 #[test]
698 fn test_add_months_leap_year() {
699 let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
701 let result = add_months(date, 1);
702 assert_eq!(result, NaiveDate::from_ymd_opt(2024, 2, 29).unwrap());
703 }
704
705 #[test]
706 fn test_is_date_expr() {
707 assert!(is_date_expr("today"));
708 assert!(is_date_expr("TODAY"));
709 assert!(is_date_expr("today + 1d"));
710 assert!(is_date_expr("now"));
711 assert!(is_date_expr("time - 2h"));
712 assert!(!is_date_expr("some_var"));
713 assert!(!is_date_expr("{{today}}"));
714 }
715
716 #[test]
717 fn test_try_evaluate() {
718 assert!(try_evaluate_date_expr("today").is_some());
719 assert!(try_evaluate_date_expr("not_a_date").is_none());
720 }
721
722 #[test]
723 fn test_parse_week() {
724 let expr = parse_date_expr("week").unwrap();
725 assert_eq!(expr.base, DateBase::Week);
726 assert_eq!(expr.offset, DateOffset::None);
727 }
728
729 #[test]
730 fn test_evaluate_week() {
731 let expr = parse_date_expr("week").unwrap();
732 let result = evaluate_date_expr(&expr);
733 let week_num: u32 = result.parse().unwrap();
735 assert!((1..=53).contains(&week_num));
736 }
737
738 #[test]
739 fn test_evaluate_week_with_format() {
740 let expr = parse_date_expr("week | %Y-W%V").unwrap();
741 let result = evaluate_date_expr(&expr);
742 assert!(result.contains("-W"));
744 assert!(result.len() >= 8); }
746
747 #[test]
748 fn test_week_with_offset() {
749 let expr = parse_date_expr("week + 1w").unwrap();
750 let result = evaluate_date_expr(&expr);
751 let week_num: u32 = result.parse().unwrap();
753 assert!((1..=53).contains(&week_num));
754 }
755
756 #[test]
757 fn test_parse_year() {
758 let expr = parse_date_expr("year").unwrap();
759 assert_eq!(expr.base, DateBase::Year);
760 }
761
762 #[test]
763 fn test_evaluate_year() {
764 let expr = parse_date_expr("year").unwrap();
765 let result = evaluate_date_expr(&expr);
766 assert_eq!(result.len(), 4);
768 let year: i32 = result.parse().unwrap();
769 assert!((2020..=2100).contains(&year));
770 }
771
772 #[test]
773 fn test_is_date_expr_week_year() {
774 assert!(is_date_expr("week"));
775 assert!(is_date_expr("WEEK"));
776 assert!(is_date_expr("week + 1w"));
777 assert!(is_date_expr("year"));
778 assert!(is_date_expr("year - 1y"));
779 }
780
781 #[test]
784 fn test_parse_iso_date_literal() {
785 let expr = parse_date_expr("2025-01-15").unwrap();
786 assert_eq!(
787 expr.base,
788 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
789 );
790 assert_eq!(expr.offset, DateOffset::None);
791 assert!(expr.format.is_none());
792 }
793
794 #[test]
795 fn test_parse_iso_date_with_offset() {
796 let expr = parse_date_expr("2025-01-15 + 7d").unwrap();
797 assert_eq!(
798 expr.base,
799 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
800 );
801 assert_eq!(
802 expr.offset,
803 DateOffset::Duration { amount: 7, unit: DurationUnit::Days }
804 );
805 }
806
807 #[test]
808 fn test_parse_iso_date_minus_offset() {
809 let expr = parse_date_expr("2025-01-15 - 3d").unwrap();
810 assert_eq!(
811 expr.base,
812 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
813 );
814 assert_eq!(
815 expr.offset,
816 DateOffset::Duration { amount: -3, unit: DurationUnit::Days }
817 );
818 }
819
820 #[test]
821 fn test_parse_iso_date_with_weekday() {
822 let expr = parse_date_expr("2025-01-15 - monday").unwrap();
823 assert_eq!(
824 expr.base,
825 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
826 );
827 assert_eq!(
828 expr.offset,
829 DateOffset::Weekday { weekday: Weekday::Mon, direction: Direction::Previous }
830 );
831 }
832
833 #[test]
834 fn test_parse_iso_date_with_format() {
835 let expr = parse_date_expr("2025-01-15 | %A").unwrap();
836 assert_eq!(
837 expr.base,
838 DateBase::Literal(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
839 );
840 assert_eq!(expr.format, Some("%A".to_string()));
841 }
842
843 #[test]
844 fn test_evaluate_iso_date_literal() {
845 let expr = parse_date_expr("2025-01-15").unwrap();
846 let result = evaluate_date_expr(&expr);
847 assert_eq!(result, "2025-01-15");
848 }
849
850 #[test]
851 fn test_evaluate_iso_date_plus_days() {
852 let expr = parse_date_expr("2025-01-15 + 7d").unwrap();
853 let result = evaluate_date_expr(&expr);
854 assert_eq!(result, "2025-01-22");
855 }
856
857 #[test]
858 fn test_evaluate_iso_date_minus_days() {
859 let expr = parse_date_expr("2025-01-15 - 5d").unwrap();
860 let result = evaluate_date_expr(&expr);
861 assert_eq!(result, "2025-01-10");
862 }
863
864 #[test]
865 fn test_evaluate_iso_date_plus_weeks() {
866 let expr = parse_date_expr("2025-01-15 + 2w").unwrap();
867 let result = evaluate_date_expr(&expr);
868 assert_eq!(result, "2025-01-29");
869 }
870
871 #[test]
872 fn test_evaluate_iso_date_plus_months() {
873 let expr = parse_date_expr("2025-01-15 + 1M").unwrap();
874 let result = evaluate_date_expr(&expr);
875 assert_eq!(result, "2025-02-15");
876 }
877
878 #[test]
879 fn test_evaluate_iso_date_with_format() {
880 let expr = parse_date_expr("2025-01-15 | %A").unwrap();
881 let result = evaluate_date_expr(&expr);
882 assert_eq!(result, "Wednesday"); }
884
885 #[test]
886 fn test_evaluate_iso_date_weekday_offset() {
887 let expr = parse_date_expr("2025-01-15 - monday").unwrap();
889 let result = evaluate_date_expr(&expr);
890 assert_eq!(result, "2025-01-13");
891 }
892
893 #[test]
894 fn test_evaluate_iso_date_next_weekday() {
895 let expr = parse_date_expr("2025-01-15 + friday").unwrap();
897 let result = evaluate_date_expr(&expr);
898 assert_eq!(result, "2025-01-17");
899 }
900
901 #[test]
902 fn test_is_date_expr_iso_literal() {
903 assert!(is_date_expr("2025-01-15"));
904 assert!(is_date_expr("2025-01-15 + 7d"));
905 assert!(is_date_expr("2025-01-15 - 3d"));
906 assert!(is_date_expr("2025-01-15 | %A"));
907 assert!(is_date_expr("1999-12-31"));
908 assert!(!is_date_expr("2025-1-15")); assert!(!is_date_expr("25-01-15")); }
911
912 #[test]
913 fn test_try_evaluate_iso_date() {
914 assert_eq!(try_evaluate_date_expr("2025-01-15"), Some("2025-01-15".to_string()));
915 assert_eq!(
916 try_evaluate_date_expr("2025-01-15 + 1d"),
917 Some("2025-01-16".to_string())
918 );
919 }
920
921 #[test]
922 fn test_invalid_iso_date() {
923 assert!(parse_date_expr("2025-13-45").is_err());
925 assert!(parse_date_expr("not-a-date").is_err());
926 }
927
928 #[test]
931 fn test_parse_week_start() {
932 let expr = parse_date_expr("week_start").unwrap();
933 assert_eq!(expr.base, DateBase::WeekStart);
934 assert_eq!(expr.offset, DateOffset::None);
935 }
936
937 #[test]
938 fn test_parse_week_end() {
939 let expr = parse_date_expr("week_end").unwrap();
940 assert_eq!(expr.base, DateBase::WeekEnd);
941 assert_eq!(expr.offset, DateOffset::None);
942 }
943
944 #[test]
945 fn test_parse_week_start_with_offset() {
946 let expr = parse_date_expr("week_start + 1w").unwrap();
947 assert_eq!(expr.base, DateBase::WeekStart);
948 assert_eq!(
949 expr.offset,
950 DateOffset::Duration { amount: 1, unit: DurationUnit::Weeks }
951 );
952 }
953
954 #[test]
955 fn test_evaluate_week_start() {
956 let expr = parse_date_expr("week_start").unwrap();
958 let result = evaluate_date_expr(&expr);
959 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
960 assert_eq!(date.weekday(), Weekday::Mon);
961 }
962
963 #[test]
964 fn test_evaluate_week_end() {
965 let expr = parse_date_expr("week_end").unwrap();
967 let result = evaluate_date_expr(&expr);
968 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
969 assert_eq!(date.weekday(), Weekday::Sun);
970 }
971
972 #[test]
973 fn test_week_start_and_end_same_week() {
974 let start_expr = parse_date_expr("week_start").unwrap();
976 let end_expr = parse_date_expr("week_end").unwrap();
977 let start =
978 NaiveDate::parse_from_str(&evaluate_date_expr(&start_expr), "%Y-%m-%d")
979 .unwrap();
980 let end = NaiveDate::parse_from_str(&evaluate_date_expr(&end_expr), "%Y-%m-%d")
981 .unwrap();
982 assert_eq!((end - start).num_days(), 6);
983 }
984
985 #[test]
986 fn test_week_start_next_week() {
987 let this_week = parse_date_expr("week_start").unwrap();
989 let next_week = parse_date_expr("week_start + 1w").unwrap();
990 let this_monday =
991 NaiveDate::parse_from_str(&evaluate_date_expr(&this_week), "%Y-%m-%d")
992 .unwrap();
993 let next_monday =
994 NaiveDate::parse_from_str(&evaluate_date_expr(&next_week), "%Y-%m-%d")
995 .unwrap();
996 assert_eq!((next_monday - this_monday).num_days(), 7);
997 }
998
999 #[test]
1002 fn test_parse_iso_week_notation() {
1003 let expr = parse_date_expr("2025-W01").unwrap();
1004 assert_eq!(expr.base, DateBase::IsoWeek { year: 2025, week: 1 });
1005 assert_eq!(expr.offset, DateOffset::None);
1006 }
1007
1008 #[test]
1009 fn test_parse_iso_week_notation_lowercase() {
1010 let expr = parse_date_expr("2025-w15").unwrap();
1011 assert_eq!(expr.base, DateBase::IsoWeek { year: 2025, week: 15 });
1012 }
1013
1014 #[test]
1015 fn test_parse_iso_week_with_offset() {
1016 let expr = parse_date_expr("2025-W01 + 6d").unwrap();
1017 assert_eq!(expr.base, DateBase::IsoWeek { year: 2025, week: 1 });
1018 assert_eq!(
1019 expr.offset,
1020 DateOffset::Duration { amount: 6, unit: DurationUnit::Days }
1021 );
1022 }
1023
1024 #[test]
1025 fn test_evaluate_iso_week_monday() {
1026 let expr = parse_date_expr("2025-W01").unwrap();
1028 let result = evaluate_date_expr(&expr);
1029 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
1030 assert_eq!(date.weekday(), Weekday::Mon);
1031 assert_eq!(result, "2024-12-30");
1033 }
1034
1035 #[test]
1036 fn test_evaluate_iso_week_sunday() {
1037 let expr = parse_date_expr("2025-W01 + 6d").unwrap();
1039 let result = evaluate_date_expr(&expr);
1040 let date = NaiveDate::parse_from_str(&result, "%Y-%m-%d").unwrap();
1041 assert_eq!(date.weekday(), Weekday::Sun);
1042 assert_eq!(result, "2025-01-05");
1043 }
1044
1045 #[test]
1046 fn test_evaluate_iso_week_specific() {
1047 let expr = parse_date_expr("2025-W03").unwrap();
1049 let result = evaluate_date_expr(&expr);
1050 assert_eq!(result, "2025-01-13");
1051 }
1052
1053 #[test]
1054 fn test_iso_week_all_days() {
1055 let monday = evaluate_date_expr(&parse_date_expr("2025-W03").unwrap());
1057 let tuesday = evaluate_date_expr(&parse_date_expr("2025-W03 + 1d").unwrap());
1058 let wednesday = evaluate_date_expr(&parse_date_expr("2025-W03 + 2d").unwrap());
1059 let thursday = evaluate_date_expr(&parse_date_expr("2025-W03 + 3d").unwrap());
1060 let friday = evaluate_date_expr(&parse_date_expr("2025-W03 + 4d").unwrap());
1061 let saturday = evaluate_date_expr(&parse_date_expr("2025-W03 + 5d").unwrap());
1062 let sunday = evaluate_date_expr(&parse_date_expr("2025-W03 + 6d").unwrap());
1063
1064 assert_eq!(monday, "2025-01-13");
1065 assert_eq!(tuesday, "2025-01-14");
1066 assert_eq!(wednesday, "2025-01-15");
1067 assert_eq!(thursday, "2025-01-16");
1068 assert_eq!(friday, "2025-01-17");
1069 assert_eq!(saturday, "2025-01-18");
1070 assert_eq!(sunday, "2025-01-19");
1071 }
1072
1073 #[test]
1074 fn test_iso_week_with_format() {
1075 let expr = parse_date_expr("2025-W03 | %A").unwrap();
1076 let result = evaluate_date_expr(&expr);
1077 assert_eq!(result, "Monday");
1078 }
1079
1080 #[test]
1081 fn test_is_date_expr_week_start_end() {
1082 assert!(is_date_expr("week_start"));
1083 assert!(is_date_expr("week_end"));
1084 assert!(is_date_expr("week_start + 1w"));
1085 assert!(is_date_expr("week_end - 1d"));
1086 }
1087
1088 #[test]
1089 fn test_is_date_expr_iso_week() {
1090 assert!(is_date_expr("2025-W01"));
1091 assert!(is_date_expr("2025-w15"));
1092 assert!(is_date_expr("2025-W01 + 6d"));
1093 assert!(is_date_expr("2025-W52 | %A"));
1094 assert!(!is_date_expr("2025-W")); assert!(!is_date_expr("W01")); }
1097
1098 #[test]
1099 fn test_try_evaluate_iso_week() {
1100 assert_eq!(try_evaluate_date_expr("2025-W03"), Some("2025-01-13".to_string()));
1101 assert_eq!(
1102 try_evaluate_date_expr("2025-W03 + 6d"),
1103 Some("2025-01-19".to_string())
1104 );
1105 }
1106
1107 #[test]
1108 fn test_invalid_iso_week() {
1109 assert!(parse_date_expr("2025-W00").is_err());
1111 assert!(parse_date_expr("2025-W54").is_err());
1113 }
1114}