Skip to main content

mps/
time_parse.rs

1use std::sync::OnceLock;
2use chrono::NaiveTime;
3use regex::Regex;
4use crate::error::MpsError;
5
6fn re_word() -> &'static Regex {
7    static RE: OnceLock<Regex> = OnceLock::new();
8    RE.get_or_init(|| Regex::new(r"(?i)^(noon|midnight)$").unwrap())
9}
10
11fn re_12h() -> &'static Regex {
12    static RE: OnceLock<Regex> = OnceLock::new();
13    RE.get_or_init(|| Regex::new(r"(?i)^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$").unwrap())
14}
15
16fn re_24h() -> &'static Regex {
17    static RE: OnceLock<Regex> = OnceLock::new();
18    RE.get_or_init(|| Regex::new(r"^(\d{1,2}):(\d{2})$").unwrap())
19}
20
21/// Parse a human time string into a NaiveTime.
22///
23/// Accepted formats (case-insensitive):
24/// - "noon"             → 12:00:00
25/// - "midnight"         → 00:00:00
26/// - "9am", "9:30am"   → 09:00:00 / 09:30:00
27/// - "3pm", "3:45pm"   → 15:00:00 / 15:45:00
28/// - "12am"            → 00:00:00 (midnight)
29/// - "12pm"            → 12:00:00 (noon)
30/// - "17:00", "9:30"   → 17:00:00 / 09:30:00  (24-hour, colon required)
31pub fn parse_time(input: &str) -> Result<NaiveTime, MpsError> {
32    let s = input.trim();
33
34    // "noon" / "midnight"
35    if let Some(cap) = re_word().captures(s) {
36        return match cap[1].to_lowercase().as_str() {
37            "noon"     => Ok(NaiveTime::from_hms_opt(12, 0, 0).unwrap()),
38            "midnight" => Ok(NaiveTime::from_hms_opt(0,  0, 0).unwrap()),
39            _          => unreachable!(),
40        };
41    }
42
43    // 12-hour: "9am", "9:30am", "3pm", "3:45pm"
44    if let Some(cap) = re_12h().captures(s) {
45        let hour: u32   = cap[1].parse().unwrap();
46        let minute: u32 = cap.get(2).map(|m| m.as_str().parse().unwrap()).unwrap_or(0);
47        let ampm        = cap[3].to_lowercase();
48        let h24 = match (ampm.as_str(), hour) {
49            ("am", 12) => 0,
50            ("am", h)  => h,
51            ("pm", 12) => 12,
52            ("pm", h)  => h + 12,
53            _          => unreachable!(),
54        };
55        return NaiveTime::from_hms_opt(h24, minute, 0)
56            .ok_or_else(|| MpsError::TimeParse(input.to_string()));
57    }
58
59    // 24-hour with colon: "17:00", "9:30"
60    if let Some(cap) = re_24h().captures(s) {
61        let hour: u32   = cap[1].parse().unwrap();
62        let minute: u32 = cap[2].parse().unwrap();
63        return NaiveTime::from_hms_opt(hour, minute, 0)
64            .ok_or_else(|| MpsError::TimeParse(input.to_string()));
65    }
66
67    Err(MpsError::TimeParse(input.to_string()))
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    fn hm(h: u32, m: u32) -> NaiveTime { NaiveTime::from_hms_opt(h, m, 0).unwrap() }
75
76    #[test] fn test_noon()           { assert_eq!(parse_time("noon").unwrap(),      hm(12, 0)); }
77    #[test] fn test_noon_upper()     { assert_eq!(parse_time("NOON").unwrap(),      hm(12, 0)); }
78    #[test] fn test_midnight()       { assert_eq!(parse_time("midnight").unwrap(),  hm( 0, 0)); }
79    #[test] fn test_9am()            { assert_eq!(parse_time("9am").unwrap(),       hm( 9, 0)); }
80    #[test] fn test_9_30am()         { assert_eq!(parse_time("9:30am").unwrap(),    hm( 9,30)); }
81    #[test] fn test_3pm()            { assert_eq!(parse_time("3pm").unwrap(),       hm(15, 0)); }
82    #[test] fn test_3_45pm()         { assert_eq!(parse_time("3:45pm").unwrap(),    hm(15,45)); }
83    #[test] fn test_12am_midnight()  { assert_eq!(parse_time("12am").unwrap(),      hm( 0, 0)); }
84    #[test] fn test_12pm_noon()      { assert_eq!(parse_time("12pm").unwrap(),      hm(12, 0)); }
85    #[test] fn test_5pm()            { assert_eq!(parse_time("5pm").unwrap(),       hm(17, 0)); }
86    #[test] fn test_24h_colon()      { assert_eq!(parse_time("17:00").unwrap(),     hm(17, 0)); }
87    #[test] fn test_24h_930()        { assert_eq!(parse_time("9:30").unwrap(),      hm( 9,30)); }
88    #[test] fn test_24h_0000()       { assert_eq!(parse_time("00:00").unwrap(),     hm( 0, 0)); }
89    #[test] fn test_with_spaces()    { assert_eq!(parse_time("  5pm  ").unwrap(),   hm(17, 0)); }
90    #[test] fn test_am_uppercase()   { assert_eq!(parse_time("9AM").unwrap(),       hm( 9, 0)); }
91    #[test] fn test_pm_uppercase()   { assert_eq!(parse_time("3PM").unwrap(),       hm(15, 0)); }
92
93    #[test] fn test_reject_empty()    { assert!(parse_time("").is_err()); }
94    #[test] fn test_reject_garbage()  { assert!(parse_time("not-a-time").is_err()); }
95    #[test] fn test_reject_bare_num() { assert!(parse_time("930").is_err()); }
96    #[test] fn test_reject_bad_hour() { assert!(parse_time("25:00").is_err()); }
97    #[test] fn test_reject_bad_min()  { assert!(parse_time("9:99pm").is_err()); }
98    // 13pm → h24 = 25 → invalid
99    #[test] fn test_reject_13pm()     { assert!(parse_time("13pm").is_err()); }
100    // 60 minutes → invalid
101    #[test] fn test_reject_bad_24h_min() { assert!(parse_time("9:60").is_err()); }
102    // Trailing garbage must not match.
103    #[test] fn test_reject_trailing_chars() { assert!(parse_time("5pmX").is_err()); }
104    // 11:59pm → 23:59
105    #[test] fn test_1159pm() { assert_eq!(parse_time("11:59pm").unwrap(), hm(23, 59)); }
106    // "1am" is 01:00
107    #[test] fn test_1am()    { assert_eq!(parse_time("1am").unwrap(), hm(1, 0)); }
108}