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