Skip to main content

mps/
time_parse.rs

1use crate::error::MpsError;
2use chrono::NaiveTime;
3use regex::Regex;
4use std::sync::OnceLock;
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 {
75        NaiveTime::from_hms_opt(h, m, 0).unwrap()
76    }
77
78    #[test]
79    fn test_noon() {
80        assert_eq!(parse_time("noon").unwrap(), hm(12, 0));
81    }
82    #[test]
83    fn test_noon_upper() {
84        assert_eq!(parse_time("NOON").unwrap(), hm(12, 0));
85    }
86    #[test]
87    fn test_midnight() {
88        assert_eq!(parse_time("midnight").unwrap(), hm(0, 0));
89    }
90    #[test]
91    fn test_9am() {
92        assert_eq!(parse_time("9am").unwrap(), hm(9, 0));
93    }
94    #[test]
95    fn test_9_30am() {
96        assert_eq!(parse_time("9:30am").unwrap(), hm(9, 30));
97    }
98    #[test]
99    fn test_3pm() {
100        assert_eq!(parse_time("3pm").unwrap(), hm(15, 0));
101    }
102    #[test]
103    fn test_3_45pm() {
104        assert_eq!(parse_time("3:45pm").unwrap(), hm(15, 45));
105    }
106    #[test]
107    fn test_12am_midnight() {
108        assert_eq!(parse_time("12am").unwrap(), hm(0, 0));
109    }
110    #[test]
111    fn test_12pm_noon() {
112        assert_eq!(parse_time("12pm").unwrap(), hm(12, 0));
113    }
114    #[test]
115    fn test_5pm() {
116        assert_eq!(parse_time("5pm").unwrap(), hm(17, 0));
117    }
118    #[test]
119    fn test_24h_colon() {
120        assert_eq!(parse_time("17:00").unwrap(), hm(17, 0));
121    }
122    #[test]
123    fn test_24h_930() {
124        assert_eq!(parse_time("9:30").unwrap(), hm(9, 30));
125    }
126    #[test]
127    fn test_24h_0000() {
128        assert_eq!(parse_time("00:00").unwrap(), hm(0, 0));
129    }
130    #[test]
131    fn test_with_spaces() {
132        assert_eq!(parse_time("  5pm  ").unwrap(), hm(17, 0));
133    }
134    #[test]
135    fn test_am_uppercase() {
136        assert_eq!(parse_time("9AM").unwrap(), hm(9, 0));
137    }
138    #[test]
139    fn test_pm_uppercase() {
140        assert_eq!(parse_time("3PM").unwrap(), hm(15, 0));
141    }
142
143    #[test]
144    fn test_reject_empty() {
145        assert!(parse_time("").is_err());
146    }
147    #[test]
148    fn test_reject_garbage() {
149        assert!(parse_time("not-a-time").is_err());
150    }
151    #[test]
152    fn test_reject_bare_num() {
153        assert!(parse_time("930").is_err());
154    }
155    #[test]
156    fn test_reject_bad_hour() {
157        assert!(parse_time("25:00").is_err());
158    }
159    #[test]
160    fn test_reject_bad_min() {
161        assert!(parse_time("9:99pm").is_err());
162    }
163    // 13pm → h24 = 25 → invalid
164    #[test]
165    fn test_reject_13pm() {
166        assert!(parse_time("13pm").is_err());
167    }
168    // 60 minutes → invalid
169    #[test]
170    fn test_reject_bad_24h_min() {
171        assert!(parse_time("9:60").is_err());
172    }
173    // Trailing garbage must not match.
174    #[test]
175    fn test_reject_trailing_chars() {
176        assert!(parse_time("5pmX").is_err());
177    }
178    // 11:59pm → 23:59
179    #[test]
180    fn test_1159pm() {
181        assert_eq!(parse_time("11:59pm").unwrap(), hm(23, 59));
182    }
183    // "1am" is 01:00
184    #[test]
185    fn test_1am() {
186        assert_eq!(parse_time("1am").unwrap(), hm(1, 0));
187    }
188}