mdvault_core/vars/
datemath.rs

1//! Date math expression parser and evaluator.
2//!
3//! Supports expressions like:
4//! - `{{today}}`, `{{now}}`, `{{time}}`, `{{week}}`, `{{year}}`
5//! - `{{today + 1d}}`, `{{today - 1w}}`, `{{now + 2h}}`
6//! - `{{today | %Y-%m-%d}}` (with format specifier)
7//! - `{{today - monday}}`, `{{today + friday}}` (relative weekday)
8//! - `{{week}}` returns ISO week number (1-53), `{{week | %Y-W%V}}` for "2025-W51"
9
10use chrono::{
11    Datelike, Duration, IsoWeek, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike,
12    Weekday,
13};
14use regex::Regex;
15use thiserror::Error;
16
17/// Error type for date math parsing and evaluation.
18#[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/// A parsed date/time base value.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum DateBase {
36    /// Current date (YYYY-MM-DD)
37    Today,
38    /// Current datetime (ISO 8601)
39    Now,
40    /// Current time (HH:MM)
41    Time,
42    /// Current date (alias for today)
43    Date,
44    /// Current ISO week number (1-53)
45    Week,
46    /// Current year (YYYY)
47    Year,
48    /// Literal date (e.g., 2025-01-15)
49    Literal(NaiveDate),
50    /// Monday of current week
51    WeekStart,
52    /// Sunday of current week
53    WeekEnd,
54    /// ISO week notation (e.g., 2025-W01) - resolves to Monday of that week
55    IsoWeek { year: i32, week: u32 },
56}
57
58/// A duration offset to apply.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum DateOffset {
61    /// No offset
62    None,
63    /// Duration: +/- N units (days, weeks, months, hours, minutes)
64    Duration { amount: i64, unit: DurationUnit },
65    /// Relative weekday: previous/next Monday, Tuesday, etc.
66    Weekday { weekday: Weekday, direction: Direction },
67}
68
69/// Units for duration offsets.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DurationUnit {
72    Minutes,
73    Hours,
74    Days,
75    Weeks,
76    Months,
77    Years,
78}
79
80/// Direction for relative weekday.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum Direction {
83    Previous, // - (go back to previous weekday)
84    Next,     // + (go forward to next weekday)
85}
86
87/// A fully parsed date math expression.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct DateExpr {
90    pub base: DateBase,
91    pub offset: DateOffset,
92    pub format: Option<String>,
93}
94
95/// Parse a date math expression.
96///
97/// Examples:
98/// - `today` -> DateExpr { base: Today, offset: None, format: None }
99/// - `today + 1d` -> DateExpr { base: Today, offset: Duration { amount: 1, unit: Days }, format: None }
100/// - `now | %H:%M` -> DateExpr { base: Now, offset: None, format: Some("%H:%M") }
101/// - `today - monday` -> DateExpr { base: Today, offset: Weekday { weekday: Monday, direction: Previous }, format: None }
102pub fn parse_date_expr(input: &str) -> Result<DateExpr, DateMathError> {
103    let input = input.trim();
104
105    // Split by format specifier first
106    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    // Parse base and offset
114    // The base can be a keyword (today, now, etc.) or an ISO date (2025-01-15)
115    // ISO dates contain hyphens, so we need a more flexible pattern
116    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            // Try parsing as ISO week notation (YYYY-Www or YYYY-Ww)
148            if let Some(iso_week) = parse_iso_week_notation(s) {
149                return Ok(iso_week);
150            }
151            // Try parsing as ISO 8601 date literal (YYYY-MM-DD)
152            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
160/// Parse ISO week notation (e.g., 2025-W01, 2025-W1)
161fn 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        // Validate week number (1-53)
167        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    // Try parsing as weekday first
186    if let Ok(weekday) = parse_weekday(operand) {
187        return Ok(DateOffset::Weekday { weekday, direction });
188    }
189
190    // Try parsing as duration (e.g., "1d", "2w", "3M")
191    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
231/// Evaluate a date expression and return the formatted result.
232pub 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            // Get Monday of the specified ISO week
274            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
282/// Get the Monday of the week containing the given date.
283fn 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
288/// Get the Sunday of the week containing the given date.
289fn 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, // hours/minutes don't affect date
303        },
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, // days/weeks/months don't affect time
351        },
352        DateOffset::Weekday { .. } => time, // weekdays don't affect time
353    }
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    // Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
366    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            // Find the previous occurrence (or today if it's the target)
397            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            // Find the next occurrence (or today if it's the target)
406            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        // If a format is provided, apply it to a date in that week
434        // This allows formats like "%Y-W%V" to produce "2025-W51"
435        Some(fmt) => {
436            // Get a date in this week (Monday)
437            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        // Default: just the week number
442        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
451/// Check if a string looks like an ISO 8601 date (YYYY-MM-DD).
452fn looks_like_iso_date(s: &str) -> bool {
453    // Quick check: must be at least 10 chars and match pattern
454    if s.len() < 10 {
455        return false;
456    }
457    let bytes = s.as_bytes();
458    // Check pattern: DDDD-DD-DD where D is digit
459    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
471/// Check if a string looks like an ISO week notation (YYYY-Www or YYYY-Ww).
472fn looks_like_iso_week(s: &str) -> bool {
473    // Pattern: YYYY-Wxx or YYYY-Wx (7-8 chars minimum)
474    if s.len() < 7 {
475        return false;
476    }
477    let bytes = s.as_bytes();
478    // Check: 4 digits, hyphen, W/w, 1-2 digits
479    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
489/// Check if a string looks like a date math expression.
490///
491/// Returns true for strings like "today", "now + 1d", "time - 2h", "week", "year",
492/// "week_start", "week_end", ISO date literals like "2025-01-15",
493/// or ISO week notation like "2025-W01".
494pub fn is_date_expr(s: &str) -> bool {
495    let s = s.trim();
496    let lower = s.to_lowercase();
497
498    // Check for keyword-based expressions
499    // Note: "week" matches week, week_start, week_end
500    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    // Extract the base part (before any + or - operator with space, or format specifier)
511    let base_part = if let Some(idx) = s.find(['+', '|']) {
512        s[..idx].trim()
513    } else if let Some(idx) = s.rfind(" -") {
514        // Use rfind for " -" to avoid matching the hyphens in the date/week
515        s[..idx].trim()
516    } else {
517        s
518    };
519
520    // Check for ISO date literal or ISO week notation
521    looks_like_iso_date(base_part) || looks_like_iso_week(base_part)
522}
523
524/// Evaluate a date expression string if it is one, otherwise return None.
525pub 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        // Should be in YYYY-MM-DD format
616        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        // Should be a day name like "Monday", "Tuesday", etc.
635        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        // Jan 31 + 1 month should be Feb 28 (non-leap year)
650        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        // Jan 31 + 1 month in leap year should be Feb 29
658        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        // Should be a number between 1 and 53
692        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        // Should be like "2025-W51"
701        assert!(result.contains("-W"));
702        assert!(result.len() >= 8); // "YYYY-WNN"
703    }
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        // Should be a valid week number
710        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        // Should be a 4-digit year
725        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    // Tests for ISO date literals
740
741    #[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"); // 2025-01-15 is a Wednesday
841    }
842
843    #[test]
844    fn test_evaluate_iso_date_weekday_offset() {
845        // 2025-01-15 is Wednesday, previous Monday is 2025-01-13
846        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        // 2025-01-15 is Wednesday, next Friday is 2025-01-17
854        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")); // Invalid format (single digit month)
867        assert!(!is_date_expr("25-01-15")); // Invalid format (2-digit year)
868    }
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        // Invalid date should fail parsing
882        assert!(parse_date_expr("2025-13-45").is_err());
883        assert!(parse_date_expr("not-a-date").is_err());
884    }
885
886    // Tests for week_start and week_end
887
888    #[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        // Test that week_start returns a Monday
915        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        // Test that week_end returns a Sunday
924        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        // week_start and week_end should be 6 days apart
933        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        // week_start + 1w should be 7 days after week_start
946        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    // Tests for ISO week notation
958
959    #[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        // 2025-W01 should resolve to Monday of that week
985        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        // Week 1 of 2025 starts on 2024-12-30 (ISO week definition)
990        assert_eq!(result, "2024-12-30");
991    }
992
993    #[test]
994    fn test_evaluate_iso_week_sunday() {
995        // 2025-W01 + 6d should give Sunday of that week
996        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        // 2025-W03 should start on 2025-01-13 (Monday)
1006        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        // Test generating all days of a week
1014        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")); // incomplete
1053        assert!(!is_date_expr("W01")); // missing year
1054    }
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        // Week 0 is invalid
1068        assert!(parse_date_expr("2025-W00").is_err());
1069        // Week 54+ is invalid
1070        assert!(parse_date_expr("2025-W54").is_err());
1071    }
1072}