saturn-cli 0.4.5

saturn is a command-line interface to calendaring, supporting Google Calendar
Documentation
use crate::time::now;
use anyhow::{anyhow, Result};
use chrono::{Datelike, Timelike};

const DAYS_OF_WEEK: [&str; 7] = ["su", "mo", "tu", "we", "th", "fr", "sa"];
const DATE_ENDINGS: [&str; 4] = ["th", "st", "rd", "nd"];

pub fn parse_date(s: String) -> Result<chrono::NaiveDate> {
    match s.to_lowercase().as_str() {
        "today" => Ok(now().date_naive()),
        "yesterday" => {
            Ok((now() - chrono::TimeDelta::try_days(1).unwrap_or_default()).date_naive())
        }
        "tomorrow" => Ok((now() + chrono::TimeDelta::try_days(1).unwrap_or_default()).date_naive()),
        "su" | "sun" | "sunday" | "mo" | "mon" | "monday" | "tu" | "tue" | "tues" | "tuesday"
        | "we" | "wed" | "wednesday" | "weds" | "th" | "thu" | "thurs" | "thursday" | "fr"
        | "fri" | "friday" | "sa" | "sat" | "saturday" => {
            let period = now().weekday().num_days_from_sunday();

            for (x, day) in DAYS_OF_WEEK.iter().enumerate() {
                if *day == s[0..2].to_string() {
                    let index = if x < period as usize {
                        7 - period as usize + x
                    } else {
                        x - period as usize
                    };
                    return Ok((now()
                        + chrono::TimeDelta::try_days(index.try_into()?).unwrap_or_default())
                    .date_naive());
                }
            }

            Ok(now().date_naive())
        }
        _ => {
            let regex = regex::Regex::new(r#"[/.-]"#)?;
            let split = regex.split(&s);
            let parts = split.collect::<Vec<&str>>();
            match parts.len() {
                3 => {
                    // FIXME this should be locale-based
                    chrono::NaiveDate::from_ymd_opt(
                        parts[0].parse()?,
                        parts[1].parse()?,
                        parts[2].parse()?,
                    )
                    .map_or_else(|| Err(anyhow!("Invalid Date")), |d| Ok(d))
                }
                2 => {
                    // FIXME this should be locale-based
                    chrono::NaiveDate::from_ymd_opt(
                        now().year(),
                        parts[0].parse()?,
                        parts[1].parse()?,
                    )
                    .map_or_else(|| Err(anyhow!("Invalid Date")), |d| Ok(d))
                }
                1 => {
                    let now = now();
                    let mut part = parts[0].trim().to_string();
                    for ending in DATE_ENDINGS {
                        if part.ends_with(ending) {
                            part = part.replace(ending, "");
                            break;
                        }
                    }
                    // FIXME this should be locale-based
                    chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), part.parse()?)
                        .map_or_else(|| Err(anyhow!("Invalid Date")), |d| Ok(d))
                }
                _ => Err(anyhow!("Cannot parse date")),
            }
        }
    }
}

fn twelve_hour_time(pm: bool, hour: u32, minute: u32) -> chrono::NaiveTime {
    let new_hour = if pm { 12 } else { 0 };

    time(
        if hour > 12 {
            hour
        } else if hour == 12 {
            new_hour
        } else {
            hour + new_hour
        },
        minute,
    )
}

fn time(hour: u32, minute: u32) -> chrono::NaiveTime {
    chrono::NaiveTime::from_hms_opt(hour, minute, 0).unwrap_or_default()
}

fn pm_time(hour: u32, minute: u32) -> chrono::NaiveTime {
    twelve_hour_time(true, hour, minute)
}

fn am_time(hour: u32, minute: u32) -> chrono::NaiveTime {
    twelve_hour_time(false, hour, minute)
}

fn time_period(hour: u32, minute: u32, today: bool) -> chrono::NaiveTime {
    if today {
        if now().hour() >= 12 {
            pm_time(hour, minute)
        } else {
            am_time(hour, minute)
        }
    } else {
        time(hour, minute)
    }
}

fn designation(
    hour: u32,
    minute: u32,
    designation: &str,
    today: bool,
) -> Result<chrono::NaiveTime> {
    match designation {
        "pm" | "PM" => Ok(pm_time(hour, minute)),
        "am" | "AM" => Ok(am_time(hour, minute)),
        "" => Ok(time_period(hour, minute, today)),
        _ => Err(anyhow!("Cannot parse time")),
    }
}

pub fn parse_time(s: String, today: bool) -> Result<chrono::NaiveTime> {
    let s = s.trim();

    match s.to_lowercase().as_str() {
        "midnight" => return Ok(time(0, 0)),
        "noon" => return Ok(time(12, 0)),
        _ => {}
    }

    let regex = regex::Regex::new(r#"[:.]"#)?;
    let split = regex.split(s);
    let parts = split.collect::<Vec<&str>>();

    match parts.len() {
        3 => {
            chrono::NaiveTime::from_hms_opt(parts[0].parse()?, parts[1].parse()?, parts[2].parse()?)
                .map_or_else(|| Err(anyhow!("Invalid Time")), |d| Ok(d))
        }
        2 => {
            let regex = regex::Regex::new(r"(\d+)(\D+)")?;
            if let Some(captures) = regex.captures(parts[1]) {
                let hour: u32 = parts[0].parse()?;

                let minute: u32 = if let Some(minute) = captures.get(1) {
                    minute.as_str().parse()?
                } else {
                    return Err(anyhow!("Cannot parse time"));
                };

                if let Some(d) = captures.get(2) {
                    designation(hour, minute, d.as_str(), today)
                } else {
                    Ok(time_period(hour, minute, today))
                }
            } else {
                let hour: u32 = parts[0].parse()?;
                let minute: u32 = parts[1].parse()?;

                Ok(time_period(hour, minute, today))
            }
        }
        1 => {
            let regex = regex::Regex::new(r"(\d+)(\D*)")?;
            if let Some(captures) = regex.captures(parts[0]) {
                let hour: u32 = if let Some(hour) = captures.get(1) {
                    hour.as_str().parse()?
                } else {
                    return Err(anyhow!("Cannot parse time"));
                };

                if let Some(d) = captures.get(2) {
                    designation(hour, 0, d.as_str(), today)
                } else {
                    Ok(time_period(hour, 0, today))
                }
            } else {
                Err(anyhow!("Cannot parse time"))
            }
        }
        _ => Err(anyhow!("Cannot parse time")),
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_parse_date() {
        use super::parse_date;
        use crate::time::now;
        use chrono::Datelike;

        let table = vec![
            (
                "2018-10-23",
                chrono::NaiveDate::from_ymd_opt(2018, 10, 23).unwrap(),
            ),
            (
                "2018/10/23",
                chrono::NaiveDate::from_ymd_opt(2018, 10, 23).unwrap(),
            ),
            (
                "2018.10.23",
                chrono::NaiveDate::from_ymd_opt(2018, 10, 23).unwrap(),
            ),
            (
                "10.23",
                chrono::NaiveDate::from_ymd_opt(now().year(), 10, 23).unwrap(),
            ),
            (
                "10/23",
                chrono::NaiveDate::from_ymd_opt(now().year(), 10, 23).unwrap(),
            ),
            (
                "10-23",
                chrono::NaiveDate::from_ymd_opt(now().year(), 10, 23).unwrap(),
            ),
            (
                "23",
                chrono::NaiveDate::from_ymd_opt(now().year(), now().month(), 23).unwrap(),
            ),
        ];

        for (to_parse, t) in table {
            assert_eq!(parse_date(to_parse.to_string()).unwrap(), t)
        }
    }

    #[test]
    fn test_parse_time() {
        use super::parse_time;
        use crate::time::now;
        use chrono::Timelike;

        let pm = now().hour() >= 12;

        let today_table = vec![
            ("12am", chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()),
            ("12pm", chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap()),
            ("8:00:00", chrono::NaiveTime::from_hms_opt(8, 0, 0).unwrap()),
            (
                "8:12:56",
                chrono::NaiveTime::from_hms_opt(8, 12, 56).unwrap(),
            ),
            (
                "8:00",
                chrono::NaiveTime::from_hms_opt(if pm { 20 } else { 8 }, 0, 0).unwrap(),
            ),
            ("8am", chrono::NaiveTime::from_hms_opt(8, 0, 0).unwrap()),
            ("8:00pm", chrono::NaiveTime::from_hms_opt(20, 0, 0).unwrap()),
            ("8pm", chrono::NaiveTime::from_hms_opt(20, 0, 0).unwrap()),
            (
                "8:30pm",
                chrono::NaiveTime::from_hms_opt(20, 30, 0).unwrap(),
            ),
            (
                "8",
                chrono::NaiveTime::from_hms_opt(if pm { 20 } else { 8 }, 0, 0).unwrap(),
            ),
            (
                "8:30",
                chrono::NaiveTime::from_hms_opt(if pm { 20 } else { 8 }, 30, 0).unwrap(),
            ),
        ];

        let other_table = vec![
            ("12am", chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()),
            ("12pm", chrono::NaiveTime::from_hms_opt(12, 0, 0).unwrap()),
            ("8:00:00", chrono::NaiveTime::from_hms_opt(8, 0, 0).unwrap()),
            (
                "8:12:56",
                chrono::NaiveTime::from_hms_opt(8, 12, 56).unwrap(),
            ),
            (
                "8:00",
                chrono::NaiveTime::from_hms_opt(if pm { 20 } else { 8 }, 0, 0).unwrap(),
            ),
            ("8am", chrono::NaiveTime::from_hms_opt(8, 0, 0).unwrap()),
            ("8:00pm", chrono::NaiveTime::from_hms_opt(20, 0, 0).unwrap()),
            ("8pm", chrono::NaiveTime::from_hms_opt(20, 0, 0).unwrap()),
            (
                "8:30pm",
                chrono::NaiveTime::from_hms_opt(20, 30, 0).unwrap(),
            ),
            (
                "8",
                chrono::NaiveTime::from_hms_opt(if pm { 20 } else { 8 }, 0, 0).unwrap(),
            ),
            (
                "8:30",
                chrono::NaiveTime::from_hms_opt(if pm { 20 } else { 8 }, 30, 0).unwrap(),
            ),
        ];

        for (to_parse, t) in today_table {
            assert_eq!(
                parse_time(to_parse.to_string(), true).unwrap(),
                t,
                "{}",
                to_parse
            )
        }

        for (to_parse, t) in other_table {
            assert_eq!(
                parse_time(to_parse.to_string(), true).unwrap(),
                t,
                "{}",
                to_parse
            )
        }
    }
}