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