temps_core/
lib.rs

1use winnow::{
2    ascii::digit1,
3    combinator::{alt, opt},
4    error::ContextError,
5    prelude::*,
6    token::{one_of, take_while},
7};
8
9// ===== Core Types =====
10
11#[derive(Debug, PartialEq, Clone)]
12pub enum TimeExpression {
13    Now,
14    Relative(RelativeTime),
15    Absolute(AbsoluteTime),
16    Day(DayReference),
17    Time(Time),
18    Date(StandardDate),
19    DayTime(DayTime),
20}
21
22#[derive(Debug, PartialEq, Clone)]
23pub struct RelativeTime {
24    pub amount: i64,
25    pub unit: TimeUnit,
26    pub direction: Direction,
27}
28
29#[derive(Debug, PartialEq, Clone)]
30pub struct AbsoluteTime {
31    pub year: u16,
32    pub month: u8,
33    pub day: u8,
34    pub hour: Option<u8>,
35    pub minute: Option<u8>,
36    pub second: Option<u8>,
37    pub nanosecond: Option<u32>,
38    pub timezone: Option<Timezone>,
39}
40
41#[derive(Debug, PartialEq, Clone)]
42pub enum Timezone {
43    Utc,
44    Offset { hours: i8, minutes: u8 },
45}
46
47#[derive(Debug, PartialEq, Clone)]
48pub enum DayReference {
49    Today,
50    Yesterday,
51    Tomorrow,
52    Weekday {
53        day: Weekday,
54        modifier: Option<WeekdayModifier>,
55    },
56}
57
58#[derive(Debug, PartialEq, Clone)]
59pub struct Time {
60    pub hour: u8,
61    pub minute: u8,
62    pub second: u8,
63    pub meridiem: Option<Meridiem>,
64}
65
66#[derive(Debug, PartialEq, Clone)]
67pub struct StandardDate {
68    pub day: u8,
69    pub month: u8,
70    pub year: u16,
71}
72
73#[derive(Debug, PartialEq, Clone)]
74pub struct DayTime {
75    pub day: DayReference,
76    pub time: Time,
77}
78
79#[derive(Debug, PartialEq, Clone, Copy)]
80pub enum TimeUnit {
81    Second,
82    Minute,
83    Hour,
84    Day,
85    Week,
86    Month,
87    Year,
88}
89
90#[derive(Debug, PartialEq, Clone, Copy)]
91pub enum Direction {
92    Past,
93    Future,
94}
95
96#[derive(Debug, PartialEq, Clone, Copy)]
97pub enum Weekday {
98    Monday,
99    Tuesday,
100    Wednesday,
101    Thursday,
102    Friday,
103    Saturday,
104    Sunday,
105}
106
107#[derive(Debug, PartialEq, Clone, Copy)]
108pub enum WeekdayModifier {
109    Last,
110    Next,
111}
112
113#[derive(Debug, PartialEq, Clone, Copy)]
114pub enum Meridiem {
115    AM,
116    PM,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum Language {
121    English,
122    German,
123}
124
125// ===== Traits =====
126
127pub trait TimeParser {
128    type DateTime;
129    type Error;
130
131    fn now(&self) -> Self::DateTime;
132    fn parse_expression(&self, expr: TimeExpression) -> Result<Self::DateTime, Self::Error>;
133}
134
135pub trait LanguageParser {
136    fn parse<'a>(
137        &self,
138        input: &'a str,
139    ) -> Result<TimeExpression, winnow::error::ParseError<&'a str, ContextError>>;
140}
141
142// ===== Constants Module =====
143
144pub mod constants {
145    //! Common constants used across the temps library
146
147    /// Number of seconds in one hour
148    pub const SECONDS_PER_HOUR: i32 = 3600;
149
150    /// Number of seconds in one minute  
151    pub const SECONDS_PER_MINUTE: i32 = 60;
152
153    /// Number of minutes in one hour
154    pub const MINUTES_PER_HOUR: i32 = 60;
155
156    /// Number of hours in one day
157    pub const HOURS_PER_DAY: i32 = 24;
158
159    /// Number of days in one week
160    pub const DAYS_PER_WEEK: i32 = 7;
161
162    /// Number of months in one year
163    pub const MONTHS_PER_YEAR: i32 = 12;
164}
165
166// ===== Errors Module =====
167
168pub mod errors {
169    //! Common error messages and error handling utilities
170
171    /// Error message for when month amount must be positive
172    pub const ERR_MONTH_POSITIVE: &str = "Month amount must be a positive number";
173
174    /// Error message for when year amount must be positive
175    pub const ERR_YEAR_POSITIVE: &str = "Year amount must be a positive number";
176
177    /// Error message for invalid date calculation
178    pub const ERR_DATE_CALC_INVALID: &str = "Date calculation resulted in invalid date";
179
180    /// Error message for year calculation overflow
181    pub const ERR_YEAR_OVERFLOW: &str = "Year calculation overflow";
182
183    /// Error message for invalid date
184    pub const ERR_INVALID_DATE: &str = "Invalid date";
185
186    /// Error message for invalid time
187    pub const ERR_INVALID_TIME: &str = "Invalid time";
188
189    /// Error message for ambiguous local time
190    pub const ERR_AMBIGUOUS_TIME: &str = "Ambiguous or invalid local time";
191
192    /// Error message for failed midnight time creation
193    pub const ERR_MIDNIGHT_FAILED: &str = "Failed to create midnight time";
194
195    /// Error message for date calculation errors
196    pub const ERR_DATE_CALC_ERROR: &str = "Date calculation error";
197
198    /// Error message for timezone conversion errors
199    pub const ERR_TIMEZONE_CONVERSION: &str = "Timezone conversion error";
200
201    /// Format error message for invalid date with components
202    pub fn format_invalid_date(year: u16, month: u8, day: u8) -> String {
203        format!("Invalid date: {}-{}-{}", year, month, day)
204    }
205
206    /// Format error message for invalid time with components
207    pub fn format_invalid_time(hour: u8, minute: u8, second: u8) -> String {
208        format!("Invalid time: {}:{}:{}", hour, minute, second)
209    }
210
211    /// Format error message for invalid timezone offset
212    pub fn format_invalid_timezone_offset(hours: i8, minutes: u8) -> String {
213        format!("Invalid timezone offset: {}:{}", hours, minutes)
214    }
215}
216
217// ===== Time Utils Module =====
218
219pub mod time_utils {
220    //! Time conversion and calculation utilities
221
222    use crate::{
223        Meridiem, WeekdayModifier,
224        constants::{SECONDS_PER_HOUR, SECONDS_PER_MINUTE},
225    };
226
227    /// Convert 12-hour time format to 24-hour format
228    ///
229    /// # Examples
230    /// ```
231    /// use temps_core::{Meridiem, time_utils::convert_12_to_24_hour};
232    ///
233    /// assert_eq!(convert_12_to_24_hour(12, Some(&Meridiem::AM)), 0);  // 12 AM -> 0
234    /// assert_eq!(convert_12_to_24_hour(12, Some(&Meridiem::PM)), 12); // 12 PM -> 12
235    /// assert_eq!(convert_12_to_24_hour(3, Some(&Meridiem::PM)), 15);  // 3 PM -> 15
236    /// assert_eq!(convert_12_to_24_hour(14, None), 14);                // 24-hour format
237    /// ```
238    pub fn convert_12_to_24_hour(hour: u8, meridiem: Option<&Meridiem>) -> u8 {
239        match meridiem {
240            Some(Meridiem::AM) => {
241                if hour == 12 {
242                    0
243                } else {
244                    hour
245                }
246            }
247            Some(Meridiem::PM) => {
248                if hour == 12 {
249                    hour
250                } else {
251                    hour + 12
252                }
253            }
254            None => hour,
255        }
256    }
257
258    /// Calculate total seconds for a timezone offset
259    ///
260    /// Uses saturating arithmetic to prevent overflow
261    pub fn calculate_timezone_offset_seconds(hours: i8, minutes: u8) -> i32 {
262        let hour_seconds = (hours as i32).saturating_mul(SECONDS_PER_HOUR);
263        let minute_seconds = (minutes as i32).saturating_mul(SECONDS_PER_MINUTE);
264        hour_seconds.saturating_add(minute_seconds)
265    }
266
267    /// Calculate the day offset for weekday calculations
268    ///
269    /// Returns the number of days to add/subtract to reach the target weekday
270    ///
271    /// # Arguments
272    /// * `current_day_offset` - Current weekday as offset from Monday (0-6)
273    /// * `target_day_offset` - Target weekday as offset from Monday (0-6)
274    /// * `modifier` - Whether to get next, last, or closest occurrence
275    pub fn calculate_weekday_offset(
276        current_day_offset: i64,
277        target_day_offset: i64,
278        modifier: Option<WeekdayModifier>,
279    ) -> i64 {
280        let days_diff = target_day_offset - current_day_offset;
281
282        match modifier {
283            None => {
284                // Get the next occurrence (including today if it matches)
285                if days_diff >= 0 {
286                    days_diff
287                } else {
288                    7 + days_diff
289                }
290            }
291            Some(WeekdayModifier::Next) => {
292                // Next occurrence (not including today)
293                if days_diff > 0 {
294                    days_diff
295                } else {
296                    7 + days_diff
297                }
298            }
299            Some(WeekdayModifier::Last) => {
300                // Previous occurrence (not including today)
301                if days_diff < 0 {
302                    days_diff
303                } else {
304                    days_diff - 7
305                }
306            }
307        }
308    }
309}
310
311// ===== Common Parsing Module =====
312
313pub mod common {
314    use super::*;
315
316    /// Parse digits as i64
317    pub fn parse_digit_number(input: &mut &str) -> winnow::Result<i64> {
318        digit1.try_map(|s: &str| s.parse::<i64>()).parse_next(input)
319    }
320
321    /// Parse ISO datetime format that's common across languages
322    pub fn parse_iso_datetime(input: &mut &str) -> winnow::Result<TimeExpression> {
323        // Parse date components
324        let year = parse_four_digit_number.parse_next(input)?;
325        '-'.parse_next(input)?;
326        let month = parse_two_digit_number.parse_next(input)?;
327        '-'.parse_next(input)?;
328        let day = parse_two_digit_number.parse_next(input)?;
329
330        // Parse optional time components
331        let time_part = opt((
332            one_of(['T', ' ']),
333            parse_two_digit_number, // hour
334            ':',
335            parse_two_digit_number, // minute
336            opt((
337                ':',
338                parse_two_digit_number, // second
339                opt((
340                    '.',
341                    digit1.try_map(|s: &str| {
342                        // Convert fractional seconds to nanoseconds
343                        let fraction = if s.len() > 9 { &s[..9] } else { s };
344
345                        // Parse the fraction and multiply by appropriate power of 10
346                        let parsed = fraction.parse::<u32>()?;
347                        let multiplier = 10_u32.pow(9 - fraction.len() as u32);
348                        Ok::<u32, std::num::ParseIntError>(parsed * multiplier)
349                    }),
350                )),
351            )),
352            opt(parse_timezone),
353        ))
354        .parse_next(input)?;
355
356        let (hour, minute, second, nanosecond, timezone) =
357            if let Some((_, h, _, m, sec_part, tz)) = time_part {
358                // We have time components
359                let hour = Some(h);
360                let minute = Some(m);
361
362                // Extract seconds and fractional seconds if present
363                let (second, nanosecond) = if let Some((_, s, frac)) = sec_part {
364                    (Some(s), frac.map(|(_, n)| n))
365                } else {
366                    (None, None)
367                };
368
369                (hour, minute, second, nanosecond, tz)
370            } else {
371                // Date only, no time components
372                (None, None, None, None, None)
373            };
374
375        Ok(TimeExpression::Absolute(AbsoluteTime {
376            year,
377            month,
378            day,
379            hour,
380            minute,
381            second,
382            nanosecond,
383            timezone,
384        }))
385    }
386
387    /// Parse timezone (Z or offset)
388    fn parse_timezone(input: &mut &str) -> winnow::Result<Timezone> {
389        alt(("Z".map(|_| Timezone::Utc), parse_offset_timezone)).parse_next(input)
390    }
391
392    /// Parse timezone offset (+/-HH:MM)
393    fn parse_offset_timezone(input: &mut &str) -> winnow::Result<Timezone> {
394        let sign = one_of(['+', '-']).parse_next(input)?;
395        let hours = parse_two_digit_number.parse_next(input)?;
396        let minutes = opt((':', parse_two_digit_number))
397            .parse_next(input)?
398            .map(|(_, m)| m)
399            .unwrap_or(0);
400
401        let hours = if sign == '+' {
402            hours as i8
403        } else {
404            -(hours as i8)
405        };
406
407        Ok(Timezone::Offset { hours, minutes })
408    }
409
410    /// Parse two digit number as u8
411    pub fn parse_two_digit_number(input: &mut &str) -> winnow::Result<u8> {
412        take_while(1..=2, |c: char| c.is_ascii_digit())
413            .try_map(|s: &str| s.parse::<u8>())
414            .parse_next(input)
415    }
416
417    /// Parse four digit number as u16
418    pub fn parse_four_digit_number(input: &mut &str) -> winnow::Result<u16> {
419        take_while(4..=4, |c: char| c.is_ascii_digit())
420            .try_map(|s: &str| s.parse::<u16>())
421            .parse_next(input)
422    }
423}
424
425// ===== Language Support =====
426
427pub mod language {
428    pub mod english;
429    pub mod german;
430}
431
432// ===== Main Parsing Function =====
433
434pub fn parse(
435    input: &str,
436    language: Language,
437) -> Result<TimeExpression, winnow::error::ParseError<&str, winnow::error::ContextError>> {
438    match language {
439        Language::English => language::english::EnglishParser.parse(input),
440        Language::German => language::german::GermanParser.parse(input),
441    }
442}