Skip to main content

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    /// Tomorrow (Today + 1 day)
57    Tomorrow,
58    /// Yesterday (Today - 1 day)
59    Yesterday,
60    /// Next week (Week + 1 week)
61    NextWeek,
62    /// Last week (Week - 1 week)
63    LastWeek,
64}
65
66/// A duration offset to apply.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum DateOffset {
69    /// No offset
70    None,
71    /// Duration: +/- N units (days, weeks, months, hours, minutes)
72    Duration { amount: i64, unit: DurationUnit },
73    /// Relative weekday: previous/next Monday, Tuesday, etc.
74    Weekday { weekday: Weekday, direction: Direction },
75}
76
77/// Units for duration offsets.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum DurationUnit {
80    Minutes,
81    Hours,
82    Days,
83    Weeks,
84    Months,
85    Years,
86}
87
88/// Direction for relative weekday.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Direction {
91    Previous, // - (go back to previous weekday)
92    Next,     // + (go forward to next weekday)
93}
94
95/// A fully parsed date math expression.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct DateExpr {
98    pub base: DateBase,
99    pub offset: DateOffset,
100    pub format: Option<String>,
101}
102
103/// Parse a date math expression.
104///
105/// Examples:
106/// - `today` -> DateExpr { base: Today, offset: None, format: None }
107/// - `today + 1d` -> DateExpr { base: Today, offset: Duration { amount: 1, unit: Days }, format: None }
108/// - `now | %H:%M` -> DateExpr { base: Now, offset: None, format: Some("%H:%M") }
109/// - `today - monday` -> DateExpr { base: Today, offset: Weekday { weekday: Monday, direction: Previous }, format: None }
110pub fn parse_date_expr(input: &str) -> Result<DateExpr, DateMathError> {
111    let input = input.trim();
112    // Normalize "next week" -> "next_week", "last week" -> "last_week"
113    let normalized =
114        input.replace("next week", "next_week").replace("last week", "last_week");
115    let input = normalized.as_str();
116
117    // Split by format specifier first
118    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    // Parse base and offset
126    // The base can be a keyword (today, now, etc.) or an ISO date (2025-01-15)
127    // ISO dates contain hyphens, so we need a more flexible pattern
128    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            // Try parsing as ISO week notation (YYYY-Www or YYYY-Ww)
164            if let Some(iso_week) = parse_iso_week_notation(s) {
165                return Ok(iso_week);
166            }
167            // Try parsing as ISO 8601 date literal (YYYY-MM-DD)
168            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
176/// Parse ISO week notation (e.g., 2025-W01, 2025-W1)
177fn 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        // Validate week number (1-53)
183        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    // Try parsing as weekday first
202    if let Ok(weekday) = parse_weekday(operand) {
203        return Ok(DateOffset::Weekday { weekday, direction });
204    }
205
206    // Try parsing as duration (e.g., "1d", "2w", "3M")
207    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
247/// Evaluate a date expression and return the formatted result.
248pub 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            // Get Monday of the specified ISO week
290            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
318/// Get the Monday of the week containing the given date.
319fn 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
324/// Get the Sunday of the week containing the given date.
325fn 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, // hours/minutes don't affect date
339        },
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, // days/weeks/months don't affect time
387        },
388        DateOffset::Weekday { .. } => time, // weekdays don't affect time
389    }
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    // Handle day overflow (e.g., Jan 31 + 1 month = Feb 28/29)
402    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            // Find the previous occurrence (or today if it's the target)
433            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            // Find the next occurrence (or today if it's the target)
442            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        // If a format is provided, apply it to a date in that week
470        // This allows formats like "%Y-W%V" to produce "2025-W51"
471        Some(fmt) => {
472            // Get a date in this week (Monday)
473            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        // Default: just the week number
478        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
487/// Check if a string looks like an ISO 8601 date (YYYY-MM-DD).
488fn looks_like_iso_date(s: &str) -> bool {
489    // Quick check: must be at least 10 chars and match pattern
490    if s.len() < 10 {
491        return false;
492    }
493    let bytes = s.as_bytes();
494    // Check pattern: DDDD-DD-DD where D is digit
495    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
507/// Check if a string looks like an ISO week notation (YYYY-Www or YYYY-Ww).
508fn looks_like_iso_week(s: &str) -> bool {
509    // Pattern: YYYY-Wxx or YYYY-Wx (7-8 chars minimum)
510    if s.len() < 7 {
511        return false;
512    }
513    let bytes = s.as_bytes();
514    // Check: 4 digits, hyphen, W/w, 1-2 digits
515    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
525/// Check if a string looks like a date math expression.
526///
527/// Returns true for strings like "today", "now + 1d", "time - 2h", "week", "year",
528/// "week_start", "week_end", ISO date literals like "2025-01-15",
529/// or ISO week notation like "2025-W01".
530pub fn is_date_expr(s: &str) -> bool {
531    let s = s.trim();
532    let lower = s.to_lowercase();
533
534    // Check for keyword-based expressions
535    // Note: "week" matches week, week_start, week_end
536    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    // Extract the base part (before any + or - operator with space, or format specifier)
553    let base_part = if let Some(idx) = s.find(['+', '|']) {
554        s[..idx].trim()
555    } else if let Some(idx) = s.rfind(" -") {
556        // Use rfind for " -" to avoid matching the hyphens in the date/week
557        s[..idx].trim()
558    } else {
559        s
560    };
561
562    // Check for ISO date literal or ISO week notation
563    looks_like_iso_date(base_part) || looks_like_iso_week(base_part)
564}
565
566/// Evaluate a date expression string if it is one, otherwise return None.
567pub 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        // Should be in YYYY-MM-DD format
658        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        // Should be a day name like "Monday", "Tuesday", etc.
677        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        // Jan 31 + 1 month should be Feb 28 (non-leap year)
692        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        // Jan 31 + 1 month in leap year should be Feb 29
700        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        // Should be a number between 1 and 53
734        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        // Should be like "2025-W51"
743        assert!(result.contains("-W"));
744        assert!(result.len() >= 8); // "YYYY-WNN"
745    }
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        // Should be a valid week number
752        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        // Should be a 4-digit year
767        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    // Tests for ISO date literals
782
783    #[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"); // 2025-01-15 is a Wednesday
883    }
884
885    #[test]
886    fn test_evaluate_iso_date_weekday_offset() {
887        // 2025-01-15 is Wednesday, previous Monday is 2025-01-13
888        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        // 2025-01-15 is Wednesday, next Friday is 2025-01-17
896        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")); // Invalid format (single digit month)
909        assert!(!is_date_expr("25-01-15")); // Invalid format (2-digit year)
910    }
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        // Invalid date should fail parsing
924        assert!(parse_date_expr("2025-13-45").is_err());
925        assert!(parse_date_expr("not-a-date").is_err());
926    }
927
928    // Tests for week_start and week_end
929
930    #[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        // Test that week_start returns a Monday
957        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        // Test that week_end returns a Sunday
966        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        // week_start and week_end should be 6 days apart
975        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        // week_start + 1w should be 7 days after week_start
988        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    // Tests for ISO week notation
1000
1001    #[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        // 2025-W01 should resolve to Monday of that week
1027        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        // Week 1 of 2025 starts on 2024-12-30 (ISO week definition)
1032        assert_eq!(result, "2024-12-30");
1033    }
1034
1035    #[test]
1036    fn test_evaluate_iso_week_sunday() {
1037        // 2025-W01 + 6d should give Sunday of that week
1038        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        // 2025-W03 should start on 2025-01-13 (Monday)
1048        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        // Test generating all days of a week
1056        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")); // incomplete
1095        assert!(!is_date_expr("W01")); // missing year
1096    }
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        // Week 0 is invalid
1110        assert!(parse_date_expr("2025-W00").is_err());
1111        // Week 54+ is invalid
1112        assert!(parse_date_expr("2025-W54").is_err());
1113    }
1114}