mps-rs 1.6.0

MPS — plain-text personal productivity CLI (Rust)
Documentation
use std::sync::OnceLock;
use chrono::NaiveTime;
use regex::Regex;
use crate::error::MpsError;

fn re_word() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)^(noon|midnight)$").unwrap())
}

fn re_12h() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"(?i)^(\d{1,2})(?::(\d{2}))?\s*(am|pm)$").unwrap())
}

fn re_24h() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| Regex::new(r"^(\d{1,2}):(\d{2})$").unwrap())
}

/// Parse a human time string into a NaiveTime.
///
/// Accepted formats (case-insensitive):
/// - "noon"             → 12:00:00
/// - "midnight"         → 00:00:00
/// - "9am", "9:30am"   → 09:00:00 / 09:30:00
/// - "3pm", "3:45pm"   → 15:00:00 / 15:45:00
/// - "12am"            → 00:00:00 (midnight)
/// - "12pm"            → 12:00:00 (noon)
/// - "17:00", "9:30"   → 17:00:00 / 09:30:00  (24-hour, colon required)
pub fn parse_time(input: &str) -> Result<NaiveTime, MpsError> {
    let s = input.trim();

    // "noon" / "midnight"
    if let Some(cap) = re_word().captures(s) {
        return match cap[1].to_lowercase().as_str() {
            "noon"     => Ok(NaiveTime::from_hms_opt(12, 0, 0).unwrap()),
            "midnight" => Ok(NaiveTime::from_hms_opt(0,  0, 0).unwrap()),
            _          => unreachable!(),
        };
    }

    // 12-hour: "9am", "9:30am", "3pm", "3:45pm"
    if let Some(cap) = re_12h().captures(s) {
        let hour: u32   = cap[1].parse().unwrap();
        let minute: u32 = cap.get(2).map(|m| m.as_str().parse().unwrap()).unwrap_or(0);
        let ampm        = cap[3].to_lowercase();
        let h24 = match (ampm.as_str(), hour) {
            ("am", 12) => 0,
            ("am", h)  => h,
            ("pm", 12) => 12,
            ("pm", h)  => h + 12,
            _          => unreachable!(),
        };
        return NaiveTime::from_hms_opt(h24, minute, 0)
            .ok_or_else(|| MpsError::TimeParse(input.to_string()));
    }

    // 24-hour with colon: "17:00", "9:30"
    if let Some(cap) = re_24h().captures(s) {
        let hour: u32   = cap[1].parse().unwrap();
        let minute: u32 = cap[2].parse().unwrap();
        return NaiveTime::from_hms_opt(hour, minute, 0)
            .ok_or_else(|| MpsError::TimeParse(input.to_string()));
    }

    Err(MpsError::TimeParse(input.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn hm(h: u32, m: u32) -> NaiveTime { NaiveTime::from_hms_opt(h, m, 0).unwrap() }

    #[test] fn test_noon()           { assert_eq!(parse_time("noon").unwrap(),      hm(12, 0)); }
    #[test] fn test_noon_upper()     { assert_eq!(parse_time("NOON").unwrap(),      hm(12, 0)); }
    #[test] fn test_midnight()       { assert_eq!(parse_time("midnight").unwrap(),  hm( 0, 0)); }
    #[test] fn test_9am()            { assert_eq!(parse_time("9am").unwrap(),       hm( 9, 0)); }
    #[test] fn test_9_30am()         { assert_eq!(parse_time("9:30am").unwrap(),    hm( 9,30)); }
    #[test] fn test_3pm()            { assert_eq!(parse_time("3pm").unwrap(),       hm(15, 0)); }
    #[test] fn test_3_45pm()         { assert_eq!(parse_time("3:45pm").unwrap(),    hm(15,45)); }
    #[test] fn test_12am_midnight()  { assert_eq!(parse_time("12am").unwrap(),      hm( 0, 0)); }
    #[test] fn test_12pm_noon()      { assert_eq!(parse_time("12pm").unwrap(),      hm(12, 0)); }
    #[test] fn test_5pm()            { assert_eq!(parse_time("5pm").unwrap(),       hm(17, 0)); }
    #[test] fn test_24h_colon()      { assert_eq!(parse_time("17:00").unwrap(),     hm(17, 0)); }
    #[test] fn test_24h_930()        { assert_eq!(parse_time("9:30").unwrap(),      hm( 9,30)); }
    #[test] fn test_24h_0000()       { assert_eq!(parse_time("00:00").unwrap(),     hm( 0, 0)); }
    #[test] fn test_with_spaces()    { assert_eq!(parse_time("  5pm  ").unwrap(),   hm(17, 0)); }
    #[test] fn test_am_uppercase()   { assert_eq!(parse_time("9AM").unwrap(),       hm( 9, 0)); }
    #[test] fn test_pm_uppercase()   { assert_eq!(parse_time("3PM").unwrap(),       hm(15, 0)); }

    #[test] fn test_reject_empty()    { assert!(parse_time("").is_err()); }
    #[test] fn test_reject_garbage()  { assert!(parse_time("not-a-time").is_err()); }
    #[test] fn test_reject_bare_num() { assert!(parse_time("930").is_err()); }
    #[test] fn test_reject_bad_hour() { assert!(parse_time("25:00").is_err()); }
    #[test] fn test_reject_bad_min()  { assert!(parse_time("9:99pm").is_err()); }
}