Skip to main content

truth_engine/
temporal.rs

1//! Deterministic temporal computation for AI agents.
2//!
3//! Provides pure functions for timezone conversion, duration computation,
4//! timestamp adjustment, and relative datetime resolution. All functions
5//! take explicit inputs (no system clock access) — the caller provides
6//! the "now" anchor when needed, keeping these functions testable and
7//! WASM-compatible.
8//!
9//! # Design Principle
10//!
11//! These functions replace LLM inference with deterministic computation.
12//! If an expression cannot be parsed unambiguously, we return an error
13//! rather than guessing — the opposite of what LLMs do.
14//!
15//! # Functions
16//!
17//! - [`convert_timezone`] — Convert a datetime between timezone representations
18//! - [`compute_duration`] — Calculate the duration between two timestamps
19//! - [`adjust_timestamp`] — Add or subtract a duration from a timestamp
20//! - [`resolve_relative`] — Resolve a relative time expression to an absolute datetime
21//!
22//! # Datetime Accuracy
23//!
24//! When used via the MCP server, the "now" anchor comes from `chrono::Utc::now()`,
25//! which reads the OS kernel clock (NTP-synchronized on modern systems, typically
26//! <50ms accuracy). No online time service is used.
27
28use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, Offset, TimeZone, Utc, Weekday};
29use chrono_tz::Tz;
30use serde::Serialize;
31
32use crate::error::TruthError;
33
34// ── Configurable week start ─────────────────────────────────────────────────
35
36/// Which day begins a week for period computations ("start of week", "next week", etc.).
37///
38/// Does **not** affect named-weekday expressions like "next Monday" or "last Friday".
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
40pub enum WeekStartDay {
41    /// ISO 8601 standard (Monday = day 0 of the week).
42    #[default]
43    Monday,
44    /// US/Canada convention (Sunday = day 0 of the week).
45    Sunday,
46}
47
48/// Options for [`resolve_relative_with_options`].
49#[derive(Debug, Clone, Default)]
50pub struct ResolveOptions {
51    /// Which day starts the week for period computations.
52    pub week_start: WeekStartDay,
53}
54
55/// How many days `weekday` is from the week-start day.
56fn days_from_week_start(weekday: Weekday, week_start: WeekStartDay) -> i64 {
57    match week_start {
58        WeekStartDay::Monday => weekday.num_days_from_monday() as i64,
59        WeekStartDay::Sunday => weekday.num_days_from_sunday() as i64,
60    }
61}
62
63// ── convert_timezone ────────────────────────────────────────────────────────
64
65/// The result of converting a datetime to a target timezone.
66#[derive(Debug, Clone, Serialize)]
67pub struct ConvertedDatetime {
68    /// The instant in UTC (RFC 3339).
69    pub utc: String,
70    /// The instant in the target timezone (RFC 3339 with offset).
71    pub local: String,
72    /// The IANA timezone name used.
73    pub timezone: String,
74    /// The UTC offset at this instant (e.g., "-05:00").
75    pub utc_offset: String,
76    /// Whether Daylight Saving Time is active at this instant.
77    pub dst_active: bool,
78}
79
80/// Convert a datetime string to a different timezone representation.
81///
82/// # Arguments
83///
84/// * `datetime` — An RFC 3339 datetime string (e.g., `"2026-03-15T14:00:00Z"`)
85/// * `target_timezone` — An IANA timezone name (e.g., `"America/New_York"`)
86///
87/// # Returns
88///
89/// A [`ConvertedDatetime`] with the same instant expressed in the target timezone,
90/// plus metadata (UTC offset, DST status).
91///
92/// # Errors
93///
94/// Returns [`TruthError::InvalidDatetime`] if the datetime string cannot be parsed,
95/// or [`TruthError::InvalidTimezone`] if the timezone name is not a valid IANA timezone.
96///
97/// # Examples
98///
99/// ```
100/// use truth_engine::temporal::convert_timezone;
101///
102/// let result = convert_timezone("2026-03-15T14:00:00Z", "America/New_York").unwrap();
103/// assert_eq!(result.timezone, "America/New_York");
104/// // March 15 2026 is EDT (UTC-4), so 14:00 UTC = 10:00 local
105/// assert!(result.local.contains("10:00:00"));
106/// ```
107pub fn convert_timezone(
108    datetime: &str,
109    target_timezone: &str,
110) -> Result<ConvertedDatetime, TruthError> {
111    let dt = parse_rfc3339(datetime)?;
112    let tz = parse_timezone(target_timezone)?;
113
114    let local = dt.with_timezone(&tz);
115
116    // Determine DST: compare the timezone's standard offset with the current offset.
117    // If they differ, DST is active.
118    let dst_active = is_dst_active(&local, &tz);
119
120    let utc_offset = format_utc_offset(&local);
121
122    Ok(ConvertedDatetime {
123        utc: dt.to_rfc3339(),
124        local: local.to_rfc3339(),
125        timezone: target_timezone.to_string(),
126        utc_offset,
127        dst_active,
128    })
129}
130
131// ── compute_duration ────────────────────────────────────────────────────────
132
133/// Duration information between two timestamps.
134#[derive(Debug, Clone, Serialize)]
135pub struct DurationInfo {
136    /// Total duration in seconds (negative if end is before start).
137    pub total_seconds: i64,
138    /// Days component of the decomposed duration.
139    pub days: i64,
140    /// Hours component (0-23).
141    pub hours: i64,
142    /// Minutes component (0-59).
143    pub minutes: i64,
144    /// Seconds component (0-59).
145    pub seconds: i64,
146    /// Human-readable representation (e.g., "2 days, 3 hours, 15 minutes").
147    pub human_readable: String,
148}
149
150/// Compute the duration between two timestamps.
151///
152/// # Arguments
153///
154/// * `start` — An RFC 3339 datetime string
155/// * `end` — An RFC 3339 datetime string
156///
157/// # Returns
158///
159/// A [`DurationInfo`] with the total seconds and decomposed days/hours/minutes/seconds.
160/// If `end` is before `start`, `total_seconds` is negative and the decomposition
161/// represents the absolute duration.
162///
163/// # Errors
164///
165/// Returns [`TruthError::InvalidDatetime`] if either datetime string cannot be parsed.
166pub fn compute_duration(start: &str, end: &str) -> Result<DurationInfo, TruthError> {
167    let start_dt = parse_rfc3339(start)?;
168    let end_dt = parse_rfc3339(end)?;
169
170    let total_seconds = (end_dt - start_dt).num_seconds();
171    let abs_seconds = total_seconds.unsigned_abs();
172
173    let days = (abs_seconds / 86400) as i64;
174    let remainder = abs_seconds % 86400;
175    let hours = (remainder / 3600) as i64;
176    let remainder = remainder % 3600;
177    let minutes = (remainder / 60) as i64;
178    let seconds = (remainder % 60) as i64;
179
180    let human_readable = format_human_duration(days, hours, minutes, seconds);
181
182    Ok(DurationInfo {
183        total_seconds,
184        days,
185        hours,
186        minutes,
187        seconds,
188        human_readable,
189    })
190}
191
192// ── adjust_timestamp ────────────────────────────────────────────────────────
193
194/// The result of adjusting a timestamp by a duration.
195#[derive(Debug, Clone, Serialize)]
196pub struct AdjustedTimestamp {
197    /// The original datetime (echoed back).
198    pub original: String,
199    /// The adjusted datetime in UTC (RFC 3339).
200    pub adjusted_utc: String,
201    /// The adjusted datetime in the source timezone (RFC 3339 with offset).
202    pub adjusted_local: String,
203    /// The normalized adjustment applied (e.g., "+2h30m").
204    pub adjustment_applied: String,
205}
206
207/// Parsed duration components from an adjustment string.
208#[derive(Debug, Clone, Default)]
209struct ParsedDuration {
210    sign: i64, // +1 or -1
211    weeks: i64,
212    days: i64,
213    hours: i64,
214    minutes: i64,
215    seconds: i64,
216}
217
218/// Adjust a timestamp by adding or subtracting a duration.
219///
220/// # Arguments
221///
222/// * `datetime` — An RFC 3339 datetime string
223/// * `adjustment` — A duration string (e.g., `"+2h"`, `"-30m"`, `"+1d2h30m"`, `"+1w"`)
224/// * `timezone` — An IANA timezone name for interpreting day-level adjustments
225///
226/// # Duration Format
227///
228/// Must start with `+` or `-`, followed by one or more components:
229/// - `Nw` — weeks
230/// - `Nd` — days (timezone-aware: same wall-clock time, not +24h across DST)
231/// - `Nh` — hours
232/// - `Nm` — minutes
233/// - `Ns` — seconds
234///
235/// Components can be combined: `+1d2h30m`, `-2w3d`.
236///
237/// # Errors
238///
239/// Returns [`TruthError::InvalidDatetime`] if the datetime cannot be parsed,
240/// [`TruthError::InvalidTimezone`] if the timezone is invalid, or
241/// [`TruthError::InvalidDuration`] if the adjustment string cannot be parsed.
242pub fn adjust_timestamp(
243    datetime: &str,
244    adjustment: &str,
245    timezone: &str,
246) -> Result<AdjustedTimestamp, TruthError> {
247    let dt = parse_rfc3339(datetime)?;
248    let tz = parse_timezone(timezone)?;
249    let parsed = parse_duration_string(adjustment)?;
250
251    // For day/week adjustments, we work in local time to preserve wall-clock time
252    // across DST transitions. For sub-day adjustments, we work in UTC.
253    let local = dt.with_timezone(&tz);
254
255    let adjusted_local = if parsed.weeks != 0 || parsed.days != 0 {
256        // Day-level: adjust date in local time, then add sub-day components in UTC
257        let total_days = parsed.sign * (parsed.weeks * 7 + parsed.days);
258        let new_date = local.date_naive() + chrono::Duration::days(total_days);
259        let new_local_naive = new_date.and_time(local.time());
260
261        let adjusted_local_dt = tz
262            .from_local_datetime(&new_local_naive)
263            .single()
264            .ok_or_else(|| {
265                TruthError::InvalidDatetime(
266                    "ambiguous or nonexistent local time after day adjustment".to_string(),
267                )
268            })?;
269
270        // Add sub-day components in UTC
271        let sub_day_seconds =
272            parsed.sign * (parsed.hours * 3600 + parsed.minutes * 60 + parsed.seconds);
273        adjusted_local_dt + chrono::Duration::seconds(sub_day_seconds)
274    } else {
275        // Sub-day only: simple UTC arithmetic
276        let total_seconds =
277            parsed.sign * (parsed.hours * 3600 + parsed.minutes * 60 + parsed.seconds);
278        local + chrono::Duration::seconds(total_seconds)
279    };
280
281    let adjusted_utc = adjusted_local.with_timezone(&Utc);
282    let normalized = normalize_duration_string(&parsed);
283
284    Ok(AdjustedTimestamp {
285        original: datetime.to_string(),
286        adjusted_utc: adjusted_utc.to_rfc3339(),
287        adjusted_local: adjusted_local.to_rfc3339(),
288        adjustment_applied: normalized,
289    })
290}
291
292// ── resolve_relative ────────────────────────────────────────────────────────
293
294/// The result of resolving a relative time expression.
295#[derive(Debug, Clone, Serialize)]
296pub struct ResolvedDatetime {
297    /// The resolved datetime in UTC (RFC 3339).
298    pub resolved_utc: String,
299    /// The resolved datetime in the given timezone (RFC 3339 with offset).
300    pub resolved_local: String,
301    /// The IANA timezone used for resolution.
302    pub timezone: String,
303    /// Human-readable interpretation (e.g., "Tuesday, February 24, 2026 at 2:00 PM EST").
304    pub interpretation: String,
305}
306
307/// Resolve a relative time expression to an absolute datetime.
308///
309/// Uses ISO 8601 week start (Monday). For configurable week start, use
310/// [`resolve_relative_with_options`].
311///
312/// # Arguments
313///
314/// * `anchor` — The reference "now" instant (typically `Utc::now()`)
315/// * `expression` — A time expression (see [`resolve_relative_with_options`] for grammar)
316/// * `timezone` — An IANA timezone name for interpreting local-time expressions
317///
318/// # Errors
319///
320/// Returns [`TruthError::InvalidExpression`] if the expression cannot be parsed
321/// deterministically.
322pub fn resolve_relative(
323    anchor: DateTime<Utc>,
324    expression: &str,
325    timezone: &str,
326) -> Result<ResolvedDatetime, TruthError> {
327    resolve_relative_with_options(anchor, expression, timezone, &ResolveOptions::default())
328}
329
330/// Resolve a relative time expression to an absolute datetime with options.
331///
332/// # Arguments
333///
334/// * `anchor` — The reference "now" instant (typically `Utc::now()`)
335/// * `expression` — A time expression (see grammar below)
336/// * `timezone` — An IANA timezone name for interpreting local-time expressions
337/// * `options` — Resolution options (week start day, etc.)
338///
339/// # Supported Expressions
340///
341/// **Anchored**: `"now"`, `"today"`, `"tomorrow"`, `"yesterday"`
342///
343/// **Weekday-relative**: `"next Monday"`, `"this Friday"`, `"last Wednesday"`
344///
345/// **Time-of-day**: `"morning"` (09:00), `"noon"` (12:00), `"afternoon"` (13:00),
346/// `"evening"` (18:00), `"night"` (21:00), `"midnight"` (00:00),
347/// `"end of day"` / `"eob"` (17:00), `"start of business"` / `"sob"` (09:00), `"lunch"` (12:00)
348///
349/// **Explicit time**: `"2pm"`, `"2:30pm"`, `"14:00"`, `"14:30"`
350///
351/// **Offset durations**: `"+2h"`, `"-30m"`, `"in 2 hours"`, `"30 minutes ago"`,
352/// `"a week from now"`
353///
354/// **Combined**: `"next Tuesday at 2pm"`, `"tomorrow morning"`,
355/// `"next Friday at 10:30am"`
356///
357/// **Period boundaries**: `"start of week"`, `"end of month"`, `"start of quarter"`,
358/// `"next week"`, `"last month"`, `"next year"`
359///
360/// **Compound periods**: `"start of last week"`, `"end of next month"`,
361/// `"start of next quarter"`, `"end of last year"`
362///
363/// **Ordinal dates**: `"first Monday of March"`, `"last Friday of the month"`,
364/// `"third Tuesday of March 2026"`
365///
366/// **Passthrough**: Any valid RFC 3339 or ISO 8601 date string
367///
368/// # Errors
369///
370/// Returns [`TruthError::InvalidExpression`] if the expression cannot be parsed
371/// deterministically. This function **never guesses** — it returns an error for
372/// any ambiguous input.
373pub fn resolve_relative_with_options(
374    anchor: DateTime<Utc>,
375    expression: &str,
376    timezone: &str,
377    options: &ResolveOptions,
378) -> Result<ResolvedDatetime, TruthError> {
379    let tz = parse_timezone(timezone)?;
380    let local_anchor = anchor.with_timezone(&tz);
381    let ws = options.week_start;
382
383    // Normalize: trim, lowercase, strip articles
384    let normalized = normalize_expression(expression);
385
386    // Try each parser in order of specificity
387    let resolved_local = try_passthrough_rfc3339(&normalized)
388        .map(|dt| dt.with_timezone(&tz))
389        .or_else(|| try_passthrough_iso_date(&normalized, &tz))
390        .or_else(|| try_anchored(&normalized, &local_anchor, &tz))
391        .or_else(|| try_combined_weekday_time(&normalized, &local_anchor, &tz))
392        .or_else(|| try_combined_anchor_time(&normalized, &local_anchor, &tz))
393        .or_else(|| try_weekday_relative(&normalized, &local_anchor, &tz))
394        .or_else(|| try_compound_period(&normalized, &local_anchor, &tz, ws))
395        .or_else(|| try_period_boundary(&normalized, &local_anchor, &tz, ws))
396        .or_else(|| try_period_relative(&normalized, &local_anchor, &tz, ws))
397        .or_else(|| try_ordinal_date(&normalized, &local_anchor, &tz))
398        .or_else(|| try_natural_offset(&normalized, &anchor))
399        .or_else(|| try_duration_offset(&normalized, &anchor))
400        .or_else(|| try_time_of_day_named(&normalized, &local_anchor, &tz))
401        .or_else(|| try_explicit_time(&normalized, &local_anchor, &tz))
402        .ok_or_else(|| {
403            TruthError::InvalidExpression(format!(
404                "cannot parse expression: '{}'",
405                expression.trim()
406            ))
407        })?;
408
409    let resolved_utc = resolved_local.with_timezone(&Utc);
410    let interpretation = format_interpretation(&resolved_local);
411
412    Ok(ResolvedDatetime {
413        resolved_utc: resolved_utc.to_rfc3339(),
414        resolved_local: resolved_local.to_rfc3339(),
415        timezone: timezone.to_string(),
416        interpretation,
417    })
418}
419
420// ── Internal helpers ────────────────────────────────────────────────────────
421
422/// Parse an RFC 3339 datetime string into `DateTime<Utc>`.
423fn parse_rfc3339(s: &str) -> Result<DateTime<Utc>, TruthError> {
424    DateTime::parse_from_rfc3339(s)
425        .map(|dt| dt.with_timezone(&Utc))
426        .map_err(|e| TruthError::InvalidDatetime(format!("'{}': {}", s, e)))
427}
428
429/// Parse an IANA timezone string into `Tz`.
430fn parse_timezone(s: &str) -> Result<Tz, TruthError> {
431    s.parse::<Tz>()
432        .map_err(|_| TruthError::InvalidTimezone(format!("'{}'", s)))
433}
434
435/// Determine if DST is active for a datetime in a timezone.
436fn is_dst_active<T: TimeZone>(dt: &DateTime<T>, tz: &Tz) -> bool {
437    // Compare January 1 offset (winter / standard) with the current offset.
438    // If they differ, DST is active.
439    let utc = dt.with_timezone(&Utc);
440    let year = utc.year();
441
442    let jan1 = Utc
443        .with_ymd_and_hms(year, 1, 1, 12, 0, 0)
444        .single()
445        .unwrap_or(utc);
446    let jan1_local = jan1.with_timezone(tz);
447
448    let current_offset = dt.offset().fix().local_minus_utc();
449    let jan_offset = jan1_local.offset().fix().local_minus_utc();
450
451    current_offset != jan_offset
452}
453
454/// Format the UTC offset as a string (e.g., "-05:00", "+09:00").
455fn format_utc_offset<T: TimeZone>(dt: &DateTime<T>) -> String {
456    let offset_secs = dt.offset().fix().local_minus_utc();
457    let sign = if offset_secs >= 0 { "+" } else { "-" };
458    let abs_secs = offset_secs.unsigned_abs();
459    let hours = abs_secs / 3600;
460    let minutes = (abs_secs % 3600) / 60;
461    format!("{sign}{hours:02}:{minutes:02}")
462}
463
464/// Format a human-readable duration string.
465fn format_human_duration(days: i64, hours: i64, minutes: i64, seconds: i64) -> String {
466    let mut parts = Vec::new();
467    if days > 0 {
468        parts.push(format!("{} day{}", days, if days == 1 { "" } else { "s" }));
469    }
470    if hours > 0 {
471        parts.push(format!(
472            "{} hour{}",
473            hours,
474            if hours == 1 { "" } else { "s" }
475        ));
476    }
477    if minutes > 0 {
478        parts.push(format!(
479            "{} minute{}",
480            minutes,
481            if minutes == 1 { "" } else { "s" }
482        ));
483    }
484    if seconds > 0 || parts.is_empty() {
485        parts.push(format!(
486            "{} second{}",
487            seconds,
488            if seconds == 1 { "" } else { "s" }
489        ));
490    }
491    parts.join(", ")
492}
493
494/// Parse a duration adjustment string (e.g., "+2h", "-1d30m", "+1w2d").
495fn parse_duration_string(s: &str) -> Result<ParsedDuration, TruthError> {
496    let s = s.trim();
497    if s.is_empty() {
498        return Err(TruthError::InvalidDuration("empty duration".to_string()));
499    }
500
501    let (sign, rest) = match s.as_bytes().first() {
502        Some(b'+') => (1i64, &s[1..]),
503        Some(b'-') => (-1i64, &s[1..]),
504        _ => {
505            return Err(TruthError::InvalidDuration(format!(
506                "duration must start with '+' or '-': '{s}'"
507            )));
508        }
509    };
510
511    if rest.is_empty() {
512        return Err(TruthError::InvalidDuration(format!(
513            "duration has no components: '{s}'"
514        )));
515    }
516
517    let mut parsed = ParsedDuration {
518        sign,
519        ..Default::default()
520    };
521
522    let mut num_buf = String::new();
523    let mut found_any = false;
524
525    for ch in rest.chars() {
526        if ch.is_ascii_digit() {
527            num_buf.push(ch);
528        } else {
529            if num_buf.is_empty() {
530                return Err(TruthError::InvalidDuration(format!(
531                    "expected number before '{ch}' in '{s}'"
532                )));
533            }
534            let n: i64 = num_buf
535                .parse()
536                .map_err(|_| TruthError::InvalidDuration(format!("invalid number in '{s}'")))?;
537            num_buf.clear();
538            found_any = true;
539
540            match ch {
541                'w' | 'W' => parsed.weeks += n,
542                'd' | 'D' => parsed.days += n,
543                'h' | 'H' => parsed.hours += n,
544                'm' | 'M' => parsed.minutes += n,
545                's' | 'S' => parsed.seconds += n,
546                _ => {
547                    return Err(TruthError::InvalidDuration(format!(
548                        "unknown unit '{ch}' in '{s}'"
549                    )));
550                }
551            }
552        }
553    }
554
555    // Trailing number without unit
556    if !num_buf.is_empty() {
557        return Err(TruthError::InvalidDuration(format!(
558            "number without unit at end of '{s}'"
559        )));
560    }
561
562    if !found_any {
563        return Err(TruthError::InvalidDuration(format!(
564            "no valid components in '{s}'"
565        )));
566    }
567
568    Ok(parsed)
569}
570
571/// Normalize a parsed duration back to a string like "+1d2h30m".
572fn normalize_duration_string(d: &ParsedDuration) -> String {
573    let sign = if d.sign >= 0 { "+" } else { "-" };
574    let mut parts = String::from(sign);
575    if d.weeks != 0 {
576        parts.push_str(&format!("{}w", d.weeks));
577    }
578    if d.days != 0 {
579        parts.push_str(&format!("{}d", d.days));
580    }
581    if d.hours != 0 {
582        parts.push_str(&format!("{}h", d.hours));
583    }
584    if d.minutes != 0 {
585        parts.push_str(&format!("{}m", d.minutes));
586    }
587    if d.seconds != 0 {
588        parts.push_str(&format!("{}s", d.seconds));
589    }
590    if parts.len() == 1 {
591        // Only sign, no components (shouldn't happen after parsing, but defensive)
592        parts.push_str("0s");
593    }
594    parts
595}
596
597// ── resolve_relative expression parsers ─────────────────────────────────────
598
599/// Normalize expression: trim, lowercase, strip common articles (but not "a"/"an" at start
600/// since those are meaningful for patterns like "a week from now").
601fn normalize_expression(s: &str) -> String {
602    let s = s.trim().to_lowercase();
603    // Strip articles in the middle: "the", "a", "an"
604    let s = s
605        .replace(" the ", " ")
606        .replace(" a ", " ")
607        .replace(" an ", " ");
608    // Strip leading "the " only (not "a "/"an " — they matter for "a week from now")
609    let s = s.strip_prefix("the ").unwrap_or(&s).to_string();
610    // Collapse multiple spaces
611    let mut result = String::new();
612    let mut prev_space = false;
613    for ch in s.chars() {
614        if ch == ' ' {
615            if !prev_space {
616                result.push(' ');
617            }
618            prev_space = true;
619        } else {
620            result.push(ch);
621            prev_space = false;
622        }
623    }
624    result.trim().to_string()
625}
626
627/// Try to parse as an RFC 3339 passthrough.
628fn try_passthrough_rfc3339(s: &str) -> Option<DateTime<Utc>> {
629    DateTime::parse_from_rfc3339(s)
630        .map(|dt| dt.with_timezone(&Utc))
631        .ok()
632}
633
634/// Try to parse as an ISO 8601 date (YYYY-MM-DD) → start of day in timezone.
635fn try_passthrough_iso_date(s: &str, tz: &Tz) -> Option<DateTime<Tz>> {
636    NaiveDate::parse_from_str(s, "%Y-%m-%d")
637        .ok()
638        .and_then(|date| {
639            let naive = date.and_hms_opt(0, 0, 0)?;
640            tz.from_local_datetime(&naive).single()
641        })
642}
643
644/// Try anchored references: "now", "today", "tomorrow", "yesterday".
645fn try_anchored(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
646    match s {
647        "now" => Some(*local),
648        "today" => make_local_start_of_day(local, tz),
649        "tomorrow" => {
650            let next = local.date_naive().succ_opt()?;
651            let naive = next.and_hms_opt(0, 0, 0)?;
652            tz.from_local_datetime(&naive).single()
653        }
654        "yesterday" => {
655            let prev = local.date_naive().pred_opt()?;
656            let naive = prev.and_hms_opt(0, 0, 0)?;
657            tz.from_local_datetime(&naive).single()
658        }
659        _ => None,
660    }
661}
662
663/// Try weekday-relative: "next Monday", "this Friday", "last Wednesday".
664fn try_weekday_relative(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
665    let parts: Vec<&str> = s.splitn(2, ' ').collect();
666    if parts.len() != 2 {
667        return None;
668    }
669
670    let modifier = parts[0];
671    let weekday = parse_weekday(parts[1])?;
672    let current = local.weekday();
673
674    let target_date = match modifier {
675        "next" => {
676            // Always future: if today is the same weekday, go to next week
677            let days_ahead =
678                (weekday.num_days_from_monday() as i64 - current.num_days_from_monday() as i64 + 7)
679                    % 7;
680            let days_ahead = if days_ahead == 0 { 7 } else { days_ahead };
681            local.date_naive() + chrono::Duration::days(days_ahead)
682        }
683        "this" => {
684            // Same week: may be past or future
685            let diff =
686                weekday.num_days_from_monday() as i64 - current.num_days_from_monday() as i64;
687            local.date_naive() + chrono::Duration::days(diff)
688        }
689        "last" => {
690            // Always past: if today is the same weekday, go to last week
691            let days_back =
692                (current.num_days_from_monday() as i64 - weekday.num_days_from_monday() as i64 + 7)
693                    % 7;
694            let days_back = if days_back == 0 { 7 } else { days_back };
695            local.date_naive() - chrono::Duration::days(days_back)
696        }
697        _ => return None,
698    };
699
700    let naive = target_date.and_hms_opt(0, 0, 0)?;
701    tz.from_local_datetime(&naive).single()
702}
703
704/// Try combined weekday + time: "next Tuesday at 2pm", "next Friday at 10:30am".
705fn try_combined_weekday_time(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
706    // Pattern: "(next|this|last) <weekday> at <time>"
707    // or: "(next|this|last) <weekday> <named_time>"
708    let parts: Vec<&str> = s.splitn(3, ' ').collect();
709    if parts.len() < 2 {
710        return None;
711    }
712
713    let modifier = parts[0];
714    if !matches!(modifier, "next" | "this" | "last") {
715        return None;
716    }
717
718    // Check for weekday in parts[1]
719    let weekday_str = parts[1];
720    let _weekday = parse_weekday(weekday_str)?;
721
722    // Get the base date from weekday-relative
723    let weekday_expr = format!("{} {}", modifier, weekday_str);
724    let base = try_weekday_relative(&weekday_expr, local, tz)?;
725
726    if parts.len() == 2 {
727        return Some(base);
728    }
729
730    let time_part = parts[2];
731
732    // Handle "at <time>" pattern
733    if let Some(at_time) = time_part.strip_prefix("at ") {
734        let time = parse_time_string(at_time)?;
735        let naive = base.date_naive().and_time(time);
736        return tz.from_local_datetime(&naive).single();
737    }
738
739    // Handle named time: "morning", "afternoon", etc.
740    if let Some(time) = named_time_to_naive(time_part) {
741        let naive = base.date_naive().and_time(time);
742        return tz.from_local_datetime(&naive).single();
743    }
744
745    None
746}
747
748/// Try combined anchor + time: "tomorrow at 2pm", "today at noon", "tomorrow morning".
749fn try_combined_anchor_time(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
750    let parts: Vec<&str> = s.splitn(2, ' ').collect();
751    if parts.len() != 2 {
752        return None;
753    }
754
755    let anchor_str = parts[0];
756    if !matches!(anchor_str, "today" | "tomorrow" | "yesterday") {
757        return None;
758    }
759
760    let base = try_anchored(anchor_str, local, tz)?;
761    let time_part = parts[1];
762
763    // "at <time>" — try named time first (e.g., "at noon"), then explicit time (e.g., "at 2pm")
764    if let Some(at_time) = time_part.strip_prefix("at ") {
765        if let Some(time) = named_time_to_naive(at_time) {
766            let naive = base.date_naive().and_time(time);
767            return tz.from_local_datetime(&naive).single();
768        }
769        let time = parse_time_string(at_time)?;
770        let naive = base.date_naive().and_time(time);
771        return tz.from_local_datetime(&naive).single();
772    }
773
774    // Named time
775    if let Some(time) = named_time_to_naive(time_part) {
776        let naive = base.date_naive().and_time(time);
777        return tz.from_local_datetime(&naive).single();
778    }
779
780    None
781}
782
783/// Try time-of-day named anchors: "morning", "noon", "afternoon", etc.
784fn try_time_of_day_named(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
785    let time = named_time_to_naive(s)?;
786    let naive = local.date_naive().and_time(time);
787    tz.from_local_datetime(&naive).single()
788}
789
790/// Try explicit time: "2pm", "2:30pm", "14:00".
791fn try_explicit_time(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
792    let time = parse_time_string(s)?;
793    let naive = local.date_naive().and_time(time);
794    tz.from_local_datetime(&naive).single()
795}
796
797/// Try natural offset: "in 2 hours", "30 minutes ago", "a week from now".
798fn try_natural_offset(s: &str, anchor: &DateTime<Utc>) -> Option<DateTime<Tz>> {
799    // "in N unit(s)"
800    if let Some(rest) = s.strip_prefix("in ") {
801        let (n, unit) = parse_natural_number_and_unit(rest)?;
802        let seconds = unit_to_seconds(n, &unit)?;
803        let result = *anchor + chrono::Duration::seconds(seconds);
804        // Return as UTC (which is a valid Tz via chrono_tz)
805        let utc_tz: Tz = "UTC".parse().ok()?;
806        return Some(result.with_timezone(&utc_tz));
807    }
808
809    // "N unit(s) ago"
810    if s.ends_with(" ago") {
811        let rest = s.strip_suffix(" ago")?;
812        let (n, unit) = parse_natural_number_and_unit(rest)?;
813        let seconds = unit_to_seconds(n, &unit)?;
814        let result = *anchor - chrono::Duration::seconds(seconds);
815        let utc_tz: Tz = "UTC".parse().ok()?;
816        return Some(result.with_timezone(&utc_tz));
817    }
818
819    // "a/an <unit> from now"
820    if s.ends_with(" from now") {
821        let rest = s.strip_suffix(" from now")?;
822        let (n, unit) = parse_natural_number_and_unit_with_article(rest)?;
823        let seconds = unit_to_seconds(n, &unit)?;
824        let result = *anchor + chrono::Duration::seconds(seconds);
825        let utc_tz: Tz = "UTC".parse().ok()?;
826        return Some(result.with_timezone(&utc_tz));
827    }
828
829    None
830}
831
832/// Try duration offset: "+2h", "-30m", "+1d2h30m".
833fn try_duration_offset(s: &str, anchor: &DateTime<Utc>) -> Option<DateTime<Tz>> {
834    if !s.starts_with('+') && !s.starts_with('-') {
835        return None;
836    }
837    let parsed = parse_duration_string(s).ok()?;
838    let total_seconds = parsed.sign
839        * (parsed.weeks * 7 * 86400
840            + parsed.days * 86400
841            + parsed.hours * 3600
842            + parsed.minutes * 60
843            + parsed.seconds);
844    let result = *anchor + chrono::Duration::seconds(total_seconds);
845    let utc_tz: Tz = "UTC".parse().ok()?;
846    Some(result.with_timezone(&utc_tz))
847}
848
849/// Try period boundary: "start of week", "end of month", etc.
850fn try_period_boundary(
851    s: &str,
852    local: &DateTime<Tz>,
853    tz: &Tz,
854    ws: WeekStartDay,
855) -> Option<DateTime<Tz>> {
856    match s {
857        "start of today" => make_local_start_of_day(local, tz),
858        "end of today" => {
859            let naive = local.date_naive().and_hms_opt(23, 59, 59)?;
860            tz.from_local_datetime(&naive).single()
861        }
862        "start of week" => {
863            let days_since_start = days_from_week_start(local.weekday(), ws);
864            let start = local.date_naive() - chrono::Duration::days(days_since_start);
865            let naive = start.and_hms_opt(0, 0, 0)?;
866            tz.from_local_datetime(&naive).single()
867        }
868        "end of week" => {
869            let days_until_end = 6 - days_from_week_start(local.weekday(), ws);
870            let end = local.date_naive() + chrono::Duration::days(days_until_end);
871            let naive = end.and_hms_opt(23, 59, 59)?;
872            tz.from_local_datetime(&naive).single()
873        }
874        "start of month" => {
875            let date = NaiveDate::from_ymd_opt(local.year(), local.month(), 1)?;
876            let naive = date.and_hms_opt(0, 0, 0)?;
877            tz.from_local_datetime(&naive).single()
878        }
879        "end of month" => {
880            let (y, m) = if local.month() == 12 {
881                (local.year() + 1, 1)
882            } else {
883                (local.year(), local.month() + 1)
884            };
885            let first_next = NaiveDate::from_ymd_opt(y, m, 1)?;
886            let last_day = first_next.pred_opt()?;
887            let naive = last_day.and_hms_opt(23, 59, 59)?;
888            tz.from_local_datetime(&naive).single()
889        }
890        "start of year" => {
891            let date = NaiveDate::from_ymd_opt(local.year(), 1, 1)?;
892            let naive = date.and_hms_opt(0, 0, 0)?;
893            tz.from_local_datetime(&naive).single()
894        }
895        "end of year" => {
896            let date = NaiveDate::from_ymd_opt(local.year(), 12, 31)?;
897            let naive = date.and_hms_opt(23, 59, 59)?;
898            tz.from_local_datetime(&naive).single()
899        }
900        "start of quarter" => {
901            let q_start_month = ((local.month() - 1) / 3) * 3 + 1;
902            let date = NaiveDate::from_ymd_opt(local.year(), q_start_month, 1)?;
903            let naive = date.and_hms_opt(0, 0, 0)?;
904            tz.from_local_datetime(&naive).single()
905        }
906        "end of quarter" => {
907            let q_end_month = ((local.month() - 1) / 3 + 1) * 3;
908            let (y, m) = if q_end_month == 12 {
909                (local.year() + 1, 1)
910            } else {
911                (local.year(), q_end_month + 1)
912            };
913            let first_next = NaiveDate::from_ymd_opt(y, m, 1)?;
914            let last_day = first_next.pred_opt()?;
915            let naive = last_day.and_hms_opt(23, 59, 59)?;
916            tz.from_local_datetime(&naive).single()
917        }
918        _ => None,
919    }
920}
921
922/// Try period relative: "next week", "last month", "next year", etc.
923fn try_period_relative(
924    s: &str,
925    local: &DateTime<Tz>,
926    tz: &Tz,
927    ws: WeekStartDay,
928) -> Option<DateTime<Tz>> {
929    match s {
930        "next week" => {
931            let days_until_next_start = 7 - days_from_week_start(local.weekday(), ws);
932            let start = local.date_naive() + chrono::Duration::days(days_until_next_start);
933            let naive = start.and_hms_opt(0, 0, 0)?;
934            tz.from_local_datetime(&naive).single()
935        }
936        "last week" => {
937            let days_since_start = days_from_week_start(local.weekday(), ws);
938            let this_start = local.date_naive() - chrono::Duration::days(days_since_start);
939            let last_start = this_start - chrono::Duration::days(7);
940            let naive = last_start.and_hms_opt(0, 0, 0)?;
941            tz.from_local_datetime(&naive).single()
942        }
943        "next month" => {
944            let (y, m) = if local.month() == 12 {
945                (local.year() + 1, 1)
946            } else {
947                (local.year(), local.month() + 1)
948            };
949            let date = NaiveDate::from_ymd_opt(y, m, 1)?;
950            let naive = date.and_hms_opt(0, 0, 0)?;
951            tz.from_local_datetime(&naive).single()
952        }
953        "last month" => {
954            let (y, m) = if local.month() == 1 {
955                (local.year() - 1, 12)
956            } else {
957                (local.year(), local.month() - 1)
958            };
959            let date = NaiveDate::from_ymd_opt(y, m, 1)?;
960            let naive = date.and_hms_opt(0, 0, 0)?;
961            tz.from_local_datetime(&naive).single()
962        }
963        "next year" => {
964            let date = NaiveDate::from_ymd_opt(local.year() + 1, 1, 1)?;
965            let naive = date.and_hms_opt(0, 0, 0)?;
966            tz.from_local_datetime(&naive).single()
967        }
968        "last year" => {
969            let date = NaiveDate::from_ymd_opt(local.year() - 1, 1, 1)?;
970            let naive = date.and_hms_opt(0, 0, 0)?;
971            tz.from_local_datetime(&naive).single()
972        }
973        _ => None,
974    }
975}
976
977/// Try compound period: "start of last week", "end of next month", etc.
978///
979/// Combines a boundary (start/end) with a period relative (last/next week/month/year/quarter).
980fn try_compound_period(
981    s: &str,
982    local: &DateTime<Tz>,
983    tz: &Tz,
984    ws: WeekStartDay,
985) -> Option<DateTime<Tz>> {
986    let (is_start, rest) = if let Some(r) = s.strip_prefix("start of ") {
987        (true, r)
988    } else if let Some(r) = s.strip_prefix("end of ") {
989        (false, r)
990    } else {
991        return None;
992    };
993
994    match rest {
995        "last week" => {
996            let days_since_start = days_from_week_start(local.weekday(), ws);
997            let this_start = local.date_naive() - chrono::Duration::days(days_since_start);
998            let last_start = this_start - chrono::Duration::days(7);
999            if is_start {
1000                let naive = last_start.and_hms_opt(0, 0, 0)?;
1001                tz.from_local_datetime(&naive).single()
1002            } else {
1003                let last_end = last_start + chrono::Duration::days(6);
1004                let naive = last_end.and_hms_opt(23, 59, 59)?;
1005                tz.from_local_datetime(&naive).single()
1006            }
1007        }
1008        "next week" => {
1009            let days_until_next_start = 7 - days_from_week_start(local.weekday(), ws);
1010            let next_start = local.date_naive() + chrono::Duration::days(days_until_next_start);
1011            if is_start {
1012                let naive = next_start.and_hms_opt(0, 0, 0)?;
1013                tz.from_local_datetime(&naive).single()
1014            } else {
1015                let next_end = next_start + chrono::Duration::days(6);
1016                let naive = next_end.and_hms_opt(23, 59, 59)?;
1017                tz.from_local_datetime(&naive).single()
1018            }
1019        }
1020        "last month" => {
1021            let (y, m) = if local.month() == 1 {
1022                (local.year() - 1, 12)
1023            } else {
1024                (local.year(), local.month() - 1)
1025            };
1026            if is_start {
1027                let date = NaiveDate::from_ymd_opt(y, m, 1)?;
1028                let naive = date.and_hms_opt(0, 0, 0)?;
1029                tz.from_local_datetime(&naive).single()
1030            } else {
1031                // Last day of prev month = day before 1st of current month
1032                let first_current = NaiveDate::from_ymd_opt(local.year(), local.month(), 1)?;
1033                let last_day = first_current.pred_opt()?;
1034                let naive = last_day.and_hms_opt(23, 59, 59)?;
1035                tz.from_local_datetime(&naive).single()
1036            }
1037        }
1038        "next month" => {
1039            let (y, m) = if local.month() == 12 {
1040                (local.year() + 1, 1)
1041            } else {
1042                (local.year(), local.month() + 1)
1043            };
1044            if is_start {
1045                let date = NaiveDate::from_ymd_opt(y, m, 1)?;
1046                let naive = date.and_hms_opt(0, 0, 0)?;
1047                tz.from_local_datetime(&naive).single()
1048            } else {
1049                // Last day of next month
1050                let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
1051                let first_after = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1052                let last_day = first_after.pred_opt()?;
1053                let naive = last_day.and_hms_opt(23, 59, 59)?;
1054                tz.from_local_datetime(&naive).single()
1055            }
1056        }
1057        "last year" => {
1058            let y = local.year() - 1;
1059            if is_start {
1060                let date = NaiveDate::from_ymd_opt(y, 1, 1)?;
1061                let naive = date.and_hms_opt(0, 0, 0)?;
1062                tz.from_local_datetime(&naive).single()
1063            } else {
1064                let date = NaiveDate::from_ymd_opt(y, 12, 31)?;
1065                let naive = date.and_hms_opt(23, 59, 59)?;
1066                tz.from_local_datetime(&naive).single()
1067            }
1068        }
1069        "next year" => {
1070            let y = local.year() + 1;
1071            if is_start {
1072                let date = NaiveDate::from_ymd_opt(y, 1, 1)?;
1073                let naive = date.and_hms_opt(0, 0, 0)?;
1074                tz.from_local_datetime(&naive).single()
1075            } else {
1076                let date = NaiveDate::from_ymd_opt(y, 12, 31)?;
1077                let naive = date.and_hms_opt(23, 59, 59)?;
1078                tz.from_local_datetime(&naive).single()
1079            }
1080        }
1081        "last quarter" => {
1082            let current_q = (local.month() - 1) / 3; // 0-based: Q1=0, Q2=1, Q3=2, Q4=3
1083            let (prev_y, prev_q) = if current_q == 0 {
1084                (local.year() - 1, 3)
1085            } else {
1086                (local.year(), current_q - 1)
1087            };
1088            let q_first_month = prev_q * 3 + 1;
1089            if is_start {
1090                let date = NaiveDate::from_ymd_opt(prev_y, q_first_month, 1)?;
1091                let naive = date.and_hms_opt(0, 0, 0)?;
1092                tz.from_local_datetime(&naive).single()
1093            } else {
1094                let q_last_month = prev_q * 3 + 3;
1095                let (ny, nm) = if q_last_month == 12 {
1096                    (prev_y + 1, 1)
1097                } else {
1098                    (prev_y, q_last_month + 1)
1099                };
1100                let first_after = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1101                let last_day = first_after.pred_opt()?;
1102                let naive = last_day.and_hms_opt(23, 59, 59)?;
1103                tz.from_local_datetime(&naive).single()
1104            }
1105        }
1106        "next quarter" => {
1107            let current_q = (local.month() - 1) / 3;
1108            let (next_y, next_q) = if current_q == 3 {
1109                (local.year() + 1, 0)
1110            } else {
1111                (local.year(), current_q + 1)
1112            };
1113            let q_first_month = next_q * 3 + 1;
1114            if is_start {
1115                let date = NaiveDate::from_ymd_opt(next_y, q_first_month, 1)?;
1116                let naive = date.and_hms_opt(0, 0, 0)?;
1117                tz.from_local_datetime(&naive).single()
1118            } else {
1119                let q_last_month = next_q * 3 + 3;
1120                let (ny, nm) = if q_last_month == 12 {
1121                    (next_y + 1, 1)
1122                } else {
1123                    (next_y, q_last_month + 1)
1124                };
1125                let first_after = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1126                let last_day = first_after.pred_opt()?;
1127                let naive = last_day.and_hms_opt(23, 59, 59)?;
1128                tz.from_local_datetime(&naive).single()
1129            }
1130        }
1131        _ => None,
1132    }
1133}
1134
1135/// Try ordinal date: "first Monday of March", "last Friday of the month",
1136/// "third Tuesday of March 2026".
1137fn try_ordinal_date(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
1138    // Pattern: "<ordinal> <weekday> of <month> [year]"
1139    // or: "last <weekday> of <month>" / "last day of <month>"
1140    let parts: Vec<&str> = s.split_whitespace().collect();
1141
1142    if parts.len() < 4 || parts.iter().position(|&p| p == "of")? < 2 {
1143        return None;
1144    }
1145
1146    let of_idx = parts.iter().position(|&p| p == "of")?;
1147    if of_idx < 2 {
1148        return None;
1149    }
1150
1151    let ordinal_str = parts[0];
1152    let target_str = parts[1];
1153
1154    // Parse "last day of <month>"
1155    if ordinal_str == "last" && target_str == "day" {
1156        let month_str = parts.get(of_idx + 1)?;
1157        let month = parse_month(month_str)?;
1158        let year = if let Some(y_str) = parts.get(of_idx + 2) {
1159            y_str.parse::<i32>().ok()?
1160        } else {
1161            local.year()
1162        };
1163        let (ny, nm) = if month == 12 {
1164            (year + 1, 1)
1165        } else {
1166            (year, month + 1)
1167        };
1168        let first_next = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1169        let last_day = first_next.pred_opt()?;
1170        let naive = last_day.and_hms_opt(0, 0, 0)?;
1171        return tz.from_local_datetime(&naive).single();
1172    }
1173
1174    let weekday = parse_weekday(target_str)?;
1175
1176    let month_part = parts.get(of_idx + 1)?;
1177    // "the month" → current month, otherwise parse month name
1178    let (month, year) = if *month_part == "month" {
1179        (local.month(), local.year())
1180    } else if let Some(month_num) = parse_month(month_part) {
1181        let year = if let Some(y_str) = parts.get(of_idx + 2) {
1182            y_str.parse::<i32>().unwrap_or(local.year())
1183        } else {
1184            local.year()
1185        };
1186        (month_num, year)
1187    } else if *month_part == "next" && parts.get(of_idx + 2) == Some(&"month") {
1188        let (y, m) = if local.month() == 12 {
1189            (local.year() + 1, 1)
1190        } else {
1191            (local.year(), local.month() + 1)
1192        };
1193        (m, y)
1194    } else {
1195        return None;
1196    };
1197
1198    let ordinal = parse_ordinal(ordinal_str)?;
1199
1200    let date = find_nth_weekday_in_month(year, month, weekday, ordinal)?;
1201    let naive = date.and_hms_opt(0, 0, 0)?;
1202    tz.from_local_datetime(&naive).single()
1203}
1204
1205/// Find the Nth weekday in a month. ordinal < 0 means "last" (-1), "second to last" (-2), etc.
1206fn find_nth_weekday_in_month(
1207    year: i32,
1208    month: u32,
1209    weekday: Weekday,
1210    ordinal: i32,
1211) -> Option<NaiveDate> {
1212    if ordinal > 0 {
1213        // Forward from the first of the month
1214        let first = NaiveDate::from_ymd_opt(year, month, 1)?;
1215        let first_wd = first.weekday();
1216        let diff = (weekday.num_days_from_monday() as i32 - first_wd.num_days_from_monday() as i32
1217            + 7)
1218            % 7;
1219        let first_occurrence = first + chrono::Duration::days(diff as i64);
1220        let target = first_occurrence + chrono::Duration::weeks((ordinal - 1) as i64);
1221        // Verify still in the same month
1222        if target.month() == month {
1223            Some(target)
1224        } else {
1225            None
1226        }
1227    } else {
1228        // Backward from the last of the month (ordinal = -1 means "last")
1229        let (ny, nm) = if month == 12 {
1230            (year + 1, 1)
1231        } else {
1232            (year, month + 1)
1233        };
1234        let first_next = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1235        let last = first_next.pred_opt()?;
1236        let last_wd = last.weekday();
1237        let diff =
1238            (last_wd.num_days_from_monday() as i32 - weekday.num_days_from_monday() as i32 + 7) % 7;
1239        let last_occurrence = last - chrono::Duration::days(diff as i64);
1240        let target = last_occurrence - chrono::Duration::weeks((-ordinal - 1) as i64);
1241        // Verify still in the same month
1242        if target.month() == month {
1243            Some(target)
1244        } else {
1245            None
1246        }
1247    }
1248}
1249
1250// ── Parsing helpers ─────────────────────────────────────────────────────────
1251
1252/// Parse a weekday name (case-insensitive, supports full and abbreviated).
1253fn parse_weekday(s: &str) -> Option<Weekday> {
1254    match s {
1255        "monday" | "mon" => Some(Weekday::Mon),
1256        "tuesday" | "tue" | "tues" => Some(Weekday::Tue),
1257        "wednesday" | "wed" => Some(Weekday::Wed),
1258        "thursday" | "thu" | "thurs" => Some(Weekday::Thu),
1259        "friday" | "fri" => Some(Weekday::Fri),
1260        "saturday" | "sat" => Some(Weekday::Sat),
1261        "sunday" | "sun" => Some(Weekday::Sun),
1262        _ => None,
1263    }
1264}
1265
1266/// Parse a month name to number (1-12).
1267fn parse_month(s: &str) -> Option<u32> {
1268    match s {
1269        "january" | "jan" => Some(1),
1270        "february" | "feb" => Some(2),
1271        "march" | "mar" => Some(3),
1272        "april" | "apr" => Some(4),
1273        "may" => Some(5),
1274        "june" | "jun" => Some(6),
1275        "july" | "jul" => Some(7),
1276        "august" | "aug" => Some(8),
1277        "september" | "sep" | "sept" => Some(9),
1278        "october" | "oct" => Some(10),
1279        "november" | "nov" => Some(11),
1280        "december" | "dec" => Some(12),
1281        _ => None,
1282    }
1283}
1284
1285/// Parse an ordinal: "first"→1, "second"→2, ..., "last"→-1.
1286fn parse_ordinal(s: &str) -> Option<i32> {
1287    match s {
1288        "first" | "1st" => Some(1),
1289        "second" | "2nd" => Some(2),
1290        "third" | "3rd" => Some(3),
1291        "fourth" | "4th" => Some(4),
1292        "fifth" | "5th" => Some(5),
1293        "last" => Some(-1),
1294        _ => None,
1295    }
1296}
1297
1298/// Map named time to NaiveTime.
1299fn named_time_to_naive(s: &str) -> Option<NaiveTime> {
1300    match s {
1301        "morning" | "start of business" | "sob" => NaiveTime::from_hms_opt(9, 0, 0),
1302        "noon" | "lunch" => NaiveTime::from_hms_opt(12, 0, 0),
1303        "afternoon" => NaiveTime::from_hms_opt(13, 0, 0),
1304        "end of day" | "end of business" | "eob" => NaiveTime::from_hms_opt(17, 0, 0),
1305        "evening" => NaiveTime::from_hms_opt(18, 0, 0),
1306        "night" => NaiveTime::from_hms_opt(21, 0, 0),
1307        "midnight" => NaiveTime::from_hms_opt(0, 0, 0),
1308        _ => None,
1309    }
1310}
1311
1312/// Parse a time string: "2pm", "2:30pm", "14:00", "14:30:00".
1313fn parse_time_string(s: &str) -> Option<NaiveTime> {
1314    let s = s.trim();
1315
1316    // 24-hour format: "14:00", "14:30", "14:30:00"
1317    if let Ok(t) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
1318        return Some(t);
1319    }
1320    if let Ok(t) = NaiveTime::parse_from_str(s, "%H:%M") {
1321        return Some(t);
1322    }
1323
1324    // 12-hour format: "2pm", "2:30pm", "2:30:00pm", "2 pm"
1325    let s_no_space = s.replace(' ', "");
1326    let (time_part, is_pm) = if s_no_space.ends_with("pm") {
1327        (s_no_space.strip_suffix("pm")?, true)
1328    } else if s_no_space.ends_with("am") {
1329        (s_no_space.strip_suffix("am")?, false)
1330    } else {
1331        return None;
1332    };
1333
1334    let parts: Vec<&str> = time_part.split(':').collect();
1335    let hour: u32 = parts.first()?.parse().ok()?;
1336    let minute: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1337    let second: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1338
1339    let hour24 = match (hour, is_pm) {
1340        (12, true) => 12,
1341        (12, false) => 0,
1342        (h, true) => h + 12,
1343        (h, false) => h,
1344    };
1345
1346    NaiveTime::from_hms_opt(hour24, minute, second)
1347}
1348
1349/// Parse "N unit(s)" from natural language (e.g., "2 hours", "30 minutes").
1350fn parse_natural_number_and_unit(s: &str) -> Option<(i64, String)> {
1351    let parts: Vec<&str> = s.split_whitespace().collect();
1352    if parts.len() < 2 {
1353        return None;
1354    }
1355    let n: i64 = parts[0].parse().ok()?;
1356    let unit = normalize_time_unit(parts[1])?;
1357    Some((n, unit))
1358}
1359
1360/// Parse "a/an unit from now" or "N unit(s) from now" prefix.
1361fn parse_natural_number_and_unit_with_article(s: &str) -> Option<(i64, String)> {
1362    let parts: Vec<&str> = s.split_whitespace().collect();
1363    if parts.is_empty() {
1364        return None;
1365    }
1366
1367    // "a week", "an hour"
1368    if parts[0] == "a" || parts[0] == "an" {
1369        if parts.len() < 2 {
1370            return None;
1371        }
1372        let unit = normalize_time_unit(parts[1])?;
1373        return Some((1, unit));
1374    }
1375
1376    // "2 hours", "30 minutes"
1377    parse_natural_number_and_unit(s)
1378}
1379
1380/// Normalize a time unit name to a standard form.
1381fn normalize_time_unit(s: &str) -> Option<String> {
1382    match s {
1383        "second" | "seconds" | "sec" | "secs" => Some("seconds".to_string()),
1384        "minute" | "minutes" | "min" | "mins" => Some("minutes".to_string()),
1385        "hour" | "hours" | "hr" | "hrs" => Some("hours".to_string()),
1386        "day" | "days" => Some("days".to_string()),
1387        "week" | "weeks" | "wk" | "wks" => Some("weeks".to_string()),
1388        _ => None,
1389    }
1390}
1391
1392/// Convert a number and unit to total seconds.
1393fn unit_to_seconds(n: i64, unit: &str) -> Option<i64> {
1394    let multiplier = match unit {
1395        "seconds" => 1,
1396        "minutes" => 60,
1397        "hours" => 3600,
1398        "days" => 86400,
1399        "weeks" => 604800,
1400        _ => return None,
1401    };
1402    Some(n * multiplier)
1403}
1404
1405/// Create a DateTime at the start of the day (00:00) in the given timezone.
1406fn make_local_start_of_day(local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
1407    let naive = local.date_naive().and_hms_opt(0, 0, 0)?;
1408    tz.from_local_datetime(&naive).single()
1409}
1410
1411/// Format a human-readable interpretation string.
1412fn format_interpretation<T: TimeZone>(dt: &DateTime<T>) -> String
1413where
1414    T::Offset: std::fmt::Display,
1415{
1416    dt.format("%A, %B %-d, %Y at %-I:%M %p %Z").to_string()
1417}
1418
1419// ── Tests ───────────────────────────────────────────────────────────────────
1420
1421#[cfg(test)]
1422mod tests {
1423    use super::*;
1424    use chrono::TimeZone;
1425
1426    // ── convert_timezone tests ──────────────────────────────────────────
1427
1428    #[test]
1429    fn test_convert_utc_to_eastern() {
1430        let result = convert_timezone("2026-03-15T14:00:00Z", "America/New_York").unwrap();
1431        assert_eq!(result.timezone, "America/New_York");
1432        // March 15 2026 is EDT (UTC-4), so 14:00 UTC = 10:00 local
1433        assert!(result.local.contains("10:00:00"));
1434        assert_eq!(result.utc, "2026-03-15T14:00:00+00:00");
1435    }
1436
1437    #[test]
1438    fn test_convert_eastern_to_pacific() {
1439        // Input is in UTC-5 (EST), convert to Pacific
1440        let result = convert_timezone("2026-01-15T14:00:00-05:00", "America/Los_Angeles").unwrap();
1441        assert_eq!(result.timezone, "America/Los_Angeles");
1442        // Jan 15 is PST (UTC-8). The input is 14:00 EST = 19:00 UTC = 11:00 PST
1443        assert!(result.local.contains("11:00:00"));
1444    }
1445
1446    #[test]
1447    fn test_convert_across_dst_spring_forward() {
1448        // March 8, 2026: US spring forward (2:00 AM → 3:00 AM)
1449        // Before DST: Jan 15, 2026 — EST (UTC-5)
1450        let winter = convert_timezone("2026-01-15T12:00:00Z", "America/New_York").unwrap();
1451        assert_eq!(winter.utc_offset, "-05:00");
1452        assert!(!winter.dst_active);
1453
1454        // After DST: March 15, 2026 — EDT (UTC-4)
1455        let summer = convert_timezone("2026-03-15T12:00:00Z", "America/New_York").unwrap();
1456        assert_eq!(summer.utc_offset, "-04:00");
1457        assert!(summer.dst_active);
1458    }
1459
1460    #[test]
1461    fn test_convert_across_dst_fall_back() {
1462        // November 1, 2026: US fall back (2:00 AM → 1:00 AM)
1463        // After fall back: Nov 2 — EST (UTC-5)
1464        let result = convert_timezone("2026-11-02T12:00:00Z", "America/New_York").unwrap();
1465        assert_eq!(result.utc_offset, "-05:00");
1466        assert!(!result.dst_active);
1467    }
1468
1469    #[test]
1470    fn test_convert_utc_offset_correct() {
1471        let result = convert_timezone("2026-06-15T12:00:00Z", "Asia/Tokyo").unwrap();
1472        assert_eq!(result.utc_offset, "+09:00");
1473        assert!(!result.dst_active); // Japan does not observe DST
1474    }
1475
1476    #[test]
1477    fn test_convert_dst_active_flag() {
1478        // Summer in New York — DST active
1479        let summer = convert_timezone("2026-07-15T12:00:00Z", "America/New_York").unwrap();
1480        assert!(summer.dst_active);
1481
1482        // Winter in New York — DST not active
1483        let winter = convert_timezone("2026-12-15T12:00:00Z", "America/New_York").unwrap();
1484        assert!(!winter.dst_active);
1485    }
1486
1487    #[test]
1488    fn test_convert_invalid_timezone_returns_error() {
1489        let result = convert_timezone("2026-03-15T14:00:00Z", "Invalid/Zone");
1490        assert!(result.is_err());
1491        let err = result.unwrap_err().to_string();
1492        assert!(err.contains("Invalid timezone"), "got: {err}");
1493    }
1494
1495    #[test]
1496    fn test_convert_invalid_datetime_returns_error() {
1497        let result = convert_timezone("not-a-datetime", "America/New_York");
1498        assert!(result.is_err());
1499        let err = result.unwrap_err().to_string();
1500        assert!(err.contains("Invalid datetime"), "got: {err}");
1501    }
1502
1503    // ── compute_duration tests ──────────────────────────────────────────
1504
1505    #[test]
1506    fn test_duration_same_day() {
1507        let result = compute_duration("2026-03-16T09:00:00Z", "2026-03-16T17:00:00Z").unwrap();
1508        assert_eq!(result.total_seconds, 28800); // 8 hours
1509        assert_eq!(result.hours, 8);
1510        assert_eq!(result.days, 0);
1511        assert_eq!(result.minutes, 0);
1512    }
1513
1514    #[test]
1515    fn test_duration_across_days() {
1516        let result = compute_duration(
1517            "2026-03-13T17:00:00Z", // Friday 5pm
1518            "2026-03-16T09:00:00Z", // Monday 9am
1519        )
1520        .unwrap();
1521        assert_eq!(result.total_seconds, 230400); // 2d + 16h = 2*86400 + 16*3600
1522        assert_eq!(result.days, 2);
1523        assert_eq!(result.hours, 16);
1524    }
1525
1526    #[test]
1527    fn test_duration_negative_direction() {
1528        let result = compute_duration("2026-03-16T17:00:00Z", "2026-03-16T09:00:00Z").unwrap();
1529        assert_eq!(result.total_seconds, -28800);
1530        // Decomposition is always positive
1531        assert_eq!(result.hours, 8);
1532    }
1533
1534    #[test]
1535    fn test_duration_exact_days() {
1536        let result = compute_duration("2026-03-16T00:00:00Z", "2026-03-19T00:00:00Z").unwrap();
1537        assert_eq!(result.days, 3);
1538        assert_eq!(result.hours, 0);
1539        assert_eq!(result.minutes, 0);
1540        assert_eq!(result.seconds, 0);
1541    }
1542
1543    #[test]
1544    fn test_duration_sub_minute() {
1545        let result = compute_duration("2026-03-16T10:00:00Z", "2026-03-16T10:00:45Z").unwrap();
1546        assert_eq!(result.total_seconds, 45);
1547        assert_eq!(result.seconds, 45);
1548        assert_eq!(result.minutes, 0);
1549    }
1550
1551    #[test]
1552    fn test_duration_human_readable_format() {
1553        let result = compute_duration("2026-03-16T00:00:00Z", "2026-03-18T03:15:00Z").unwrap();
1554        assert_eq!(result.human_readable, "2 days, 3 hours, 15 minutes");
1555    }
1556
1557    #[test]
1558    fn test_duration_invalid_input() {
1559        let result = compute_duration("not-a-datetime", "2026-03-16T10:00:00Z");
1560        assert!(result.is_err());
1561    }
1562
1563    // ── adjust_timestamp tests ──────────────────────────────────────────
1564
1565    #[test]
1566    fn test_adjust_add_hours() {
1567        let result = adjust_timestamp("2026-03-16T10:00:00Z", "+2h", "UTC").unwrap();
1568        assert!(result.adjusted_utc.contains("12:00:00"));
1569    }
1570
1571    #[test]
1572    fn test_adjust_subtract_days() {
1573        let result = adjust_timestamp("2026-03-05T10:00:00Z", "-3d", "UTC").unwrap();
1574        assert!(result.adjusted_utc.contains("2026-03-02"));
1575    }
1576
1577    #[test]
1578    fn test_adjust_add_minutes() {
1579        let result = adjust_timestamp("2026-03-16T10:00:00Z", "+90m", "UTC").unwrap();
1580        assert!(result.adjusted_utc.contains("11:30:00"));
1581    }
1582
1583    #[test]
1584    fn test_adjust_add_weeks() {
1585        let result = adjust_timestamp("2026-03-02T10:00:00Z", "+2w", "UTC").unwrap();
1586        assert!(result.adjusted_utc.contains("2026-03-16"));
1587    }
1588
1589    #[test]
1590    fn test_adjust_compound_duration() {
1591        let result = adjust_timestamp("2026-03-16T10:00:00Z", "+1d2h30m", "UTC").unwrap();
1592        // March 16 10:00 + 1d2h30m = March 17 12:30
1593        assert!(result.adjusted_utc.contains("2026-03-17"));
1594        assert!(result.adjusted_utc.contains("12:30:00"));
1595    }
1596
1597    #[test]
1598    fn test_adjust_day_across_dst() {
1599        // March 8 2026: US spring forward. +1d should preserve wall-clock time.
1600        let result = adjust_timestamp(
1601            "2026-03-07T22:00:00-05:00", // 10pm EST (= 03:00 UTC on March 8)
1602            "+1d",
1603            "America/New_York",
1604        )
1605        .unwrap();
1606        // March 8, 10pm EDT (now in EDT = -04:00)
1607        assert!(result.adjusted_local.contains("22:00:00"));
1608    }
1609
1610    #[test]
1611    fn test_adjust_negative_compound() {
1612        let result = adjust_timestamp("2026-03-16T10:00:00Z", "-1d12h", "UTC").unwrap();
1613        // March 16 10:00 - 1d12h = March 14 22:00
1614        assert!(result.adjusted_utc.contains("2026-03-14"));
1615        assert!(result.adjusted_utc.contains("22:00:00"));
1616    }
1617
1618    #[test]
1619    fn test_adjust_add_seconds() {
1620        let result = adjust_timestamp("2026-03-16T10:00:00Z", "+3600s", "UTC").unwrap();
1621        assert!(result.adjusted_utc.contains("11:00:00"));
1622    }
1623
1624    #[test]
1625    fn test_adjust_invalid_format() {
1626        let result = adjust_timestamp("2026-03-16T10:00:00Z", "2h", "UTC");
1627        assert!(result.is_err());
1628        let err = result.unwrap_err().to_string();
1629        assert!(err.contains("must start with '+' or '-'"), "got: {err}");
1630    }
1631
1632    #[test]
1633    fn test_adjust_zero_duration() {
1634        let result = adjust_timestamp("2026-03-16T10:00:00Z", "+0h", "UTC").unwrap();
1635        assert!(result.adjusted_utc.contains("10:00:00"));
1636    }
1637
1638    // ── resolve_relative tests ──────────────────────────────────────────
1639
1640    fn anchor() -> DateTime<Utc> {
1641        // Wednesday, February 18, 2026, 14:30:00 UTC
1642        Utc.with_ymd_and_hms(2026, 2, 18, 14, 30, 0).unwrap()
1643    }
1644
1645    #[test]
1646    fn test_resolve_now() {
1647        let result = resolve_relative(anchor(), "now", "UTC").unwrap();
1648        assert!(result.resolved_utc.contains("14:30:00"));
1649    }
1650
1651    #[test]
1652    fn test_resolve_today() {
1653        let result = resolve_relative(anchor(), "today", "UTC").unwrap();
1654        assert!(result.resolved_utc.contains("2026-02-18"));
1655        assert!(result.resolved_utc.contains("00:00:00"));
1656    }
1657
1658    #[test]
1659    fn test_resolve_tomorrow() {
1660        let result = resolve_relative(anchor(), "tomorrow", "UTC").unwrap();
1661        assert!(result.resolved_utc.contains("2026-02-19"));
1662        assert!(result.resolved_utc.contains("00:00:00"));
1663    }
1664
1665    #[test]
1666    fn test_resolve_yesterday() {
1667        let result = resolve_relative(anchor(), "yesterday", "UTC").unwrap();
1668        assert!(result.resolved_utc.contains("2026-02-17"));
1669    }
1670
1671    #[test]
1672    fn test_resolve_next_monday_from_wednesday() {
1673        // Anchor is Wednesday Feb 18 → next Monday is Feb 23
1674        let result = resolve_relative(anchor(), "next Monday", "UTC").unwrap();
1675        assert!(result.resolved_utc.contains("2026-02-23"));
1676    }
1677
1678    #[test]
1679    fn test_resolve_next_friday_from_friday() {
1680        // If anchor is Friday Feb 20 → next Friday should be Feb 27 (not same day)
1681        let fri_anchor = Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap();
1682        let result = resolve_relative(fri_anchor, "next Friday", "UTC").unwrap();
1683        assert!(result.resolved_utc.contains("2026-02-27"));
1684    }
1685
1686    #[test]
1687    fn test_resolve_this_wednesday_from_monday() {
1688        let mon_anchor = Utc.with_ymd_and_hms(2026, 2, 16, 10, 0, 0).unwrap();
1689        let result = resolve_relative(mon_anchor, "this Wednesday", "UTC").unwrap();
1690        assert!(result.resolved_utc.contains("2026-02-18"));
1691    }
1692
1693    #[test]
1694    fn test_resolve_last_tuesday_from_thursday() {
1695        let thu_anchor = Utc.with_ymd_and_hms(2026, 2, 19, 10, 0, 0).unwrap();
1696        let result = resolve_relative(thu_anchor, "last Tuesday", "UTC").unwrap();
1697        assert!(result.resolved_utc.contains("2026-02-17"));
1698    }
1699
1700    #[test]
1701    fn test_resolve_morning() {
1702        let result = resolve_relative(anchor(), "morning", "UTC").unwrap();
1703        assert!(result.resolved_utc.contains("09:00:00"));
1704    }
1705
1706    #[test]
1707    fn test_resolve_noon() {
1708        let result = resolve_relative(anchor(), "noon", "UTC").unwrap();
1709        assert!(result.resolved_utc.contains("12:00:00"));
1710    }
1711
1712    #[test]
1713    fn test_resolve_afternoon() {
1714        let result = resolve_relative(anchor(), "afternoon", "UTC").unwrap();
1715        assert!(result.resolved_utc.contains("13:00:00"));
1716    }
1717
1718    #[test]
1719    fn test_resolve_evening() {
1720        let result = resolve_relative(anchor(), "evening", "UTC").unwrap();
1721        assert!(result.resolved_utc.contains("18:00:00"));
1722    }
1723
1724    #[test]
1725    fn test_resolve_eob() {
1726        let result = resolve_relative(anchor(), "eob", "UTC").unwrap();
1727        assert!(result.resolved_utc.contains("17:00:00"));
1728    }
1729
1730    #[test]
1731    fn test_resolve_midnight() {
1732        let result = resolve_relative(anchor(), "midnight", "UTC").unwrap();
1733        assert!(result.resolved_utc.contains("00:00:00"));
1734    }
1735
1736    #[test]
1737    fn test_resolve_2pm() {
1738        let result = resolve_relative(anchor(), "2pm", "UTC").unwrap();
1739        assert!(result.resolved_utc.contains("14:00:00"));
1740    }
1741
1742    #[test]
1743    fn test_resolve_2_30pm() {
1744        let result = resolve_relative(anchor(), "2:30pm", "UTC").unwrap();
1745        assert!(result.resolved_utc.contains("14:30:00"));
1746    }
1747
1748    #[test]
1749    fn test_resolve_14_00() {
1750        let result = resolve_relative(anchor(), "14:00", "UTC").unwrap();
1751        assert!(result.resolved_utc.contains("14:00:00"));
1752    }
1753
1754    #[test]
1755    fn test_resolve_in_2_hours() {
1756        let result = resolve_relative(anchor(), "in 2 hours", "UTC").unwrap();
1757        assert!(result.resolved_utc.contains("16:30:00"));
1758    }
1759
1760    #[test]
1761    fn test_resolve_30_minutes_ago() {
1762        let result = resolve_relative(anchor(), "30 minutes ago", "UTC").unwrap();
1763        assert!(result.resolved_utc.contains("14:00:00"));
1764    }
1765
1766    #[test]
1767    fn test_resolve_in_3_days() {
1768        let result = resolve_relative(anchor(), "in 3 days", "UTC").unwrap();
1769        assert!(result.resolved_utc.contains("2026-02-21"));
1770    }
1771
1772    #[test]
1773    fn test_resolve_a_week_from_now() {
1774        let result = resolve_relative(anchor(), "a week from now", "UTC").unwrap();
1775        assert!(result.resolved_utc.contains("2026-02-25"));
1776    }
1777
1778    #[test]
1779    fn test_resolve_next_tuesday_at_2pm() {
1780        // Anchor is Wed Feb 18 → next Tuesday is Feb 24, at 2pm
1781        let result = resolve_relative(anchor(), "next Tuesday at 2pm", "UTC").unwrap();
1782        assert!(result.resolved_utc.contains("2026-02-24"));
1783        assert!(result.resolved_utc.contains("14:00:00"));
1784    }
1785
1786    #[test]
1787    fn test_resolve_tomorrow_at_10_30am() {
1788        let result = resolve_relative(anchor(), "tomorrow at 10:30am", "UTC").unwrap();
1789        assert!(result.resolved_utc.contains("2026-02-19"));
1790        assert!(result.resolved_utc.contains("10:30:00"));
1791    }
1792
1793    #[test]
1794    fn test_resolve_tomorrow_morning() {
1795        let result = resolve_relative(anchor(), "tomorrow morning", "UTC").unwrap();
1796        assert!(result.resolved_utc.contains("2026-02-19"));
1797        assert!(result.resolved_utc.contains("09:00:00"));
1798    }
1799
1800    #[test]
1801    fn test_resolve_next_friday_evening() {
1802        // Anchor is Wed Feb 18 → next Friday is Feb 20, evening = 18:00
1803        let result = resolve_relative(anchor(), "next Friday evening", "UTC").unwrap();
1804        assert!(result.resolved_utc.contains("2026-02-20"));
1805        assert!(result.resolved_utc.contains("18:00:00"));
1806    }
1807
1808    #[test]
1809    fn test_resolve_today_at_noon() {
1810        let result = resolve_relative(anchor(), "today at noon", "UTC").unwrap();
1811        assert!(result.resolved_utc.contains("2026-02-18"));
1812        assert!(result.resolved_utc.contains("12:00:00"));
1813    }
1814
1815    #[test]
1816    fn test_resolve_start_of_week() {
1817        // Anchor is Wed Feb 18 → start of ISO week is Mon Feb 16
1818        let result = resolve_relative(anchor(), "start of week", "UTC").unwrap();
1819        assert!(result.resolved_utc.contains("2026-02-16"));
1820        assert!(result.resolved_utc.contains("00:00:00"));
1821    }
1822
1823    #[test]
1824    fn test_resolve_end_of_month() {
1825        let result = resolve_relative(anchor(), "end of month", "UTC").unwrap();
1826        assert!(result.resolved_utc.contains("2026-02-28"));
1827        assert!(result.resolved_utc.contains("23:59:59"));
1828    }
1829
1830    #[test]
1831    fn test_resolve_start_of_quarter() {
1832        // Feb is Q1, so start of quarter is Jan 1
1833        let result = resolve_relative(anchor(), "start of quarter", "UTC").unwrap();
1834        assert!(result.resolved_utc.contains("2026-01-01"));
1835    }
1836
1837    #[test]
1838    fn test_resolve_next_week() {
1839        // Anchor is Wed Feb 18 → next Monday is Feb 23
1840        let result = resolve_relative(anchor(), "next week", "UTC").unwrap();
1841        assert!(result.resolved_utc.contains("2026-02-23"));
1842    }
1843
1844    #[test]
1845    fn test_resolve_next_month() {
1846        let result = resolve_relative(anchor(), "next month", "UTC").unwrap();
1847        assert!(result.resolved_utc.contains("2026-03-01"));
1848    }
1849
1850    #[test]
1851    fn test_resolve_first_monday_of_march() {
1852        let result = resolve_relative(anchor(), "first Monday of March", "UTC").unwrap();
1853        // March 2026: first Monday is March 2
1854        assert!(result.resolved_utc.contains("2026-03-02"));
1855    }
1856
1857    #[test]
1858    fn test_resolve_last_friday_of_month() {
1859        let result = resolve_relative(anchor(), "last Friday of the month", "UTC").unwrap();
1860        // February 2026: last Friday is Feb 27
1861        assert!(result.resolved_utc.contains("2026-02-27"));
1862    }
1863
1864    #[test]
1865    fn test_resolve_third_tuesday_of_march_2026() {
1866        let result = resolve_relative(anchor(), "third Tuesday of March 2026", "UTC").unwrap();
1867        // March 2026: 1st Tue=3, 2nd=10, 3rd=17
1868        assert!(result.resolved_utc.contains("2026-03-17"));
1869    }
1870
1871    #[test]
1872    fn test_resolve_passthrough_rfc3339() {
1873        let input = "2026-06-15T10:00:00-04:00";
1874        let result = resolve_relative(anchor(), input, "UTC").unwrap();
1875        // Should preserve the instant (convert to UTC)
1876        assert!(result.resolved_utc.contains("2026-06-15"));
1877        assert!(result.resolved_utc.contains("14:00:00"));
1878    }
1879
1880    #[test]
1881    fn test_resolve_passthrough_iso_date() {
1882        let result = resolve_relative(anchor(), "2026-03-15", "America/New_York").unwrap();
1883        // Should be start of day March 15 in Eastern time
1884        assert!(result.resolved_local.contains("2026-03-15"));
1885        assert!(result.resolved_local.contains("00:00:00"));
1886    }
1887
1888    #[test]
1889    fn test_resolve_case_insensitive() {
1890        let result = resolve_relative(anchor(), "Next TUESDAY at 2PM", "UTC").unwrap();
1891        assert!(result.resolved_utc.contains("2026-02-24"));
1892        assert!(result.resolved_utc.contains("14:00:00"));
1893    }
1894
1895    #[test]
1896    fn test_resolve_articles_ignored() {
1897        let result = resolve_relative(anchor(), "a week from now", "UTC").unwrap();
1898        assert!(result.resolved_utc.contains("2026-02-25"));
1899    }
1900
1901    #[test]
1902    fn test_resolve_unparseable_returns_error() {
1903        let result = resolve_relative(anchor(), "gobbledygook", "UTC");
1904        assert!(result.is_err());
1905        let err = result.unwrap_err().to_string();
1906        assert!(err.contains("cannot parse expression"), "got: {err}");
1907    }
1908
1909    #[test]
1910    fn test_resolve_interpretation_format() {
1911        let result = resolve_relative(anchor(), "next Tuesday at 2pm", "UTC").unwrap();
1912        // Should contain day of week and date
1913        assert!(result.interpretation.contains("Tuesday"));
1914        assert!(result.interpretation.contains("February 24"));
1915        assert!(result.interpretation.contains("2026"));
1916    }
1917
1918    // ── Compound period expression tests ────────────────────────────────
1919
1920    #[test]
1921    fn test_resolve_start_of_last_week() {
1922        // Anchor is Wed Feb 18 → last week started Mon Feb 9
1923        let result = resolve_relative(anchor(), "start of last week", "UTC").unwrap();
1924        assert!(result.resolved_utc.contains("2026-02-09"));
1925        assert!(result.resolved_utc.contains("00:00:00"));
1926    }
1927
1928    #[test]
1929    fn test_resolve_end_of_last_week() {
1930        // Anchor is Wed Feb 18 → last week ended Sun Feb 15
1931        let result = resolve_relative(anchor(), "end of last week", "UTC").unwrap();
1932        assert!(result.resolved_utc.contains("2026-02-15"));
1933        assert!(result.resolved_utc.contains("23:59:59"));
1934    }
1935
1936    #[test]
1937    fn test_resolve_start_of_next_week() {
1938        // Anchor is Wed Feb 18 → next week starts Mon Feb 23
1939        let result = resolve_relative(anchor(), "start of next week", "UTC").unwrap();
1940        assert!(result.resolved_utc.contains("2026-02-23"));
1941        assert!(result.resolved_utc.contains("00:00:00"));
1942    }
1943
1944    #[test]
1945    fn test_resolve_end_of_next_week() {
1946        // Anchor is Wed Feb 18 → next week ends Sun Mar 1
1947        let result = resolve_relative(anchor(), "end of next week", "UTC").unwrap();
1948        assert!(result.resolved_utc.contains("2026-03-01"));
1949        assert!(result.resolved_utc.contains("23:59:59"));
1950    }
1951
1952    #[test]
1953    fn test_resolve_start_of_last_month() {
1954        let result = resolve_relative(anchor(), "start of last month", "UTC").unwrap();
1955        assert!(result.resolved_utc.contains("2026-01-01"));
1956        assert!(result.resolved_utc.contains("00:00:00"));
1957    }
1958
1959    #[test]
1960    fn test_resolve_end_of_last_month() {
1961        // Jan has 31 days
1962        let result = resolve_relative(anchor(), "end of last month", "UTC").unwrap();
1963        assert!(result.resolved_utc.contains("2026-01-31"));
1964        assert!(result.resolved_utc.contains("23:59:59"));
1965    }
1966
1967    #[test]
1968    fn test_resolve_start_of_next_month() {
1969        let result = resolve_relative(anchor(), "start of next month", "UTC").unwrap();
1970        assert!(result.resolved_utc.contains("2026-03-01"));
1971        assert!(result.resolved_utc.contains("00:00:00"));
1972    }
1973
1974    #[test]
1975    fn test_resolve_end_of_next_month() {
1976        // March has 31 days
1977        let result = resolve_relative(anchor(), "end of next month", "UTC").unwrap();
1978        assert!(result.resolved_utc.contains("2026-03-31"));
1979        assert!(result.resolved_utc.contains("23:59:59"));
1980    }
1981
1982    #[test]
1983    fn test_resolve_start_of_next_year() {
1984        let result = resolve_relative(anchor(), "start of next year", "UTC").unwrap();
1985        assert!(result.resolved_utc.contains("2027-01-01"));
1986        assert!(result.resolved_utc.contains("00:00:00"));
1987    }
1988
1989    #[test]
1990    fn test_resolve_end_of_last_quarter() {
1991        // Anchor is Feb 2026 (Q1) → last quarter is Q4 2025 → ends Dec 31, 2025
1992        let result = resolve_relative(anchor(), "end of last quarter", "UTC").unwrap();
1993        assert!(result.resolved_utc.contains("2025-12-31"));
1994        assert!(result.resolved_utc.contains("23:59:59"));
1995    }
1996
1997    // ── Sunday week start tests ─────────────────────────────────────────
1998
1999    #[test]
2000    fn test_resolve_start_of_week_sunday() {
2001        // Anchor is Wed Feb 18 → with Sunday start, week started Sun Feb 15
2002        let options = ResolveOptions {
2003            week_start: WeekStartDay::Sunday,
2004        };
2005        let result =
2006            resolve_relative_with_options(anchor(), "start of week", "UTC", &options).unwrap();
2007        assert!(result.resolved_utc.contains("2026-02-15"));
2008        assert!(result.resolved_utc.contains("00:00:00"));
2009    }
2010
2011    #[test]
2012    fn test_resolve_end_of_week_sunday() {
2013        // Anchor is Wed Feb 18 → with Sunday start, week ends Sat Feb 21
2014        let options = ResolveOptions {
2015            week_start: WeekStartDay::Sunday,
2016        };
2017        let result =
2018            resolve_relative_with_options(anchor(), "end of week", "UTC", &options).unwrap();
2019        assert!(result.resolved_utc.contains("2026-02-21"));
2020        assert!(result.resolved_utc.contains("23:59:59"));
2021    }
2022
2023    #[test]
2024    fn test_resolve_start_of_last_week_sunday() {
2025        // Anchor is Wed Feb 18 → with Sunday start, last week started Sun Feb 8
2026        let options = ResolveOptions {
2027            week_start: WeekStartDay::Sunday,
2028        };
2029        let result =
2030            resolve_relative_with_options(anchor(), "start of last week", "UTC", &options).unwrap();
2031        assert!(result.resolved_utc.contains("2026-02-08"));
2032        assert!(result.resolved_utc.contains("00:00:00"));
2033    }
2034
2035    #[test]
2036    fn test_resolve_next_week_sunday() {
2037        // Anchor is Wed Feb 18 → with Sunday start, next week starts Sun Feb 22
2038        let options = ResolveOptions {
2039            week_start: WeekStartDay::Sunday,
2040        };
2041        let result = resolve_relative_with_options(anchor(), "next week", "UTC", &options).unwrap();
2042        assert!(result.resolved_utc.contains("2026-02-22"));
2043        assert!(result.resolved_utc.contains("00:00:00"));
2044    }
2045}