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, Copy, PartialEq, Eq, Default, Serialize)]
40pub enum WeekStartDay {
41 #[default]
43 Monday,
44 Sunday,
46}
47
48#[derive(Debug, Clone, Default)]
50pub struct ResolveOptions {
51 pub week_start: WeekStartDay,
53}
54
55fn days_from_week_start(weekday: Weekday, week_start: WeekStartDay) -> i64 {
57 match week_start {
58 WeekStartDay::Monday => weekday.num_days_from_monday() as i64,
59 WeekStartDay::Sunday => weekday.num_days_from_sunday() as i64,
60 }
61}
62
63#[derive(Debug, Clone, Serialize)]
67pub struct ConvertedDatetime {
68 pub utc: String,
70 pub local: String,
72 pub timezone: String,
74 pub utc_offset: String,
76 pub dst_active: bool,
78}
79
80pub fn convert_timezone(
108 datetime: &str,
109 target_timezone: &str,
110) -> Result<ConvertedDatetime, TruthError> {
111 let dt = parse_rfc3339(datetime)?;
112 let tz = parse_timezone(target_timezone)?;
113
114 let local = dt.with_timezone(&tz);
115
116 let dst_active = is_dst_active(&local, &tz);
119
120 let utc_offset = format_utc_offset(&local);
121
122 Ok(ConvertedDatetime {
123 utc: dt.to_rfc3339(),
124 local: local.to_rfc3339(),
125 timezone: target_timezone.to_string(),
126 utc_offset,
127 dst_active,
128 })
129}
130
131#[derive(Debug, Clone, Serialize)]
135pub struct DurationInfo {
136 pub total_seconds: i64,
138 pub days: i64,
140 pub hours: i64,
142 pub minutes: i64,
144 pub seconds: i64,
146 pub human_readable: String,
148}
149
150pub fn compute_duration(start: &str, end: &str) -> Result<DurationInfo, TruthError> {
167 let start_dt = parse_rfc3339(start)?;
168 let end_dt = parse_rfc3339(end)?;
169
170 let total_seconds = (end_dt - start_dt).num_seconds();
171 let abs_seconds = total_seconds.unsigned_abs();
172
173 let days = (abs_seconds / 86400) as i64;
174 let remainder = abs_seconds % 86400;
175 let hours = (remainder / 3600) as i64;
176 let remainder = remainder % 3600;
177 let minutes = (remainder / 60) as i64;
178 let seconds = (remainder % 60) as i64;
179
180 let human_readable = format_human_duration(days, hours, minutes, seconds);
181
182 Ok(DurationInfo {
183 total_seconds,
184 days,
185 hours,
186 minutes,
187 seconds,
188 human_readable,
189 })
190}
191
192#[derive(Debug, Clone, Serialize)]
196pub struct AdjustedTimestamp {
197 pub original: String,
199 pub adjusted_utc: String,
201 pub adjusted_local: String,
203 pub adjustment_applied: String,
205}
206
207#[derive(Debug, Clone, Default)]
209struct ParsedDuration {
210 sign: i64, weeks: i64,
212 days: i64,
213 hours: i64,
214 minutes: i64,
215 seconds: i64,
216}
217
218pub fn adjust_timestamp(
243 datetime: &str,
244 adjustment: &str,
245 timezone: &str,
246) -> Result<AdjustedTimestamp, TruthError> {
247 let dt = parse_rfc3339(datetime)?;
248 let tz = parse_timezone(timezone)?;
249 let parsed = parse_duration_string(adjustment)?;
250
251 let local = dt.with_timezone(&tz);
254
255 let adjusted_local = if parsed.weeks != 0 || parsed.days != 0 {
256 let total_days = parsed.sign * (parsed.weeks * 7 + parsed.days);
258 let new_date = local.date_naive() + chrono::Duration::days(total_days);
259 let new_local_naive = new_date.and_time(local.time());
260
261 let adjusted_local_dt = tz
262 .from_local_datetime(&new_local_naive)
263 .single()
264 .ok_or_else(|| {
265 TruthError::InvalidDatetime(
266 "ambiguous or nonexistent local time after day adjustment".to_string(),
267 )
268 })?;
269
270 let sub_day_seconds =
272 parsed.sign * (parsed.hours * 3600 + parsed.minutes * 60 + parsed.seconds);
273 adjusted_local_dt + chrono::Duration::seconds(sub_day_seconds)
274 } else {
275 let total_seconds =
277 parsed.sign * (parsed.hours * 3600 + parsed.minutes * 60 + parsed.seconds);
278 local + chrono::Duration::seconds(total_seconds)
279 };
280
281 let adjusted_utc = adjusted_local.with_timezone(&Utc);
282 let normalized = normalize_duration_string(&parsed);
283
284 Ok(AdjustedTimestamp {
285 original: datetime.to_string(),
286 adjusted_utc: adjusted_utc.to_rfc3339(),
287 adjusted_local: adjusted_local.to_rfc3339(),
288 adjustment_applied: normalized,
289 })
290}
291
292#[derive(Debug, Clone, Serialize)]
296pub struct ResolvedDatetime {
297 pub resolved_utc: String,
299 pub resolved_local: String,
301 pub timezone: String,
303 pub interpretation: String,
305}
306
307pub fn resolve_relative(
323 anchor: DateTime<Utc>,
324 expression: &str,
325 timezone: &str,
326) -> Result<ResolvedDatetime, TruthError> {
327 resolve_relative_with_options(anchor, expression, timezone, &ResolveOptions::default())
328}
329
330pub fn resolve_relative_with_options(
374 anchor: DateTime<Utc>,
375 expression: &str,
376 timezone: &str,
377 options: &ResolveOptions,
378) -> Result<ResolvedDatetime, TruthError> {
379 let tz = parse_timezone(timezone)?;
380 let local_anchor = anchor.with_timezone(&tz);
381 let ws = options.week_start;
382
383 let normalized = normalize_expression(expression);
385
386 let resolved_local = try_passthrough_rfc3339(&normalized)
388 .map(|dt| dt.with_timezone(&tz))
389 .or_else(|| try_passthrough_iso_date(&normalized, &tz))
390 .or_else(|| try_anchored(&normalized, &local_anchor, &tz))
391 .or_else(|| try_combined_weekday_time(&normalized, &local_anchor, &tz))
392 .or_else(|| try_combined_anchor_time(&normalized, &local_anchor, &tz))
393 .or_else(|| try_weekday_relative(&normalized, &local_anchor, &tz))
394 .or_else(|| try_compound_period(&normalized, &local_anchor, &tz, ws))
395 .or_else(|| try_period_boundary(&normalized, &local_anchor, &tz, ws))
396 .or_else(|| try_period_relative(&normalized, &local_anchor, &tz, ws))
397 .or_else(|| try_ordinal_date(&normalized, &local_anchor, &tz))
398 .or_else(|| try_natural_offset(&normalized, &anchor))
399 .or_else(|| try_duration_offset(&normalized, &anchor))
400 .or_else(|| try_time_of_day_named(&normalized, &local_anchor, &tz))
401 .or_else(|| try_explicit_time(&normalized, &local_anchor, &tz))
402 .ok_or_else(|| {
403 TruthError::InvalidExpression(format!(
404 "cannot parse expression: '{}'",
405 expression.trim()
406 ))
407 })?;
408
409 let resolved_utc = resolved_local.with_timezone(&Utc);
410 let interpretation = format_interpretation(&resolved_local);
411
412 Ok(ResolvedDatetime {
413 resolved_utc: resolved_utc.to_rfc3339(),
414 resolved_local: resolved_local.to_rfc3339(),
415 timezone: timezone.to_string(),
416 interpretation,
417 })
418}
419
420fn parse_rfc3339(s: &str) -> Result<DateTime<Utc>, TruthError> {
424 DateTime::parse_from_rfc3339(s)
425 .map(|dt| dt.with_timezone(&Utc))
426 .map_err(|e| TruthError::InvalidDatetime(format!("'{}': {}", s, e)))
427}
428
429fn parse_timezone(s: &str) -> Result<Tz, TruthError> {
431 s.parse::<Tz>()
432 .map_err(|_| TruthError::InvalidTimezone(format!("'{}'", s)))
433}
434
435fn is_dst_active<T: TimeZone>(dt: &DateTime<T>, tz: &Tz) -> bool {
437 let utc = dt.with_timezone(&Utc);
440 let year = utc.year();
441
442 let jan1 = Utc
443 .with_ymd_and_hms(year, 1, 1, 12, 0, 0)
444 .single()
445 .unwrap_or(utc);
446 let jan1_local = jan1.with_timezone(tz);
447
448 let current_offset = dt.offset().fix().local_minus_utc();
449 let jan_offset = jan1_local.offset().fix().local_minus_utc();
450
451 current_offset != jan_offset
452}
453
454fn format_utc_offset<T: TimeZone>(dt: &DateTime<T>) -> String {
456 let offset_secs = dt.offset().fix().local_minus_utc();
457 let sign = if offset_secs >= 0 { "+" } else { "-" };
458 let abs_secs = offset_secs.unsigned_abs();
459 let hours = abs_secs / 3600;
460 let minutes = (abs_secs % 3600) / 60;
461 format!("{sign}{hours:02}:{minutes:02}")
462}
463
464fn format_human_duration(days: i64, hours: i64, minutes: i64, seconds: i64) -> String {
466 let mut parts = Vec::new();
467 if days > 0 {
468 parts.push(format!("{} day{}", days, if days == 1 { "" } else { "s" }));
469 }
470 if hours > 0 {
471 parts.push(format!(
472 "{} hour{}",
473 hours,
474 if hours == 1 { "" } else { "s" }
475 ));
476 }
477 if minutes > 0 {
478 parts.push(format!(
479 "{} minute{}",
480 minutes,
481 if minutes == 1 { "" } else { "s" }
482 ));
483 }
484 if seconds > 0 || parts.is_empty() {
485 parts.push(format!(
486 "{} second{}",
487 seconds,
488 if seconds == 1 { "" } else { "s" }
489 ));
490 }
491 parts.join(", ")
492}
493
494fn parse_duration_string(s: &str) -> Result<ParsedDuration, TruthError> {
496 let s = s.trim();
497 if s.is_empty() {
498 return Err(TruthError::InvalidDuration("empty duration".to_string()));
499 }
500
501 let (sign, rest) = match s.as_bytes().first() {
502 Some(b'+') => (1i64, &s[1..]),
503 Some(b'-') => (-1i64, &s[1..]),
504 _ => {
505 return Err(TruthError::InvalidDuration(format!(
506 "duration must start with '+' or '-': '{s}'"
507 )));
508 }
509 };
510
511 if rest.is_empty() {
512 return Err(TruthError::InvalidDuration(format!(
513 "duration has no components: '{s}'"
514 )));
515 }
516
517 let mut parsed = ParsedDuration {
518 sign,
519 ..Default::default()
520 };
521
522 let mut num_buf = String::new();
523 let mut found_any = false;
524
525 for ch in rest.chars() {
526 if ch.is_ascii_digit() {
527 num_buf.push(ch);
528 } else {
529 if num_buf.is_empty() {
530 return Err(TruthError::InvalidDuration(format!(
531 "expected number before '{ch}' in '{s}'"
532 )));
533 }
534 let n: i64 = num_buf
535 .parse()
536 .map_err(|_| TruthError::InvalidDuration(format!("invalid number in '{s}'")))?;
537 num_buf.clear();
538 found_any = true;
539
540 match ch {
541 'w' | 'W' => parsed.weeks += n,
542 'd' | 'D' => parsed.days += n,
543 'h' | 'H' => parsed.hours += n,
544 'm' | 'M' => parsed.minutes += n,
545 's' | 'S' => parsed.seconds += n,
546 _ => {
547 return Err(TruthError::InvalidDuration(format!(
548 "unknown unit '{ch}' in '{s}'"
549 )));
550 }
551 }
552 }
553 }
554
555 if !num_buf.is_empty() {
557 return Err(TruthError::InvalidDuration(format!(
558 "number without unit at end of '{s}'"
559 )));
560 }
561
562 if !found_any {
563 return Err(TruthError::InvalidDuration(format!(
564 "no valid components in '{s}'"
565 )));
566 }
567
568 Ok(parsed)
569}
570
571fn normalize_duration_string(d: &ParsedDuration) -> String {
573 let sign = if d.sign >= 0 { "+" } else { "-" };
574 let mut parts = String::from(sign);
575 if d.weeks != 0 {
576 parts.push_str(&format!("{}w", d.weeks));
577 }
578 if d.days != 0 {
579 parts.push_str(&format!("{}d", d.days));
580 }
581 if d.hours != 0 {
582 parts.push_str(&format!("{}h", d.hours));
583 }
584 if d.minutes != 0 {
585 parts.push_str(&format!("{}m", d.minutes));
586 }
587 if d.seconds != 0 {
588 parts.push_str(&format!("{}s", d.seconds));
589 }
590 if parts.len() == 1 {
591 parts.push_str("0s");
593 }
594 parts
595}
596
597fn normalize_expression(s: &str) -> String {
602 let s = s.trim().to_lowercase();
603 let s = s
605 .replace(" the ", " ")
606 .replace(" a ", " ")
607 .replace(" an ", " ");
608 let s = s.strip_prefix("the ").unwrap_or(&s).to_string();
610 let mut result = String::new();
612 let mut prev_space = false;
613 for ch in s.chars() {
614 if ch == ' ' {
615 if !prev_space {
616 result.push(' ');
617 }
618 prev_space = true;
619 } else {
620 result.push(ch);
621 prev_space = false;
622 }
623 }
624 result.trim().to_string()
625}
626
627fn try_passthrough_rfc3339(s: &str) -> Option<DateTime<Utc>> {
629 DateTime::parse_from_rfc3339(s)
630 .map(|dt| dt.with_timezone(&Utc))
631 .ok()
632}
633
634fn try_passthrough_iso_date(s: &str, tz: &Tz) -> Option<DateTime<Tz>> {
636 NaiveDate::parse_from_str(s, "%Y-%m-%d")
637 .ok()
638 .and_then(|date| {
639 let naive = date.and_hms_opt(0, 0, 0)?;
640 tz.from_local_datetime(&naive).single()
641 })
642}
643
644fn try_anchored(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
646 match s {
647 "now" => Some(*local),
648 "today" => make_local_start_of_day(local, tz),
649 "tomorrow" => {
650 let next = local.date_naive().succ_opt()?;
651 let naive = next.and_hms_opt(0, 0, 0)?;
652 tz.from_local_datetime(&naive).single()
653 }
654 "yesterday" => {
655 let prev = local.date_naive().pred_opt()?;
656 let naive = prev.and_hms_opt(0, 0, 0)?;
657 tz.from_local_datetime(&naive).single()
658 }
659 _ => None,
660 }
661}
662
663fn try_weekday_relative(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
665 let parts: Vec<&str> = s.splitn(2, ' ').collect();
666 if parts.len() != 2 {
667 return None;
668 }
669
670 let modifier = parts[0];
671 let weekday = parse_weekday(parts[1])?;
672 let current = local.weekday();
673
674 let target_date = match modifier {
675 "next" => {
676 let days_ahead =
678 (weekday.num_days_from_monday() as i64 - current.num_days_from_monday() as i64 + 7)
679 % 7;
680 let days_ahead = if days_ahead == 0 { 7 } else { days_ahead };
681 local.date_naive() + chrono::Duration::days(days_ahead)
682 }
683 "this" => {
684 let diff =
686 weekday.num_days_from_monday() as i64 - current.num_days_from_monday() as i64;
687 local.date_naive() + chrono::Duration::days(diff)
688 }
689 "last" => {
690 let days_back =
692 (current.num_days_from_monday() as i64 - weekday.num_days_from_monday() as i64 + 7)
693 % 7;
694 let days_back = if days_back == 0 { 7 } else { days_back };
695 local.date_naive() - chrono::Duration::days(days_back)
696 }
697 _ => return None,
698 };
699
700 let naive = target_date.and_hms_opt(0, 0, 0)?;
701 tz.from_local_datetime(&naive).single()
702}
703
704fn try_combined_weekday_time(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
706 let parts: Vec<&str> = s.splitn(3, ' ').collect();
709 if parts.len() < 2 {
710 return None;
711 }
712
713 let modifier = parts[0];
714 if !matches!(modifier, "next" | "this" | "last") {
715 return None;
716 }
717
718 let weekday_str = parts[1];
720 let _weekday = parse_weekday(weekday_str)?;
721
722 let weekday_expr = format!("{} {}", modifier, weekday_str);
724 let base = try_weekday_relative(&weekday_expr, local, tz)?;
725
726 if parts.len() == 2 {
727 return Some(base);
728 }
729
730 let time_part = parts[2];
731
732 if let Some(at_time) = time_part.strip_prefix("at ") {
734 let time = parse_time_string(at_time)?;
735 let naive = base.date_naive().and_time(time);
736 return tz.from_local_datetime(&naive).single();
737 }
738
739 if let Some(time) = named_time_to_naive(time_part) {
741 let naive = base.date_naive().and_time(time);
742 return tz.from_local_datetime(&naive).single();
743 }
744
745 None
746}
747
748fn try_combined_anchor_time(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
750 let parts: Vec<&str> = s.splitn(2, ' ').collect();
751 if parts.len() != 2 {
752 return None;
753 }
754
755 let anchor_str = parts[0];
756 if !matches!(anchor_str, "today" | "tomorrow" | "yesterday") {
757 return None;
758 }
759
760 let base = try_anchored(anchor_str, local, tz)?;
761 let time_part = parts[1];
762
763 if let Some(at_time) = time_part.strip_prefix("at ") {
765 if let Some(time) = named_time_to_naive(at_time) {
766 let naive = base.date_naive().and_time(time);
767 return tz.from_local_datetime(&naive).single();
768 }
769 let time = parse_time_string(at_time)?;
770 let naive = base.date_naive().and_time(time);
771 return tz.from_local_datetime(&naive).single();
772 }
773
774 if let Some(time) = named_time_to_naive(time_part) {
776 let naive = base.date_naive().and_time(time);
777 return tz.from_local_datetime(&naive).single();
778 }
779
780 None
781}
782
783fn try_time_of_day_named(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
785 let time = named_time_to_naive(s)?;
786 let naive = local.date_naive().and_time(time);
787 tz.from_local_datetime(&naive).single()
788}
789
790fn try_explicit_time(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
792 let time = parse_time_string(s)?;
793 let naive = local.date_naive().and_time(time);
794 tz.from_local_datetime(&naive).single()
795}
796
797fn try_natural_offset(s: &str, anchor: &DateTime<Utc>) -> Option<DateTime<Tz>> {
799 if let Some(rest) = s.strip_prefix("in ") {
801 let (n, unit) = parse_natural_number_and_unit(rest)?;
802 let seconds = unit_to_seconds(n, &unit)?;
803 let result = *anchor + chrono::Duration::seconds(seconds);
804 let utc_tz: Tz = "UTC".parse().ok()?;
806 return Some(result.with_timezone(&utc_tz));
807 }
808
809 if s.ends_with(" ago") {
811 let rest = s.strip_suffix(" ago")?;
812 let (n, unit) = parse_natural_number_and_unit(rest)?;
813 let seconds = unit_to_seconds(n, &unit)?;
814 let result = *anchor - chrono::Duration::seconds(seconds);
815 let utc_tz: Tz = "UTC".parse().ok()?;
816 return Some(result.with_timezone(&utc_tz));
817 }
818
819 if s.ends_with(" from now") {
821 let rest = s.strip_suffix(" from now")?;
822 let (n, unit) = parse_natural_number_and_unit_with_article(rest)?;
823 let seconds = unit_to_seconds(n, &unit)?;
824 let result = *anchor + chrono::Duration::seconds(seconds);
825 let utc_tz: Tz = "UTC".parse().ok()?;
826 return Some(result.with_timezone(&utc_tz));
827 }
828
829 None
830}
831
832fn try_duration_offset(s: &str, anchor: &DateTime<Utc>) -> Option<DateTime<Tz>> {
834 if !s.starts_with('+') && !s.starts_with('-') {
835 return None;
836 }
837 let parsed = parse_duration_string(s).ok()?;
838 let total_seconds = parsed.sign
839 * (parsed.weeks * 7 * 86400
840 + parsed.days * 86400
841 + parsed.hours * 3600
842 + parsed.minutes * 60
843 + parsed.seconds);
844 let result = *anchor + chrono::Duration::seconds(total_seconds);
845 let utc_tz: Tz = "UTC".parse().ok()?;
846 Some(result.with_timezone(&utc_tz))
847}
848
849fn try_period_boundary(
851 s: &str,
852 local: &DateTime<Tz>,
853 tz: &Tz,
854 ws: WeekStartDay,
855) -> Option<DateTime<Tz>> {
856 match s {
857 "start of today" => make_local_start_of_day(local, tz),
858 "end of today" => {
859 let naive = local.date_naive().and_hms_opt(23, 59, 59)?;
860 tz.from_local_datetime(&naive).single()
861 }
862 "start of week" => {
863 let days_since_start = days_from_week_start(local.weekday(), ws);
864 let start = local.date_naive() - chrono::Duration::days(days_since_start);
865 let naive = start.and_hms_opt(0, 0, 0)?;
866 tz.from_local_datetime(&naive).single()
867 }
868 "end of week" => {
869 let days_until_end = 6 - days_from_week_start(local.weekday(), ws);
870 let end = local.date_naive() + chrono::Duration::days(days_until_end);
871 let naive = end.and_hms_opt(23, 59, 59)?;
872 tz.from_local_datetime(&naive).single()
873 }
874 "start of month" => {
875 let date = NaiveDate::from_ymd_opt(local.year(), local.month(), 1)?;
876 let naive = date.and_hms_opt(0, 0, 0)?;
877 tz.from_local_datetime(&naive).single()
878 }
879 "end of month" => {
880 let (y, m) = if local.month() == 12 {
881 (local.year() + 1, 1)
882 } else {
883 (local.year(), local.month() + 1)
884 };
885 let first_next = NaiveDate::from_ymd_opt(y, m, 1)?;
886 let last_day = first_next.pred_opt()?;
887 let naive = last_day.and_hms_opt(23, 59, 59)?;
888 tz.from_local_datetime(&naive).single()
889 }
890 "start of year" => {
891 let date = NaiveDate::from_ymd_opt(local.year(), 1, 1)?;
892 let naive = date.and_hms_opt(0, 0, 0)?;
893 tz.from_local_datetime(&naive).single()
894 }
895 "end of year" => {
896 let date = NaiveDate::from_ymd_opt(local.year(), 12, 31)?;
897 let naive = date.and_hms_opt(23, 59, 59)?;
898 tz.from_local_datetime(&naive).single()
899 }
900 "start of quarter" => {
901 let q_start_month = ((local.month() - 1) / 3) * 3 + 1;
902 let date = NaiveDate::from_ymd_opt(local.year(), q_start_month, 1)?;
903 let naive = date.and_hms_opt(0, 0, 0)?;
904 tz.from_local_datetime(&naive).single()
905 }
906 "end of quarter" => {
907 let q_end_month = ((local.month() - 1) / 3 + 1) * 3;
908 let (y, m) = if q_end_month == 12 {
909 (local.year() + 1, 1)
910 } else {
911 (local.year(), q_end_month + 1)
912 };
913 let first_next = NaiveDate::from_ymd_opt(y, m, 1)?;
914 let last_day = first_next.pred_opt()?;
915 let naive = last_day.and_hms_opt(23, 59, 59)?;
916 tz.from_local_datetime(&naive).single()
917 }
918 _ => None,
919 }
920}
921
922fn try_period_relative(
924 s: &str,
925 local: &DateTime<Tz>,
926 tz: &Tz,
927 ws: WeekStartDay,
928) -> Option<DateTime<Tz>> {
929 match s {
930 "next week" => {
931 let days_until_next_start = 7 - days_from_week_start(local.weekday(), ws);
932 let start = local.date_naive() + chrono::Duration::days(days_until_next_start);
933 let naive = start.and_hms_opt(0, 0, 0)?;
934 tz.from_local_datetime(&naive).single()
935 }
936 "last week" => {
937 let days_since_start = days_from_week_start(local.weekday(), ws);
938 let this_start = local.date_naive() - chrono::Duration::days(days_since_start);
939 let last_start = this_start - chrono::Duration::days(7);
940 let naive = last_start.and_hms_opt(0, 0, 0)?;
941 tz.from_local_datetime(&naive).single()
942 }
943 "next month" => {
944 let (y, m) = if local.month() == 12 {
945 (local.year() + 1, 1)
946 } else {
947 (local.year(), local.month() + 1)
948 };
949 let date = NaiveDate::from_ymd_opt(y, m, 1)?;
950 let naive = date.and_hms_opt(0, 0, 0)?;
951 tz.from_local_datetime(&naive).single()
952 }
953 "last month" => {
954 let (y, m) = if local.month() == 1 {
955 (local.year() - 1, 12)
956 } else {
957 (local.year(), local.month() - 1)
958 };
959 let date = NaiveDate::from_ymd_opt(y, m, 1)?;
960 let naive = date.and_hms_opt(0, 0, 0)?;
961 tz.from_local_datetime(&naive).single()
962 }
963 "next year" => {
964 let date = NaiveDate::from_ymd_opt(local.year() + 1, 1, 1)?;
965 let naive = date.and_hms_opt(0, 0, 0)?;
966 tz.from_local_datetime(&naive).single()
967 }
968 "last year" => {
969 let date = NaiveDate::from_ymd_opt(local.year() - 1, 1, 1)?;
970 let naive = date.and_hms_opt(0, 0, 0)?;
971 tz.from_local_datetime(&naive).single()
972 }
973 _ => None,
974 }
975}
976
977fn try_compound_period(
981 s: &str,
982 local: &DateTime<Tz>,
983 tz: &Tz,
984 ws: WeekStartDay,
985) -> Option<DateTime<Tz>> {
986 let (is_start, rest) = if let Some(r) = s.strip_prefix("start of ") {
987 (true, r)
988 } else if let Some(r) = s.strip_prefix("end of ") {
989 (false, r)
990 } else {
991 return None;
992 };
993
994 match rest {
995 "last week" => {
996 let days_since_start = days_from_week_start(local.weekday(), ws);
997 let this_start = local.date_naive() - chrono::Duration::days(days_since_start);
998 let last_start = this_start - chrono::Duration::days(7);
999 if is_start {
1000 let naive = last_start.and_hms_opt(0, 0, 0)?;
1001 tz.from_local_datetime(&naive).single()
1002 } else {
1003 let last_end = last_start + chrono::Duration::days(6);
1004 let naive = last_end.and_hms_opt(23, 59, 59)?;
1005 tz.from_local_datetime(&naive).single()
1006 }
1007 }
1008 "next week" => {
1009 let days_until_next_start = 7 - days_from_week_start(local.weekday(), ws);
1010 let next_start = local.date_naive() + chrono::Duration::days(days_until_next_start);
1011 if is_start {
1012 let naive = next_start.and_hms_opt(0, 0, 0)?;
1013 tz.from_local_datetime(&naive).single()
1014 } else {
1015 let next_end = next_start + chrono::Duration::days(6);
1016 let naive = next_end.and_hms_opt(23, 59, 59)?;
1017 tz.from_local_datetime(&naive).single()
1018 }
1019 }
1020 "last month" => {
1021 let (y, m) = if local.month() == 1 {
1022 (local.year() - 1, 12)
1023 } else {
1024 (local.year(), local.month() - 1)
1025 };
1026 if is_start {
1027 let date = NaiveDate::from_ymd_opt(y, m, 1)?;
1028 let naive = date.and_hms_opt(0, 0, 0)?;
1029 tz.from_local_datetime(&naive).single()
1030 } else {
1031 let first_current = NaiveDate::from_ymd_opt(local.year(), local.month(), 1)?;
1033 let last_day = first_current.pred_opt()?;
1034 let naive = last_day.and_hms_opt(23, 59, 59)?;
1035 tz.from_local_datetime(&naive).single()
1036 }
1037 }
1038 "next month" => {
1039 let (y, m) = if local.month() == 12 {
1040 (local.year() + 1, 1)
1041 } else {
1042 (local.year(), local.month() + 1)
1043 };
1044 if is_start {
1045 let date = NaiveDate::from_ymd_opt(y, m, 1)?;
1046 let naive = date.and_hms_opt(0, 0, 0)?;
1047 tz.from_local_datetime(&naive).single()
1048 } else {
1049 let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
1051 let first_after = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1052 let last_day = first_after.pred_opt()?;
1053 let naive = last_day.and_hms_opt(23, 59, 59)?;
1054 tz.from_local_datetime(&naive).single()
1055 }
1056 }
1057 "last year" => {
1058 let y = local.year() - 1;
1059 if is_start {
1060 let date = NaiveDate::from_ymd_opt(y, 1, 1)?;
1061 let naive = date.and_hms_opt(0, 0, 0)?;
1062 tz.from_local_datetime(&naive).single()
1063 } else {
1064 let date = NaiveDate::from_ymd_opt(y, 12, 31)?;
1065 let naive = date.and_hms_opt(23, 59, 59)?;
1066 tz.from_local_datetime(&naive).single()
1067 }
1068 }
1069 "next year" => {
1070 let y = local.year() + 1;
1071 if is_start {
1072 let date = NaiveDate::from_ymd_opt(y, 1, 1)?;
1073 let naive = date.and_hms_opt(0, 0, 0)?;
1074 tz.from_local_datetime(&naive).single()
1075 } else {
1076 let date = NaiveDate::from_ymd_opt(y, 12, 31)?;
1077 let naive = date.and_hms_opt(23, 59, 59)?;
1078 tz.from_local_datetime(&naive).single()
1079 }
1080 }
1081 "last quarter" => {
1082 let current_q = (local.month() - 1) / 3; let (prev_y, prev_q) = if current_q == 0 {
1084 (local.year() - 1, 3)
1085 } else {
1086 (local.year(), current_q - 1)
1087 };
1088 let q_first_month = prev_q * 3 + 1;
1089 if is_start {
1090 let date = NaiveDate::from_ymd_opt(prev_y, q_first_month, 1)?;
1091 let naive = date.and_hms_opt(0, 0, 0)?;
1092 tz.from_local_datetime(&naive).single()
1093 } else {
1094 let q_last_month = prev_q * 3 + 3;
1095 let (ny, nm) = if q_last_month == 12 {
1096 (prev_y + 1, 1)
1097 } else {
1098 (prev_y, q_last_month + 1)
1099 };
1100 let first_after = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1101 let last_day = first_after.pred_opt()?;
1102 let naive = last_day.and_hms_opt(23, 59, 59)?;
1103 tz.from_local_datetime(&naive).single()
1104 }
1105 }
1106 "next quarter" => {
1107 let current_q = (local.month() - 1) / 3;
1108 let (next_y, next_q) = if current_q == 3 {
1109 (local.year() + 1, 0)
1110 } else {
1111 (local.year(), current_q + 1)
1112 };
1113 let q_first_month = next_q * 3 + 1;
1114 if is_start {
1115 let date = NaiveDate::from_ymd_opt(next_y, q_first_month, 1)?;
1116 let naive = date.and_hms_opt(0, 0, 0)?;
1117 tz.from_local_datetime(&naive).single()
1118 } else {
1119 let q_last_month = next_q * 3 + 3;
1120 let (ny, nm) = if q_last_month == 12 {
1121 (next_y + 1, 1)
1122 } else {
1123 (next_y, q_last_month + 1)
1124 };
1125 let first_after = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1126 let last_day = first_after.pred_opt()?;
1127 let naive = last_day.and_hms_opt(23, 59, 59)?;
1128 tz.from_local_datetime(&naive).single()
1129 }
1130 }
1131 _ => None,
1132 }
1133}
1134
1135fn try_ordinal_date(s: &str, local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
1138 let parts: Vec<&str> = s.split_whitespace().collect();
1141
1142 if parts.len() < 4 || parts.iter().position(|&p| p == "of")? < 2 {
1143 return None;
1144 }
1145
1146 let of_idx = parts.iter().position(|&p| p == "of")?;
1147 if of_idx < 2 {
1148 return None;
1149 }
1150
1151 let ordinal_str = parts[0];
1152 let target_str = parts[1];
1153
1154 if ordinal_str == "last" && target_str == "day" {
1156 let month_str = parts.get(of_idx + 1)?;
1157 let month = parse_month(month_str)?;
1158 let year = if let Some(y_str) = parts.get(of_idx + 2) {
1159 y_str.parse::<i32>().ok()?
1160 } else {
1161 local.year()
1162 };
1163 let (ny, nm) = if month == 12 {
1164 (year + 1, 1)
1165 } else {
1166 (year, month + 1)
1167 };
1168 let first_next = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1169 let last_day = first_next.pred_opt()?;
1170 let naive = last_day.and_hms_opt(0, 0, 0)?;
1171 return tz.from_local_datetime(&naive).single();
1172 }
1173
1174 let weekday = parse_weekday(target_str)?;
1175
1176 let month_part = parts.get(of_idx + 1)?;
1177 let (month, year) = if *month_part == "month" {
1179 (local.month(), local.year())
1180 } else if let Some(month_num) = parse_month(month_part) {
1181 let year = if let Some(y_str) = parts.get(of_idx + 2) {
1182 y_str.parse::<i32>().unwrap_or(local.year())
1183 } else {
1184 local.year()
1185 };
1186 (month_num, year)
1187 } else if *month_part == "next" && parts.get(of_idx + 2) == Some(&"month") {
1188 let (y, m) = if local.month() == 12 {
1189 (local.year() + 1, 1)
1190 } else {
1191 (local.year(), local.month() + 1)
1192 };
1193 (m, y)
1194 } else {
1195 return None;
1196 };
1197
1198 let ordinal = parse_ordinal(ordinal_str)?;
1199
1200 let date = find_nth_weekday_in_month(year, month, weekday, ordinal)?;
1201 let naive = date.and_hms_opt(0, 0, 0)?;
1202 tz.from_local_datetime(&naive).single()
1203}
1204
1205fn find_nth_weekday_in_month(
1207 year: i32,
1208 month: u32,
1209 weekday: Weekday,
1210 ordinal: i32,
1211) -> Option<NaiveDate> {
1212 if ordinal > 0 {
1213 let first = NaiveDate::from_ymd_opt(year, month, 1)?;
1215 let first_wd = first.weekday();
1216 let diff = (weekday.num_days_from_monday() as i32 - first_wd.num_days_from_monday() as i32
1217 + 7)
1218 % 7;
1219 let first_occurrence = first + chrono::Duration::days(diff as i64);
1220 let target = first_occurrence + chrono::Duration::weeks((ordinal - 1) as i64);
1221 if target.month() == month {
1223 Some(target)
1224 } else {
1225 None
1226 }
1227 } else {
1228 let (ny, nm) = if month == 12 {
1230 (year + 1, 1)
1231 } else {
1232 (year, month + 1)
1233 };
1234 let first_next = NaiveDate::from_ymd_opt(ny, nm, 1)?;
1235 let last = first_next.pred_opt()?;
1236 let last_wd = last.weekday();
1237 let diff =
1238 (last_wd.num_days_from_monday() as i32 - weekday.num_days_from_monday() as i32 + 7) % 7;
1239 let last_occurrence = last - chrono::Duration::days(diff as i64);
1240 let target = last_occurrence - chrono::Duration::weeks((-ordinal - 1) as i64);
1241 if target.month() == month {
1243 Some(target)
1244 } else {
1245 None
1246 }
1247 }
1248}
1249
1250fn parse_weekday(s: &str) -> Option<Weekday> {
1254 match s {
1255 "monday" | "mon" => Some(Weekday::Mon),
1256 "tuesday" | "tue" | "tues" => Some(Weekday::Tue),
1257 "wednesday" | "wed" => Some(Weekday::Wed),
1258 "thursday" | "thu" | "thurs" => Some(Weekday::Thu),
1259 "friday" | "fri" => Some(Weekday::Fri),
1260 "saturday" | "sat" => Some(Weekday::Sat),
1261 "sunday" | "sun" => Some(Weekday::Sun),
1262 _ => None,
1263 }
1264}
1265
1266fn parse_month(s: &str) -> Option<u32> {
1268 match s {
1269 "january" | "jan" => Some(1),
1270 "february" | "feb" => Some(2),
1271 "march" | "mar" => Some(3),
1272 "april" | "apr" => Some(4),
1273 "may" => Some(5),
1274 "june" | "jun" => Some(6),
1275 "july" | "jul" => Some(7),
1276 "august" | "aug" => Some(8),
1277 "september" | "sep" | "sept" => Some(9),
1278 "october" | "oct" => Some(10),
1279 "november" | "nov" => Some(11),
1280 "december" | "dec" => Some(12),
1281 _ => None,
1282 }
1283}
1284
1285fn parse_ordinal(s: &str) -> Option<i32> {
1287 match s {
1288 "first" | "1st" => Some(1),
1289 "second" | "2nd" => Some(2),
1290 "third" | "3rd" => Some(3),
1291 "fourth" | "4th" => Some(4),
1292 "fifth" | "5th" => Some(5),
1293 "last" => Some(-1),
1294 _ => None,
1295 }
1296}
1297
1298fn named_time_to_naive(s: &str) -> Option<NaiveTime> {
1300 match s {
1301 "morning" | "start of business" | "sob" => NaiveTime::from_hms_opt(9, 0, 0),
1302 "noon" | "lunch" => NaiveTime::from_hms_opt(12, 0, 0),
1303 "afternoon" => NaiveTime::from_hms_opt(13, 0, 0),
1304 "end of day" | "end of business" | "eob" => NaiveTime::from_hms_opt(17, 0, 0),
1305 "evening" => NaiveTime::from_hms_opt(18, 0, 0),
1306 "night" => NaiveTime::from_hms_opt(21, 0, 0),
1307 "midnight" => NaiveTime::from_hms_opt(0, 0, 0),
1308 _ => None,
1309 }
1310}
1311
1312fn parse_time_string(s: &str) -> Option<NaiveTime> {
1314 let s = s.trim();
1315
1316 if let Ok(t) = NaiveTime::parse_from_str(s, "%H:%M:%S") {
1318 return Some(t);
1319 }
1320 if let Ok(t) = NaiveTime::parse_from_str(s, "%H:%M") {
1321 return Some(t);
1322 }
1323
1324 let s_no_space = s.replace(' ', "");
1326 let (time_part, is_pm) = if s_no_space.ends_with("pm") {
1327 (s_no_space.strip_suffix("pm")?, true)
1328 } else if s_no_space.ends_with("am") {
1329 (s_no_space.strip_suffix("am")?, false)
1330 } else {
1331 return None;
1332 };
1333
1334 let parts: Vec<&str> = time_part.split(':').collect();
1335 let hour: u32 = parts.first()?.parse().ok()?;
1336 let minute: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1337 let second: u32 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
1338
1339 let hour24 = match (hour, is_pm) {
1340 (12, true) => 12,
1341 (12, false) => 0,
1342 (h, true) => h + 12,
1343 (h, false) => h,
1344 };
1345
1346 NaiveTime::from_hms_opt(hour24, minute, second)
1347}
1348
1349fn parse_natural_number_and_unit(s: &str) -> Option<(i64, String)> {
1351 let parts: Vec<&str> = s.split_whitespace().collect();
1352 if parts.len() < 2 {
1353 return None;
1354 }
1355 let n: i64 = parts[0].parse().ok()?;
1356 let unit = normalize_time_unit(parts[1])?;
1357 Some((n, unit))
1358}
1359
1360fn parse_natural_number_and_unit_with_article(s: &str) -> Option<(i64, String)> {
1362 let parts: Vec<&str> = s.split_whitespace().collect();
1363 if parts.is_empty() {
1364 return None;
1365 }
1366
1367 if parts[0] == "a" || parts[0] == "an" {
1369 if parts.len() < 2 {
1370 return None;
1371 }
1372 let unit = normalize_time_unit(parts[1])?;
1373 return Some((1, unit));
1374 }
1375
1376 parse_natural_number_and_unit(s)
1378}
1379
1380fn normalize_time_unit(s: &str) -> Option<String> {
1382 match s {
1383 "second" | "seconds" | "sec" | "secs" => Some("seconds".to_string()),
1384 "minute" | "minutes" | "min" | "mins" => Some("minutes".to_string()),
1385 "hour" | "hours" | "hr" | "hrs" => Some("hours".to_string()),
1386 "day" | "days" => Some("days".to_string()),
1387 "week" | "weeks" | "wk" | "wks" => Some("weeks".to_string()),
1388 _ => None,
1389 }
1390}
1391
1392fn unit_to_seconds(n: i64, unit: &str) -> Option<i64> {
1394 let multiplier = match unit {
1395 "seconds" => 1,
1396 "minutes" => 60,
1397 "hours" => 3600,
1398 "days" => 86400,
1399 "weeks" => 604800,
1400 _ => return None,
1401 };
1402 Some(n * multiplier)
1403}
1404
1405fn make_local_start_of_day(local: &DateTime<Tz>, tz: &Tz) -> Option<DateTime<Tz>> {
1407 let naive = local.date_naive().and_hms_opt(0, 0, 0)?;
1408 tz.from_local_datetime(&naive).single()
1409}
1410
1411fn format_interpretation<T: TimeZone>(dt: &DateTime<T>) -> String
1413where
1414 T::Offset: std::fmt::Display,
1415{
1416 dt.format("%A, %B %-d, %Y at %-I:%M %p %Z").to_string()
1417}
1418
1419#[cfg(test)]
1422mod tests {
1423 use super::*;
1424 use chrono::TimeZone;
1425
1426 #[test]
1429 fn test_convert_utc_to_eastern() {
1430 let result = convert_timezone("2026-03-15T14:00:00Z", "America/New_York").unwrap();
1431 assert_eq!(result.timezone, "America/New_York");
1432 assert!(result.local.contains("10:00:00"));
1434 assert_eq!(result.utc, "2026-03-15T14:00:00+00:00");
1435 }
1436
1437 #[test]
1438 fn test_convert_eastern_to_pacific() {
1439 let result = convert_timezone("2026-01-15T14:00:00-05:00", "America/Los_Angeles").unwrap();
1441 assert_eq!(result.timezone, "America/Los_Angeles");
1442 assert!(result.local.contains("11:00:00"));
1444 }
1445
1446 #[test]
1447 fn test_convert_across_dst_spring_forward() {
1448 let winter = convert_timezone("2026-01-15T12:00:00Z", "America/New_York").unwrap();
1451 assert_eq!(winter.utc_offset, "-05:00");
1452 assert!(!winter.dst_active);
1453
1454 let summer = convert_timezone("2026-03-15T12:00:00Z", "America/New_York").unwrap();
1456 assert_eq!(summer.utc_offset, "-04:00");
1457 assert!(summer.dst_active);
1458 }
1459
1460 #[test]
1461 fn test_convert_across_dst_fall_back() {
1462 let result = convert_timezone("2026-11-02T12:00:00Z", "America/New_York").unwrap();
1465 assert_eq!(result.utc_offset, "-05:00");
1466 assert!(!result.dst_active);
1467 }
1468
1469 #[test]
1470 fn test_convert_utc_offset_correct() {
1471 let result = convert_timezone("2026-06-15T12:00:00Z", "Asia/Tokyo").unwrap();
1472 assert_eq!(result.utc_offset, "+09:00");
1473 assert!(!result.dst_active); }
1475
1476 #[test]
1477 fn test_convert_dst_active_flag() {
1478 let summer = convert_timezone("2026-07-15T12:00:00Z", "America/New_York").unwrap();
1480 assert!(summer.dst_active);
1481
1482 let winter = convert_timezone("2026-12-15T12:00:00Z", "America/New_York").unwrap();
1484 assert!(!winter.dst_active);
1485 }
1486
1487 #[test]
1488 fn test_convert_invalid_timezone_returns_error() {
1489 let result = convert_timezone("2026-03-15T14:00:00Z", "Invalid/Zone");
1490 assert!(result.is_err());
1491 let err = result.unwrap_err().to_string();
1492 assert!(err.contains("Invalid timezone"), "got: {err}");
1493 }
1494
1495 #[test]
1496 fn test_convert_invalid_datetime_returns_error() {
1497 let result = convert_timezone("not-a-datetime", "America/New_York");
1498 assert!(result.is_err());
1499 let err = result.unwrap_err().to_string();
1500 assert!(err.contains("Invalid datetime"), "got: {err}");
1501 }
1502
1503 #[test]
1506 fn test_duration_same_day() {
1507 let result = compute_duration("2026-03-16T09:00:00Z", "2026-03-16T17:00:00Z").unwrap();
1508 assert_eq!(result.total_seconds, 28800); assert_eq!(result.hours, 8);
1510 assert_eq!(result.days, 0);
1511 assert_eq!(result.minutes, 0);
1512 }
1513
1514 #[test]
1515 fn test_duration_across_days() {
1516 let result = compute_duration(
1517 "2026-03-13T17:00:00Z", "2026-03-16T09:00:00Z", )
1520 .unwrap();
1521 assert_eq!(result.total_seconds, 230400); assert_eq!(result.days, 2);
1523 assert_eq!(result.hours, 16);
1524 }
1525
1526 #[test]
1527 fn test_duration_negative_direction() {
1528 let result = compute_duration("2026-03-16T17:00:00Z", "2026-03-16T09:00:00Z").unwrap();
1529 assert_eq!(result.total_seconds, -28800);
1530 assert_eq!(result.hours, 8);
1532 }
1533
1534 #[test]
1535 fn test_duration_exact_days() {
1536 let result = compute_duration("2026-03-16T00:00:00Z", "2026-03-19T00:00:00Z").unwrap();
1537 assert_eq!(result.days, 3);
1538 assert_eq!(result.hours, 0);
1539 assert_eq!(result.minutes, 0);
1540 assert_eq!(result.seconds, 0);
1541 }
1542
1543 #[test]
1544 fn test_duration_sub_minute() {
1545 let result = compute_duration("2026-03-16T10:00:00Z", "2026-03-16T10:00:45Z").unwrap();
1546 assert_eq!(result.total_seconds, 45);
1547 assert_eq!(result.seconds, 45);
1548 assert_eq!(result.minutes, 0);
1549 }
1550
1551 #[test]
1552 fn test_duration_human_readable_format() {
1553 let result = compute_duration("2026-03-16T00:00:00Z", "2026-03-18T03:15:00Z").unwrap();
1554 assert_eq!(result.human_readable, "2 days, 3 hours, 15 minutes");
1555 }
1556
1557 #[test]
1558 fn test_duration_invalid_input() {
1559 let result = compute_duration("not-a-datetime", "2026-03-16T10:00:00Z");
1560 assert!(result.is_err());
1561 }
1562
1563 #[test]
1566 fn test_adjust_add_hours() {
1567 let result = adjust_timestamp("2026-03-16T10:00:00Z", "+2h", "UTC").unwrap();
1568 assert!(result.adjusted_utc.contains("12:00:00"));
1569 }
1570
1571 #[test]
1572 fn test_adjust_subtract_days() {
1573 let result = adjust_timestamp("2026-03-05T10:00:00Z", "-3d", "UTC").unwrap();
1574 assert!(result.adjusted_utc.contains("2026-03-02"));
1575 }
1576
1577 #[test]
1578 fn test_adjust_add_minutes() {
1579 let result = adjust_timestamp("2026-03-16T10:00:00Z", "+90m", "UTC").unwrap();
1580 assert!(result.adjusted_utc.contains("11:30:00"));
1581 }
1582
1583 #[test]
1584 fn test_adjust_add_weeks() {
1585 let result = adjust_timestamp("2026-03-02T10:00:00Z", "+2w", "UTC").unwrap();
1586 assert!(result.adjusted_utc.contains("2026-03-16"));
1587 }
1588
1589 #[test]
1590 fn test_adjust_compound_duration() {
1591 let result = adjust_timestamp("2026-03-16T10:00:00Z", "+1d2h30m", "UTC").unwrap();
1592 assert!(result.adjusted_utc.contains("2026-03-17"));
1594 assert!(result.adjusted_utc.contains("12:30:00"));
1595 }
1596
1597 #[test]
1598 fn test_adjust_day_across_dst() {
1599 let result = adjust_timestamp(
1601 "2026-03-07T22:00:00-05:00", "+1d",
1603 "America/New_York",
1604 )
1605 .unwrap();
1606 assert!(result.adjusted_local.contains("22:00:00"));
1608 }
1609
1610 #[test]
1611 fn test_adjust_negative_compound() {
1612 let result = adjust_timestamp("2026-03-16T10:00:00Z", "-1d12h", "UTC").unwrap();
1613 assert!(result.adjusted_utc.contains("2026-03-14"));
1615 assert!(result.adjusted_utc.contains("22:00:00"));
1616 }
1617
1618 #[test]
1619 fn test_adjust_add_seconds() {
1620 let result = adjust_timestamp("2026-03-16T10:00:00Z", "+3600s", "UTC").unwrap();
1621 assert!(result.adjusted_utc.contains("11:00:00"));
1622 }
1623
1624 #[test]
1625 fn test_adjust_invalid_format() {
1626 let result = adjust_timestamp("2026-03-16T10:00:00Z", "2h", "UTC");
1627 assert!(result.is_err());
1628 let err = result.unwrap_err().to_string();
1629 assert!(err.contains("must start with '+' or '-'"), "got: {err}");
1630 }
1631
1632 #[test]
1633 fn test_adjust_zero_duration() {
1634 let result = adjust_timestamp("2026-03-16T10:00:00Z", "+0h", "UTC").unwrap();
1635 assert!(result.adjusted_utc.contains("10:00:00"));
1636 }
1637
1638 fn anchor() -> DateTime<Utc> {
1641 Utc.with_ymd_and_hms(2026, 2, 18, 14, 30, 0).unwrap()
1643 }
1644
1645 #[test]
1646 fn test_resolve_now() {
1647 let result = resolve_relative(anchor(), "now", "UTC").unwrap();
1648 assert!(result.resolved_utc.contains("14:30:00"));
1649 }
1650
1651 #[test]
1652 fn test_resolve_today() {
1653 let result = resolve_relative(anchor(), "today", "UTC").unwrap();
1654 assert!(result.resolved_utc.contains("2026-02-18"));
1655 assert!(result.resolved_utc.contains("00:00:00"));
1656 }
1657
1658 #[test]
1659 fn test_resolve_tomorrow() {
1660 let result = resolve_relative(anchor(), "tomorrow", "UTC").unwrap();
1661 assert!(result.resolved_utc.contains("2026-02-19"));
1662 assert!(result.resolved_utc.contains("00:00:00"));
1663 }
1664
1665 #[test]
1666 fn test_resolve_yesterday() {
1667 let result = resolve_relative(anchor(), "yesterday", "UTC").unwrap();
1668 assert!(result.resolved_utc.contains("2026-02-17"));
1669 }
1670
1671 #[test]
1672 fn test_resolve_next_monday_from_wednesday() {
1673 let result = resolve_relative(anchor(), "next Monday", "UTC").unwrap();
1675 assert!(result.resolved_utc.contains("2026-02-23"));
1676 }
1677
1678 #[test]
1679 fn test_resolve_next_friday_from_friday() {
1680 let fri_anchor = Utc.with_ymd_and_hms(2026, 2, 20, 10, 0, 0).unwrap();
1682 let result = resolve_relative(fri_anchor, "next Friday", "UTC").unwrap();
1683 assert!(result.resolved_utc.contains("2026-02-27"));
1684 }
1685
1686 #[test]
1687 fn test_resolve_this_wednesday_from_monday() {
1688 let mon_anchor = Utc.with_ymd_and_hms(2026, 2, 16, 10, 0, 0).unwrap();
1689 let result = resolve_relative(mon_anchor, "this Wednesday", "UTC").unwrap();
1690 assert!(result.resolved_utc.contains("2026-02-18"));
1691 }
1692
1693 #[test]
1694 fn test_resolve_last_tuesday_from_thursday() {
1695 let thu_anchor = Utc.with_ymd_and_hms(2026, 2, 19, 10, 0, 0).unwrap();
1696 let result = resolve_relative(thu_anchor, "last Tuesday", "UTC").unwrap();
1697 assert!(result.resolved_utc.contains("2026-02-17"));
1698 }
1699
1700 #[test]
1701 fn test_resolve_morning() {
1702 let result = resolve_relative(anchor(), "morning", "UTC").unwrap();
1703 assert!(result.resolved_utc.contains("09:00:00"));
1704 }
1705
1706 #[test]
1707 fn test_resolve_noon() {
1708 let result = resolve_relative(anchor(), "noon", "UTC").unwrap();
1709 assert!(result.resolved_utc.contains("12:00:00"));
1710 }
1711
1712 #[test]
1713 fn test_resolve_afternoon() {
1714 let result = resolve_relative(anchor(), "afternoon", "UTC").unwrap();
1715 assert!(result.resolved_utc.contains("13:00:00"));
1716 }
1717
1718 #[test]
1719 fn test_resolve_evening() {
1720 let result = resolve_relative(anchor(), "evening", "UTC").unwrap();
1721 assert!(result.resolved_utc.contains("18:00:00"));
1722 }
1723
1724 #[test]
1725 fn test_resolve_eob() {
1726 let result = resolve_relative(anchor(), "eob", "UTC").unwrap();
1727 assert!(result.resolved_utc.contains("17:00:00"));
1728 }
1729
1730 #[test]
1731 fn test_resolve_midnight() {
1732 let result = resolve_relative(anchor(), "midnight", "UTC").unwrap();
1733 assert!(result.resolved_utc.contains("00:00:00"));
1734 }
1735
1736 #[test]
1737 fn test_resolve_2pm() {
1738 let result = resolve_relative(anchor(), "2pm", "UTC").unwrap();
1739 assert!(result.resolved_utc.contains("14:00:00"));
1740 }
1741
1742 #[test]
1743 fn test_resolve_2_30pm() {
1744 let result = resolve_relative(anchor(), "2:30pm", "UTC").unwrap();
1745 assert!(result.resolved_utc.contains("14:30:00"));
1746 }
1747
1748 #[test]
1749 fn test_resolve_14_00() {
1750 let result = resolve_relative(anchor(), "14:00", "UTC").unwrap();
1751 assert!(result.resolved_utc.contains("14:00:00"));
1752 }
1753
1754 #[test]
1755 fn test_resolve_in_2_hours() {
1756 let result = resolve_relative(anchor(), "in 2 hours", "UTC").unwrap();
1757 assert!(result.resolved_utc.contains("16:30:00"));
1758 }
1759
1760 #[test]
1761 fn test_resolve_30_minutes_ago() {
1762 let result = resolve_relative(anchor(), "30 minutes ago", "UTC").unwrap();
1763 assert!(result.resolved_utc.contains("14:00:00"));
1764 }
1765
1766 #[test]
1767 fn test_resolve_in_3_days() {
1768 let result = resolve_relative(anchor(), "in 3 days", "UTC").unwrap();
1769 assert!(result.resolved_utc.contains("2026-02-21"));
1770 }
1771
1772 #[test]
1773 fn test_resolve_a_week_from_now() {
1774 let result = resolve_relative(anchor(), "a week from now", "UTC").unwrap();
1775 assert!(result.resolved_utc.contains("2026-02-25"));
1776 }
1777
1778 #[test]
1779 fn test_resolve_next_tuesday_at_2pm() {
1780 let result = resolve_relative(anchor(), "next Tuesday at 2pm", "UTC").unwrap();
1782 assert!(result.resolved_utc.contains("2026-02-24"));
1783 assert!(result.resolved_utc.contains("14:00:00"));
1784 }
1785
1786 #[test]
1787 fn test_resolve_tomorrow_at_10_30am() {
1788 let result = resolve_relative(anchor(), "tomorrow at 10:30am", "UTC").unwrap();
1789 assert!(result.resolved_utc.contains("2026-02-19"));
1790 assert!(result.resolved_utc.contains("10:30:00"));
1791 }
1792
1793 #[test]
1794 fn test_resolve_tomorrow_morning() {
1795 let result = resolve_relative(anchor(), "tomorrow morning", "UTC").unwrap();
1796 assert!(result.resolved_utc.contains("2026-02-19"));
1797 assert!(result.resolved_utc.contains("09:00:00"));
1798 }
1799
1800 #[test]
1801 fn test_resolve_next_friday_evening() {
1802 let result = resolve_relative(anchor(), "next Friday evening", "UTC").unwrap();
1804 assert!(result.resolved_utc.contains("2026-02-20"));
1805 assert!(result.resolved_utc.contains("18:00:00"));
1806 }
1807
1808 #[test]
1809 fn test_resolve_today_at_noon() {
1810 let result = resolve_relative(anchor(), "today at noon", "UTC").unwrap();
1811 assert!(result.resolved_utc.contains("2026-02-18"));
1812 assert!(result.resolved_utc.contains("12:00:00"));
1813 }
1814
1815 #[test]
1816 fn test_resolve_start_of_week() {
1817 let result = resolve_relative(anchor(), "start of week", "UTC").unwrap();
1819 assert!(result.resolved_utc.contains("2026-02-16"));
1820 assert!(result.resolved_utc.contains("00:00:00"));
1821 }
1822
1823 #[test]
1824 fn test_resolve_end_of_month() {
1825 let result = resolve_relative(anchor(), "end of month", "UTC").unwrap();
1826 assert!(result.resolved_utc.contains("2026-02-28"));
1827 assert!(result.resolved_utc.contains("23:59:59"));
1828 }
1829
1830 #[test]
1831 fn test_resolve_start_of_quarter() {
1832 let result = resolve_relative(anchor(), "start of quarter", "UTC").unwrap();
1834 assert!(result.resolved_utc.contains("2026-01-01"));
1835 }
1836
1837 #[test]
1838 fn test_resolve_next_week() {
1839 let result = resolve_relative(anchor(), "next week", "UTC").unwrap();
1841 assert!(result.resolved_utc.contains("2026-02-23"));
1842 }
1843
1844 #[test]
1845 fn test_resolve_next_month() {
1846 let result = resolve_relative(anchor(), "next month", "UTC").unwrap();
1847 assert!(result.resolved_utc.contains("2026-03-01"));
1848 }
1849
1850 #[test]
1851 fn test_resolve_first_monday_of_march() {
1852 let result = resolve_relative(anchor(), "first Monday of March", "UTC").unwrap();
1853 assert!(result.resolved_utc.contains("2026-03-02"));
1855 }
1856
1857 #[test]
1858 fn test_resolve_last_friday_of_month() {
1859 let result = resolve_relative(anchor(), "last Friday of the month", "UTC").unwrap();
1860 assert!(result.resolved_utc.contains("2026-02-27"));
1862 }
1863
1864 #[test]
1865 fn test_resolve_third_tuesday_of_march_2026() {
1866 let result = resolve_relative(anchor(), "third Tuesday of March 2026", "UTC").unwrap();
1867 assert!(result.resolved_utc.contains("2026-03-17"));
1869 }
1870
1871 #[test]
1872 fn test_resolve_passthrough_rfc3339() {
1873 let input = "2026-06-15T10:00:00-04:00";
1874 let result = resolve_relative(anchor(), input, "UTC").unwrap();
1875 assert!(result.resolved_utc.contains("2026-06-15"));
1877 assert!(result.resolved_utc.contains("14:00:00"));
1878 }
1879
1880 #[test]
1881 fn test_resolve_passthrough_iso_date() {
1882 let result = resolve_relative(anchor(), "2026-03-15", "America/New_York").unwrap();
1883 assert!(result.resolved_local.contains("2026-03-15"));
1885 assert!(result.resolved_local.contains("00:00:00"));
1886 }
1887
1888 #[test]
1889 fn test_resolve_case_insensitive() {
1890 let result = resolve_relative(anchor(), "Next TUESDAY at 2PM", "UTC").unwrap();
1891 assert!(result.resolved_utc.contains("2026-02-24"));
1892 assert!(result.resolved_utc.contains("14:00:00"));
1893 }
1894
1895 #[test]
1896 fn test_resolve_articles_ignored() {
1897 let result = resolve_relative(anchor(), "a week from now", "UTC").unwrap();
1898 assert!(result.resolved_utc.contains("2026-02-25"));
1899 }
1900
1901 #[test]
1902 fn test_resolve_unparseable_returns_error() {
1903 let result = resolve_relative(anchor(), "gobbledygook", "UTC");
1904 assert!(result.is_err());
1905 let err = result.unwrap_err().to_string();
1906 assert!(err.contains("cannot parse expression"), "got: {err}");
1907 }
1908
1909 #[test]
1910 fn test_resolve_interpretation_format() {
1911 let result = resolve_relative(anchor(), "next Tuesday at 2pm", "UTC").unwrap();
1912 assert!(result.interpretation.contains("Tuesday"));
1914 assert!(result.interpretation.contains("February 24"));
1915 assert!(result.interpretation.contains("2026"));
1916 }
1917
1918 #[test]
1921 fn test_resolve_start_of_last_week() {
1922 let result = resolve_relative(anchor(), "start of last week", "UTC").unwrap();
1924 assert!(result.resolved_utc.contains("2026-02-09"));
1925 assert!(result.resolved_utc.contains("00:00:00"));
1926 }
1927
1928 #[test]
1929 fn test_resolve_end_of_last_week() {
1930 let result = resolve_relative(anchor(), "end of last week", "UTC").unwrap();
1932 assert!(result.resolved_utc.contains("2026-02-15"));
1933 assert!(result.resolved_utc.contains("23:59:59"));
1934 }
1935
1936 #[test]
1937 fn test_resolve_start_of_next_week() {
1938 let result = resolve_relative(anchor(), "start of next week", "UTC").unwrap();
1940 assert!(result.resolved_utc.contains("2026-02-23"));
1941 assert!(result.resolved_utc.contains("00:00:00"));
1942 }
1943
1944 #[test]
1945 fn test_resolve_end_of_next_week() {
1946 let result = resolve_relative(anchor(), "end of next week", "UTC").unwrap();
1948 assert!(result.resolved_utc.contains("2026-03-01"));
1949 assert!(result.resolved_utc.contains("23:59:59"));
1950 }
1951
1952 #[test]
1953 fn test_resolve_start_of_last_month() {
1954 let result = resolve_relative(anchor(), "start of last month", "UTC").unwrap();
1955 assert!(result.resolved_utc.contains("2026-01-01"));
1956 assert!(result.resolved_utc.contains("00:00:00"));
1957 }
1958
1959 #[test]
1960 fn test_resolve_end_of_last_month() {
1961 let result = resolve_relative(anchor(), "end of last month", "UTC").unwrap();
1963 assert!(result.resolved_utc.contains("2026-01-31"));
1964 assert!(result.resolved_utc.contains("23:59:59"));
1965 }
1966
1967 #[test]
1968 fn test_resolve_start_of_next_month() {
1969 let result = resolve_relative(anchor(), "start of next month", "UTC").unwrap();
1970 assert!(result.resolved_utc.contains("2026-03-01"));
1971 assert!(result.resolved_utc.contains("00:00:00"));
1972 }
1973
1974 #[test]
1975 fn test_resolve_end_of_next_month() {
1976 let result = resolve_relative(anchor(), "end of next month", "UTC").unwrap();
1978 assert!(result.resolved_utc.contains("2026-03-31"));
1979 assert!(result.resolved_utc.contains("23:59:59"));
1980 }
1981
1982 #[test]
1983 fn test_resolve_start_of_next_year() {
1984 let result = resolve_relative(anchor(), "start of next year", "UTC").unwrap();
1985 assert!(result.resolved_utc.contains("2027-01-01"));
1986 assert!(result.resolved_utc.contains("00:00:00"));
1987 }
1988
1989 #[test]
1990 fn test_resolve_end_of_last_quarter() {
1991 let result = resolve_relative(anchor(), "end of last quarter", "UTC").unwrap();
1993 assert!(result.resolved_utc.contains("2025-12-31"));
1994 assert!(result.resolved_utc.contains("23:59:59"));
1995 }
1996
1997 #[test]
2000 fn test_resolve_start_of_week_sunday() {
2001 let options = ResolveOptions {
2003 week_start: WeekStartDay::Sunday,
2004 };
2005 let result =
2006 resolve_relative_with_options(anchor(), "start of week", "UTC", &options).unwrap();
2007 assert!(result.resolved_utc.contains("2026-02-15"));
2008 assert!(result.resolved_utc.contains("00:00:00"));
2009 }
2010
2011 #[test]
2012 fn test_resolve_end_of_week_sunday() {
2013 let options = ResolveOptions {
2015 week_start: WeekStartDay::Sunday,
2016 };
2017 let result =
2018 resolve_relative_with_options(anchor(), "end of week", "UTC", &options).unwrap();
2019 assert!(result.resolved_utc.contains("2026-02-21"));
2020 assert!(result.resolved_utc.contains("23:59:59"));
2021 }
2022
2023 #[test]
2024 fn test_resolve_start_of_last_week_sunday() {
2025 let options = ResolveOptions {
2027 week_start: WeekStartDay::Sunday,
2028 };
2029 let result =
2030 resolve_relative_with_options(anchor(), "start of last week", "UTC", &options).unwrap();
2031 assert!(result.resolved_utc.contains("2026-02-08"));
2032 assert!(result.resolved_utc.contains("00:00:00"));
2033 }
2034
2035 #[test]
2036 fn test_resolve_next_week_sunday() {
2037 let options = ResolveOptions {
2039 week_start: WeekStartDay::Sunday,
2040 };
2041 let result = resolve_relative_with_options(anchor(), "next week", "UTC", &options).unwrap();
2042 assert!(result.resolved_utc.contains("2026-02-22"));
2043 assert!(result.resolved_utc.contains("00:00:00"));
2044 }
2045}