temps_core/lib.rs
1//! # temps-core
2//!
3//! Core functionality for parsing human-readable time expressions.
4//!
5//! This crate provides the fundamental types and traits for parsing natural language
6//! time expressions like "in 5 minutes", "yesterday at 3pm", or "next Monday".
7//! It is designed to be backend-agnostic, allowing different datetime libraries
8//! (chrono, jiff, etc.) to implement the parsing logic.
9//!
10//! ## Overview
11//!
12//! The crate consists of several key components:
13//!
14//! - **Types**: Core data structures representing different time expressions
15//! - **Traits**: Interfaces for implementing time parsing with different backends
16//! - **Parsers**: Language-specific parsers (English and German)
17//! - **Utilities**: Helper functions for time calculations and conversions
18//!
19//! ## Example
20//!
21//! ```
22//! use temps_core::{parse, Language, TimeExpression};
23//!
24//! // Parse a relative time expression
25//! let expr = parse("in 5 minutes", Language::English).unwrap();
26//! match expr {
27//! TimeExpression::Relative(rel) => {
28//! println!("Amount: {}, Unit: {:?}", rel.amount, rel.unit);
29//! }
30//! _ => {}
31//! }
32//!
33//! // Parse with German language
34//! let expr = parse("in 5 Minuten", Language::German).unwrap();
35//! ```
36//!
37//! ## Supported Languages
38//!
39//! - English
40//! - German
41//!
42//! ## Error Handling
43//!
44//! All parsing operations return a `Result<T, TempsError>` where `TempsError`
45//! provides detailed information about what went wrong during parsing or
46//! date calculations.
47
48// ===== Error Module =====
49pub mod error;
50pub use error::{Result, TempsError};
51
52// ===== Core Types =====
53
54/// Represents a parsed time expression.
55///
56/// This is the main output type of the parsing functions. It can represent
57/// various forms of time expressions from natural language input.
58///
59/// # Examples
60///
61/// ```
62/// use temps_core::{parse, Language, TimeExpression};
63///
64/// // "now" -> TimeExpression::Now
65/// // "in 5 minutes" -> TimeExpression::Relative(...)
66/// // "2024-01-15T14:30:00Z" -> TimeExpression::Absolute(...)
67/// // "tomorrow" -> TimeExpression::Day(...)
68/// // "3:30 pm" -> TimeExpression::Time(...)
69/// // "tomorrow at 3:30 pm" -> TimeExpression::DayTime(...)
70/// ```
71#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
72pub enum TimeExpression {
73 /// The current moment in time (e.g., "now", "jetzt")
74 Now,
75 /// A time relative to now (e.g., "in 5 minutes", "3 days ago")
76 Relative(RelativeTime),
77 /// An absolute date/time (e.g., "2024-01-15T14:30:00Z")
78 Absolute(AbsoluteTime),
79 /// A day reference (e.g., "tomorrow", "next Monday")
80 Day(DayReference),
81 /// A time of day (e.g., "3:30 pm", "14:30")
82 Time(Time),
83 /// A calendar date (e.g., "15/03/2024", "31-12-2025")
84 Date(StandardDate),
85 /// A day with a specific time (e.g., "tomorrow at 3:30 pm")
86 DayTime(DayTime),
87}
88
89/// Represents a time relative to the current moment.
90///
91/// # Examples
92///
93/// ```
94/// use temps_core::{RelativeTime, TimeUnit, Direction};
95///
96/// // "in 5 minutes"
97/// let future = RelativeTime {
98/// amount: 5,
99/// unit: TimeUnit::Minute,
100/// direction: Direction::Future,
101/// };
102///
103/// // "3 days ago"
104/// let past = RelativeTime {
105/// amount: 3,
106/// unit: TimeUnit::Day,
107/// direction: Direction::Past,
108/// };
109/// ```
110#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
111pub struct RelativeTime {
112 /// The numeric amount (e.g., 5 in "5 minutes")
113 pub amount: i64,
114 /// The time unit (second, minute, hour, etc.)
115 pub unit: TimeUnit,
116 /// Whether this is in the past or future
117 pub direction: Direction,
118}
119
120/// Represents an absolute date and time.
121///
122/// This type can represent various levels of precision, from just a date
123/// to a full timestamp with timezone and nanosecond precision.
124///
125/// # Examples
126///
127/// ```
128/// use temps_core::{AbsoluteTime, Timezone};
129///
130/// // Date only: "2024-01-15"
131/// let date_only = AbsoluteTime {
132/// year: 2024,
133/// month: 1,
134/// day: 15,
135/// hour: None,
136/// minute: None,
137/// second: None,
138/// nanosecond: None,
139/// timezone: None,
140/// };
141///
142/// // Full timestamp: "2024-01-15T14:30:00Z"
143/// let full_timestamp = AbsoluteTime {
144/// year: 2024,
145/// month: 1,
146/// day: 15,
147/// hour: Some(14),
148/// minute: Some(30),
149/// second: Some(0),
150/// nanosecond: None,
151/// timezone: Some(Timezone::Utc),
152/// };
153/// ```
154#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
155pub struct AbsoluteTime {
156 /// The year (e.g., 2024)
157 pub year: u16,
158 /// The month (1-12)
159 pub month: u8,
160 /// The day of month (1-31)
161 pub day: u8,
162 /// The hour (0-23), if specified
163 pub hour: Option<u8>,
164 /// The minute (0-59), if specified
165 pub minute: Option<u8>,
166 /// The second (0-59), if specified
167 pub second: Option<u8>,
168 /// The nanosecond (0-999999999), if specified
169 pub nanosecond: Option<u32>,
170 /// The timezone, if specified
171 pub timezone: Option<Timezone>,
172}
173
174/// Represents a timezone specification.
175///
176/// # Examples
177///
178/// ```
179/// use temps_core::Timezone;
180///
181/// // UTC timezone ("Z")
182/// let utc = Timezone::Utc;
183///
184/// // Offset timezone ("+02:00")
185/// let offset = Timezone::Offset { hours: 2, minutes: 0 };
186///
187/// // Negative offset ("-05:30")
188/// let negative = Timezone::Offset { hours: -5, minutes: 30 };
189/// ```
190#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
191pub enum Timezone {
192 /// UTC timezone (represented as "Z" in ISO format)
193 Utc,
194 /// Timezone offset from UTC
195 Offset {
196 /// Hours offset (-12 to +14)
197 hours: i8,
198 /// Minutes offset (0-59)
199 minutes: u8,
200 },
201}
202
203/// Represents a reference to a specific day.
204///
205/// # Examples
206///
207/// ```
208/// use temps_core::{DayReference, Weekday, WeekdayModifier};
209///
210/// // "today"
211/// let today = DayReference::Today;
212///
213/// // "next Monday"
214/// let next_monday = DayReference::Weekday {
215/// day: Weekday::Monday,
216/// modifier: Some(WeekdayModifier::Next),
217/// };
218///
219/// // "Friday" (upcoming Friday)
220/// let friday = DayReference::Weekday {
221/// day: Weekday::Friday,
222/// modifier: None,
223/// };
224/// ```
225#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
226pub enum DayReference {
227 /// Today's date
228 Today,
229 /// Yesterday's date
230 Yesterday,
231 /// Tomorrow's date
232 Tomorrow,
233 /// A specific weekday
234 Weekday {
235 /// The day of the week
236 day: Weekday,
237 /// Optional modifier (next/last)
238 modifier: Option<WeekdayModifier>,
239 },
240}
241
242/// Represents a time of day.
243///
244/// Can represent both 12-hour (with AM/PM) and 24-hour formats.
245///
246/// # Examples
247///
248/// ```
249/// use temps_core::{Time, Meridiem};
250///
251/// // "3:30 PM"
252/// let afternoon = Time {
253/// hour: 3,
254/// minute: 30,
255/// second: 0,
256/// meridiem: Some(Meridiem::PM),
257/// };
258///
259/// // "14:30" (24-hour format)
260/// let military = Time {
261/// hour: 14,
262/// minute: 30,
263/// second: 0,
264/// meridiem: None,
265/// };
266/// ```
267#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
268pub struct Time {
269 /// Hour (0-23 for 24-hour format, 1-12 for 12-hour format)
270 pub hour: u8,
271 /// Minute (0-59)
272 pub minute: u8,
273 /// Second (0-59)
274 pub second: u8,
275 /// AM/PM indicator for 12-hour format
276 pub meridiem: Option<Meridiem>,
277}
278
279/// Represents a calendar date.
280///
281/// Used for parsing date formats like "15/03/2024" or "31-12-2025".
282///
283/// # Examples
284///
285/// ```
286/// use temps_core::StandardDate;
287///
288/// // "15/03/2024"
289/// let date = StandardDate {
290/// day: 15,
291/// month: 3,
292/// year: 2024,
293/// };
294/// ```
295#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
296pub struct StandardDate {
297 /// Day of month (1-31)
298 pub day: u8,
299 /// Month (1-12)
300 pub month: u8,
301 /// Year (e.g., 2024)
302 pub year: u16,
303}
304
305/// Represents a combination of a day reference and a specific time.
306///
307/// Used for expressions like "tomorrow at 3:30 pm" or "next Monday at 9:00 am".
308///
309/// # Examples
310///
311/// ```
312/// use temps_core::{DayTime, DayReference, Time, Meridiem};
313///
314/// // "tomorrow at 3:30 pm"
315/// let tomorrow_afternoon = DayTime {
316/// day: DayReference::Tomorrow,
317/// time: Time {
318/// hour: 3,
319/// minute: 30,
320/// second: 0,
321/// meridiem: Some(Meridiem::PM),
322/// },
323/// };
324/// ```
325#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
326pub struct DayTime {
327 /// The day reference
328 pub day: DayReference,
329 /// The specific time on that day
330 pub time: Time,
331}
332
333/// Units of time used in relative expressions.
334///
335/// # Examples
336///
337/// ```
338/// use temps_core::TimeUnit;
339///
340/// // Used in expressions like:
341/// // "5 seconds", "10 minutes", "2 hours", "3 days",
342/// // "1 week", "6 months", "2 years"
343/// ```
344#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
345pub enum TimeUnit {
346 Second,
347 Minute,
348 Hour,
349 Day,
350 Week,
351 Month,
352 Year,
353}
354
355/// Direction of time relative to now.
356///
357/// # Examples
358///
359/// ```
360/// use temps_core::Direction;
361///
362/// // "5 minutes ago" -> Direction::Past
363/// // "in 5 minutes" -> Direction::Future
364/// ```
365#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
366pub enum Direction {
367 Past,
368 Future,
369}
370
371/// Days of the week.
372///
373/// Used in expressions like "next Monday" or "last Friday".
374#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
375pub enum Weekday {
376 Monday,
377 Tuesday,
378 Wednesday,
379 Thursday,
380 Friday,
381 Saturday,
382 Sunday,
383}
384
385/// Modifiers for weekday references.
386///
387/// # Examples
388///
389/// ```
390/// use temps_core::WeekdayModifier;
391///
392/// // "last Monday" -> WeekdayModifier::Last
393/// // "next Friday" -> WeekdayModifier::Next
394/// // "Monday" (no modifier) -> finds the next occurrence
395/// ```
396#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
397pub enum WeekdayModifier {
398 Last,
399 Next,
400}
401
402/// AM/PM indicator for 12-hour time format.
403#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
404pub enum Meridiem {
405 AM,
406 PM,
407}
408
409/// Supported languages for parsing time expressions.
410///
411/// # Examples
412///
413/// ```
414/// use temps_core::{parse, Language};
415///
416/// // Parse English
417/// let expr = parse("in 5 minutes", Language::English);
418///
419/// // Parse German
420/// let expr = parse("in 5 Minuten", Language::German);
421/// ```
422#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
423pub enum Language {
424 English,
425 German,
426}
427
428// ===== Traits =====
429
430/// Trait for implementing time parsing with a specific datetime backend.
431///
432/// This trait should be implemented by datetime libraries (chrono, jiff, etc.)
433/// to provide the actual time calculation logic.
434///
435/// # Examples
436///
437/// ```
438/// use temps_core::{TimeParser, TimeExpression, Result};
439///
440/// struct MyTimeParser;
441///
442/// impl TimeParser for MyTimeParser {
443/// type DateTime = String; // Your datetime type
444///
445/// fn now(&self) -> Self::DateTime {
446/// "2024-01-15T14:30:00Z".to_string()
447/// }
448///
449/// fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime> {
450/// // Implementation here
451/// Ok(self.now())
452/// }
453/// }
454/// ```
455pub trait TimeParser {
456 /// The datetime type used by this implementation
457 type DateTime;
458
459 /// Get the current date and time
460 fn now(&self) -> Self::DateTime;
461
462 /// Parse a time expression into a concrete datetime
463 ///
464 /// # Errors
465 ///
466 /// Returns `TempsError` if:
467 /// - Date calculation results in an invalid date
468 /// - Arithmetic overflow occurs
469 /// - The backend library returns an error
470 fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime>;
471}
472
473/// Trait for implementing language-specific parsers.
474///
475/// This trait is implemented by language modules to provide
476/// natural language parsing for different languages.
477///
478/// # Examples
479///
480/// ```
481/// use temps_core::{LanguageParser, TimeExpression, Result};
482///
483/// struct MyLanguageParser;
484///
485/// impl LanguageParser for MyLanguageParser {
486/// fn parse(&self, input: &str) -> Result<TimeExpression> {
487/// // Parse language-specific input
488/// Ok(TimeExpression::Now)
489/// }
490/// }
491/// ```
492pub trait LanguageParser {
493 /// Parse a natural language time expression
494 ///
495 /// # Errors
496 ///
497 /// Returns `TempsError::ParseError` if the input cannot be parsed
498 fn parse(&self, input: &str) -> Result<TimeExpression>;
499}
500
501// ===== Constants Module =====
502
503pub mod constants {
504 //! Common constants used across the temps library
505
506 /// Number of seconds in one hour
507 pub const SECONDS_PER_HOUR: i32 = 3600;
508
509 /// Number of seconds in one minute
510 pub const SECONDS_PER_MINUTE: i32 = 60;
511
512 /// Number of minutes in one hour
513 pub const MINUTES_PER_HOUR: i32 = 60;
514
515 /// Number of hours in one day
516 pub const HOURS_PER_DAY: i32 = 24;
517
518 /// Number of days in one week
519 pub const DAYS_PER_WEEK: i32 = 7;
520
521 /// Number of months in one year
522 pub const MONTHS_PER_YEAR: i32 = 12;
523}
524
525// ===== Errors Module =====
526
527pub mod errors {
528 //! Common error messages and error handling utilities
529
530 /// Error message for when month amount must be positive
531 pub const ERR_MONTH_POSITIVE: &str = "Month amount must be a positive number";
532
533 /// Error message for when year amount must be positive
534 pub const ERR_YEAR_POSITIVE: &str = "Year amount must be a positive number";
535
536 /// Error message for invalid date calculation
537 pub const ERR_DATE_CALC_INVALID: &str = "Date calculation resulted in invalid date";
538
539 /// Error message for year calculation overflow
540 pub const ERR_YEAR_OVERFLOW: &str = "Year calculation overflow";
541
542 /// Error message for invalid date
543 pub const ERR_INVALID_DATE: &str = "Invalid date";
544
545 /// Error message for invalid time
546 pub const ERR_INVALID_TIME: &str = "Invalid time";
547
548 /// Error message for ambiguous local time
549 pub const ERR_AMBIGUOUS_TIME: &str = "Ambiguous or invalid local time";
550
551 /// Error message for failed midnight time creation
552 pub const ERR_MIDNIGHT_FAILED: &str = "Failed to create midnight time";
553
554 /// Error message for date calculation errors
555 pub const ERR_DATE_CALC_ERROR: &str = "Date calculation error";
556
557 /// Error message for timezone conversion errors
558 pub const ERR_TIMEZONE_CONVERSION: &str = "Timezone conversion error";
559
560 /// Error message for negative relative amounts
561 pub const ERR_RELATIVE_AMOUNT_NON_NEGATIVE: &str = "Relative amount must be non-negative";
562
563 /// Format error message for invalid date with components
564 #[must_use]
565 pub fn format_invalid_date(year: u16, month: u8, day: u8) -> String {
566 format!("Invalid date: {year}-{month}-{day}")
567 }
568
569 /// Format error message for invalid time with components
570 #[must_use]
571 pub fn format_invalid_time(hour: u8, minute: u8, second: u8) -> String {
572 format!("Invalid time: {hour}:{minute}:{second}")
573 }
574
575 /// Format error message for invalid timezone offset
576 #[must_use]
577 pub fn format_invalid_timezone_offset(hours: i8, minutes: u8) -> String {
578 format!("Invalid timezone offset: {hours}:{minutes}")
579 }
580}
581
582// ===== Time Utils Module =====
583
584pub mod time_utils {
585 //! Time conversion and calculation utilities
586
587 use crate::{
588 Meridiem, Timezone, WeekdayModifier,
589 constants::{SECONDS_PER_HOUR, SECONDS_PER_MINUTE},
590 };
591
592 /// Convert 12-hour time format to 24-hour format
593 ///
594 /// # Examples
595 /// ```
596 /// use temps_core::{Meridiem, time_utils::convert_12_to_24_hour};
597 ///
598 /// assert_eq!(convert_12_to_24_hour(12, Some(&Meridiem::AM)), 0); // 12 AM -> 0
599 /// assert_eq!(convert_12_to_24_hour(12, Some(&Meridiem::PM)), 12); // 12 PM -> 12
600 /// assert_eq!(convert_12_to_24_hour(3, Some(&Meridiem::PM)), 15); // 3 PM -> 15
601 /// assert_eq!(convert_12_to_24_hour(14, None), 14); // 24-hour format
602 /// ```
603 #[must_use]
604 pub fn convert_12_to_24_hour(hour: u8, meridiem: Option<&Meridiem>) -> u8 {
605 match meridiem {
606 Some(Meridiem::AM) => {
607 if hour == 12 {
608 0
609 } else {
610 hour
611 }
612 }
613 Some(Meridiem::PM) => {
614 if hour == 12 {
615 hour
616 } else {
617 hour + 12
618 }
619 }
620 None => hour,
621 }
622 }
623
624 /// Calculate total seconds for a timezone offset
625 ///
626 /// Uses saturating arithmetic to prevent overflow
627 #[must_use]
628 pub fn calculate_timezone_offset_seconds(hours: i8, minutes: u8) -> i32 {
629 let hour_seconds = i32::from(hours).saturating_mul(SECONDS_PER_HOUR);
630 let minute_seconds = i32::from(minutes).saturating_mul(SECONDS_PER_MINUTE);
631 let minute_seconds = if hours < 0 {
632 -minute_seconds
633 } else {
634 minute_seconds
635 };
636
637 hour_seconds.saturating_add(minute_seconds)
638 }
639
640 /// Check whether the date components form a real calendar date.
641 #[must_use]
642 pub fn is_valid_calendar_date(year: u16, month: u8, day: u8) -> bool {
643 let days_in_month = match month {
644 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
645 4 | 6 | 9 | 11 => 30,
646 2 if is_leap_year(year) => 29,
647 2 => 28,
648 _ => return false,
649 };
650
651 (1..=days_in_month).contains(&day)
652 }
653
654 /// Check whether the time components form a valid 24-hour clock time.
655 #[must_use]
656 pub fn is_valid_24_hour_time(hour: u8, minute: u8, second: u8) -> bool {
657 hour <= 23 && minute <= 59 && second <= 59
658 }
659
660 /// Check whether time components are valid for either 24-hour or AM/PM notation.
661 #[must_use]
662 pub fn is_valid_time(hour: u8, minute: u8, second: u8, meridiem: Option<Meridiem>) -> bool {
663 match meridiem {
664 Some(_) => (1..=12).contains(&hour) && minute <= 59 && second <= 59,
665 None => is_valid_24_hour_time(hour, minute, second),
666 }
667 }
668
669 /// Check whether a timezone offset is in the supported UTC-12:00..=UTC+14:00 range.
670 #[must_use]
671 pub fn is_valid_timezone_offset(offset: Timezone) -> bool {
672 match offset {
673 Timezone::Utc => true,
674 Timezone::Offset { hours, minutes } => {
675 minutes <= 59
676 && match hours {
677 -12 => minutes == 0,
678 -11..=13 => true,
679 14 => minutes == 0,
680 _ => false,
681 }
682 }
683 }
684 }
685
686 /// Calculate the day offset for weekday calculations
687 ///
688 /// Returns the number of days to add/subtract to reach the target weekday
689 ///
690 /// # Arguments
691 /// * `current_day_offset` - Current weekday as offset from Monday (0-6)
692 /// * `target_day_offset` - Target weekday as offset from Monday (0-6)
693 /// * `modifier` - Whether to get next, last, or closest occurrence
694 #[must_use]
695 pub fn calculate_weekday_offset(
696 current_day_offset: i64,
697 target_day_offset: i64,
698 modifier: Option<WeekdayModifier>,
699 ) -> i64 {
700 let days_diff = target_day_offset - current_day_offset;
701
702 match modifier {
703 None => {
704 // Get the next occurrence (including today if it matches)
705 if days_diff >= 0 {
706 days_diff
707 } else {
708 7 + days_diff
709 }
710 }
711 Some(WeekdayModifier::Next) => {
712 // Next occurrence (not including today)
713 if days_diff > 0 {
714 days_diff
715 } else {
716 7 + days_diff
717 }
718 }
719 Some(WeekdayModifier::Last) => {
720 // Previous occurrence (not including today)
721 if days_diff < 0 {
722 days_diff
723 } else {
724 days_diff - 7
725 }
726 }
727 }
728 }
729
730 #[must_use]
731 fn is_leap_year(year: u16) -> bool {
732 year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
733 }
734}
735
736// ===== Common Parsing Module =====
737
738/// Common parsing utilities shared across language implementations.
739///
740/// This module contains parser building blocks that are shared between
741/// different language implementations, such as ISO datetime parsing,
742/// number parsing, and the case-insensitive keyword helper.
743pub mod common {
744 use super::{AbsoluteTime, TimeExpression, Timezone, time_utils};
745 use chumsky::{error::Rich, extra, prelude::*, text};
746
747 /// The error type used throughout the parsers.
748 pub type ParserError<'a> = extra::Err<Rich<'a, char>>;
749
750 /// Match an ASCII keyword case-insensitively.
751 ///
752 /// Used for English keywords ("now", "ago", "in") and German
753 /// abbreviations ("sek", "min", "uhr"). Non-ASCII characters in
754 /// `target` are compared exactly. The parser is internally a chain
755 /// of single-character matchers so error messages mention the
756 /// keyword's first expected character; the whole branch is then
757 /// labelled with `target` so callers see "expected `now`" etc.
758 pub fn keyword_ci<'a>(
759 target: &'static str,
760 ) -> impl Parser<'a, &'a str, (), ParserError<'a>> + Clone {
761 let mut chars = target.chars();
762 let first = chars.next().expect("keyword must be non-empty");
763 let mut parser: chumsky::Boxed<'a, 'a, &'a str, (), ParserError<'a>> =
764 char_ci(first).ignored().boxed();
765 for c in chars {
766 parser = parser.then_ignore(char_ci(c)).boxed();
767 }
768 parser.labelled(target)
769 }
770
771 fn char_ci<'a>(target: char) -> chumsky::Boxed<'a, 'a, &'a str, char, ParserError<'a>> {
772 let lower = target.to_ascii_lowercase();
773 let upper = target.to_ascii_uppercase();
774 if lower == upper {
775 just(target).boxed()
776 } else {
777 one_of([lower, upper]).boxed()
778 }
779 }
780
781 fn ascii_digit<'a>() -> impl Parser<'a, &'a str, char, ParserError<'a>> + Clone {
782 one_of('0'..='9').labelled("digit")
783 }
784
785 /// Parse a sequence of digits as an `i64`.
786 pub fn digit_number<'a>() -> impl Parser<'a, &'a str, i64, ParserError<'a>> + Clone {
787 ascii_digit()
788 .repeated()
789 .at_least(1)
790 .to_slice()
791 .try_map(|s: &str, span| {
792 s.parse::<i64>()
793 .map_err(|e| Rich::custom(span, e.to_string()))
794 })
795 .labelled("number")
796 }
797
798 /// Parse a 1 or 2 digit number as `u8`.
799 pub fn two_digit_number<'a>() -> impl Parser<'a, &'a str, u8, ParserError<'a>> + Clone {
800 ascii_digit()
801 .repeated()
802 .at_least(1)
803 .at_most(2)
804 .to_slice()
805 .try_map(|s: &str, span| {
806 s.parse::<u8>()
807 .map_err(|e| Rich::custom(span, e.to_string()))
808 })
809 }
810
811 /// Parse a 4-digit number as `u16`.
812 pub fn four_digit_number<'a>() -> impl Parser<'a, &'a str, u16, ParserError<'a>> + Clone {
813 ascii_digit()
814 .repeated()
815 .exactly(4)
816 .to_slice()
817 .try_map(|s: &str, span| {
818 s.parse::<u16>()
819 .map_err(|e| Rich::custom(span, e.to_string()))
820 })
821 .labelled("4-digit year")
822 }
823
824 fn offset_timezone<'a>() -> impl Parser<'a, &'a str, Timezone, ParserError<'a>> + Clone {
825 one_of(['+', '-'])
826 .then(two_digit_number())
827 .then(just(':').ignore_then(two_digit_number()).or_not())
828 .try_map(|((sign, hours), minutes), span| {
829 let minutes = minutes.unwrap_or(0);
830 let hours = i8::try_from(hours)
831 .map_err(|_| Rich::custom(span, "timezone hour offset out of range"))?;
832 let signed_hours = if sign == '+' { hours } else { -hours };
833 let offset = Timezone::Offset {
834 hours: signed_hours,
835 minutes,
836 };
837
838 let can_represent = !(sign == '-' && hours == 0 && minutes > 0);
839 if can_represent && time_utils::is_valid_timezone_offset(offset) {
840 Ok(offset)
841 } else {
842 Err(Rich::custom(span, "invalid timezone offset"))
843 }
844 })
845 }
846
847 fn timezone<'a>() -> impl Parser<'a, &'a str, Timezone, ParserError<'a>> + Clone {
848 choice((just('Z').to(Timezone::Utc), offset_timezone()))
849 }
850
851 fn fractional_seconds<'a>() -> impl Parser<'a, &'a str, u32, ParserError<'a>> + Clone {
852 text::digits(10).to_slice().try_map(|s: &str, span| {
853 let fraction = if s.len() > 9 { &s[..9] } else { s };
854 let parsed: u32 = fraction
855 .parse()
856 .map_err(|e: std::num::ParseIntError| Rich::custom(span, e.to_string()))?;
857 let fraction_len =
858 u32::try_from(fraction.len()).expect("fraction length is capped at 9 digits");
859 Ok(parsed * 10_u32.pow(9 - fraction_len))
860 })
861 }
862
863 /// Parse ISO 8601 datetime format.
864 ///
865 /// Supports:
866 /// - Date only: `2024-01-15`
867 /// - Date and time: `2024-01-15T14:30:00`
868 /// - With timezone: `2024-01-15T14:30:00Z`
869 /// - With offset: `2024-01-15T14:30:00+02:00`
870 /// - With fractional seconds: `2024-01-15T14:30:00.123Z`
871 pub fn iso_datetime<'a>() -> impl Parser<'a, &'a str, TimeExpression, ParserError<'a>> + Clone {
872 let date = four_digit_number()
873 .then_ignore(just('-'))
874 .then(two_digit_number())
875 .then_ignore(just('-'))
876 .then(two_digit_number())
877 .try_map(|((year, month), day), span| {
878 if time_utils::is_valid_calendar_date(year, month, day) {
879 Ok((year, month, day))
880 } else {
881 Err(Rich::custom(span, "invalid calendar date"))
882 }
883 });
884
885 let time = one_of(['T', ' '])
886 .ignore_then(two_digit_number())
887 .then_ignore(just(':'))
888 .then(two_digit_number())
889 .then(
890 just(':')
891 .ignore_then(two_digit_number())
892 .then(just('.').ignore_then(fractional_seconds()).or_not())
893 .or_not(),
894 )
895 .then(timezone().or_not())
896 .try_map(|(((hour, minute), sec_part), tz), span| {
897 let second = sec_part.as_ref().map_or(0, |(s, _)| *s);
898 if time_utils::is_valid_24_hour_time(hour, minute, second) {
899 Ok((hour, minute, sec_part, tz))
900 } else {
901 Err(Rich::custom(span, "invalid time"))
902 }
903 });
904
905 date.then(time.or_not())
906 .map(|((year, month, day), time_opt)| match time_opt {
907 Some((h, m, sec_part, tz)) => {
908 let (second, nanosecond) = match sec_part {
909 Some((s, frac)) => (Some(s), frac),
910 None => (None, None),
911 };
912 TimeExpression::Absolute(AbsoluteTime {
913 year,
914 month,
915 day,
916 hour: Some(h),
917 minute: Some(m),
918 second,
919 nanosecond,
920 timezone: tz,
921 })
922 }
923 None => TimeExpression::Absolute(AbsoluteTime {
924 year,
925 month,
926 day,
927 hour: None,
928 minute: None,
929 second: None,
930 nanosecond: None,
931 timezone: None,
932 }),
933 })
934 }
935}
936
937// ===== Language Support =====
938
939/// Language-specific parser implementations.
940///
941/// Each submodule contains a parser for a specific language.
942/// All parsers implement the `LanguageParser` trait.
943pub mod language {
944 /// English language parser.
945 ///
946 /// Supports expressions like:
947 /// - "in 5 minutes", "3 days ago"
948 /// - "tomorrow at 3:30 pm"
949 /// - "next Monday", "last Friday"
950 pub mod english;
951
952 /// German language parser.
953 ///
954 /// Supports expressions like:
955 /// - "in 5 Minuten", "vor 3 Tagen"
956 /// - "morgen um 15:30"
957 /// - "nächsten Montag", "letzten Freitag"
958 pub mod german;
959}
960
961// ===== Main Parsing Function =====
962
963/// Parse a natural language time expression.
964///
965/// This is the main entry point for parsing time expressions. It takes
966/// a string input and a language, and returns a parsed `TimeExpression`.
967///
968/// # Arguments
969///
970/// * `input` - The natural language time expression to parse
971/// * `language` - The language to use for parsing
972///
973/// # Returns
974///
975/// Returns `Ok(TimeExpression)` if parsing succeeds, or `Err(TempsError)`
976/// if the input cannot be parsed.
977///
978/// # Examples
979///
980/// ```
981/// use temps_core::{parse, Language, TimeExpression};
982///
983/// // Parse English expressions
984/// let expr = parse("in 5 minutes", Language::English).unwrap();
985/// let expr = parse("tomorrow at 3:30 pm", Language::English).unwrap();
986/// let expr = parse("next Monday", Language::English).unwrap();
987///
988/// // Parse German expressions
989/// let expr = parse("in 5 Minuten", Language::German).unwrap();
990/// let expr = parse("morgen um 15:30", Language::German).unwrap();
991/// let expr = parse("nächsten Montag", Language::German).unwrap();
992///
993/// // Parse ISO datetime (works in any language)
994/// let expr = parse("2024-01-15T14:30:00Z", Language::English).unwrap();
995/// ```
996///
997/// # Supported Formats
998///
999/// ## Relative Time
1000/// - "in 5 minutes", "5 minutes ago"
1001/// - "in 2 hours", "an hour ago"
1002/// - "in 3 days", "2 days ago"
1003/// - "in a week", "2 weeks ago"
1004/// - "in 6 months", "a month ago"
1005/// - "in 2 years", "a year ago"
1006///
1007/// ## Day References
1008/// - "today", "yesterday", "tomorrow"
1009/// - "Monday", "Tuesday", etc.
1010/// - "next Monday", "last Friday"
1011///
1012/// ## Times
1013/// - "3:30 pm", "10:15 am"
1014/// - "14:30", "09:00"
1015///
1016/// ## Dates
1017/// - "15/03/2024", "31-12-2025"
1018///
1019/// ## Combined
1020/// - "tomorrow at 3:30 pm"
1021/// - "next Monday at 9:00 am"
1022///
1023/// ## ISO Format
1024/// - "2024-01-15T14:30:00Z"
1025/// - "2024-01-15T14:30:00+02:00"
1026/// - "2024-01-15T14:30:00.123Z"
1027pub fn parse(input: &str, language: Language) -> Result<TimeExpression> {
1028 match language {
1029 Language::English => language::english::EnglishParser.parse(input),
1030 Language::German => language::german::GermanParser.parse(input),
1031 }
1032}