1use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, Offset, TimeZone, Utc, Weekday};
29use chrono_tz::Tz;
30use serde::Serialize;
31
32use crate::error::TruthError;
33
34#[derive(Debug, Clone, Serialize)]
38pub struct ConvertedDatetime {
39 pub utc: String,
41 pub local: String,
43 pub timezone: String,
45 pub utc_offset: String,
47 pub dst_active: bool,
49}
50
51pub 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 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#[derive(Debug, Clone, Serialize)]
106pub struct DurationInfo {
107 pub total_seconds: i64,
109 pub days: i64,
111 pub hours: i64,
113 pub minutes: i64,
115 pub seconds: i64,
117 pub human_readable: String,
119}
120
121pub 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#[derive(Debug, Clone, Serialize)]
167pub struct AdjustedTimestamp {
168 pub original: String,
170 pub adjusted_utc: String,
172 pub adjusted_local: String,
174 pub adjustment_applied: String,
176}
177
178#[derive(Debug, Clone, Default)]
180struct ParsedDuration {
181 sign: i64, weeks: i64,
183 days: i64,
184 hours: i64,
185 minutes: i64,
186 seconds: i64,
187}
188
189pub 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 let local = dt.with_timezone(&tz);
225
226 let adjusted_local = if parsed.weeks != 0 || parsed.days != 0 {
227 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 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 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#[derive(Debug, Clone, Serialize)]
267pub struct ResolvedDatetime {
268 pub resolved_utc: String,
270 pub resolved_local: String,
272 pub timezone: String,
274 pub interpretation: String,
276}
277
278pub 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 let normalized = normalize_expression(expression);
327
328 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
361fn 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
370fn parse_timezone(s: &str) -> Result<Tz, TruthError> {
372 s.parse::<Tz>()
373 .map_err(|_| TruthError::InvalidTimezone(format!("'{}'", s)))
374}
375
376fn is_dst_active<T: TimeZone>(dt: &DateTime<T>, tz: &Tz) -> bool {
378 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
395fn 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
405fn 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
435fn 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 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
512fn 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 parts.push_str("0s");
534 }
535 parts
536}
537
538fn normalize_expression(s: &str) -> String {
543 let s = s.trim().to_lowercase();
544 let s = s
546 .replace(" the ", " ")
547 .replace(" a ", " ")
548 .replace(" an ", " ");
549 let s = s.strip_prefix("the ").unwrap_or(&s).to_string();
551 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
568fn 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
575fn 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
585fn 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
604fn 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 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 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 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
645fn try_combined_weekday_time(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
647 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 let weekday_str = parts[1];
661 let _weekday = parse_weekday(weekday_str)?;
662
663 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 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 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
689fn 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 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 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
724fn 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
731fn 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
738fn try_natural_offset(s: &str, anchor: &DateTime<Utc>) -> Option<DateTime<Tz>> {
740 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 let utc_tz: Tz = "UTC".parse().ok()?;
747 return Some(result.with_timezone(&utc_tz));
748 }
749
750 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 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
773fn 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
790fn 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
858fn 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
908fn try_ordinal_date(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
911 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 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 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
978fn 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 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 if target.month() == month {
996 Some(target)
997 } else {
998 None
999 }
1000 } else {
1001 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 if target.month() == month {
1016 Some(target)
1017 } else {
1018 None
1019 }
1020 }
1021}
1022
1023fn 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
1039fn 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
1058fn 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
1071fn 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
1085fn parse_time_string(s: &str) -> Option<NaiveTime> {
1087 let s = s.trim();
1088
1089 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 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
1122fn 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
1133fn 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 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 parse_natural_number_and_unit(s)
1151}
1152
1153fn 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
1165fn 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
1178fn 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
1184fn 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#[cfg(test)]
1195mod tests {
1196 use super::*;
1197 use chrono::TimeZone;
1198
1199 #[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 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 let result = convert_timezone("2026-01-15T14:00:00-05:00", "America/Los_Angeles").unwrap();
1214 assert_eq!(result.timezone, "America/Los_Angeles");
1215 assert!(result.local.contains("11:00:00"));
1217 }
1218
1219 #[test]
1220 fn test_convert_across_dst_spring_forward() {
1221 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 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 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); }
1248
1249 #[test]
1250 fn test_convert_dst_active_flag() {
1251 let summer = convert_timezone("2026-07-15T12:00:00Z", "America/New_York").unwrap();
1253 assert!(summer.dst_active);
1254
1255 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 #[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); 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", "2026-03-16T09:00:00Z", )
1293 .unwrap();
1294 assert_eq!(result.total_seconds, 230400); 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 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 #[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 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 let result = adjust_timestamp(
1374 "2026-03-07T22:00:00-05:00", "+1d",
1376 "America/New_York",
1377 )
1378 .unwrap();
1379 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 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 fn anchor() -> DateTime<Utc> {
1414 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(result.interpretation.contains("Tuesday"));
1687 assert!(result.interpretation.contains("February 24"));
1688 assert!(result.interpretation.contains("2026"));
1689 }
1690}