fdate 0.2.2

Natural date input parsing
Documentation
use chrono::{Month, Weekday};
use nom::{
    IResult, Parser,
    branch::alt,
    bytes::complete::tag_no_case,
    character::complete::{alpha1, digit1},
    combinator::{map_res, opt},
};

#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) enum RelativeDirection {
    #[default]
    Future,
    Past,
}

pub(super) fn parse_relative_direction(input: &str) -> IResult<&str, RelativeDirection> {
    alt((
        tag_no_case("next").map(|_| RelativeDirection::Future),
        tag_no_case("last").map(|_| RelativeDirection::Past),
    ))
    .parse(input)
}

pub(super) fn parse_number(input: &str) -> IResult<&str, u32> {
    alt((
        map_res(digit1, |x: &str| x.parse::<u32>()),
        map_res(alpha1, |word: &str| {
            match word.to_ascii_lowercase().as_str() {
                "zero" => Ok(0),
                "one" => Ok(1),
                "two" => Ok(2),
                "three" => Ok(3),
                "four" => Ok(4),
                "five" => Ok(5),
                "six" => Ok(6),
                "seven" => Ok(7),
                "eight" => Ok(8),
                "nine" => Ok(9),
                "ten" => Ok(10),
                "eleven" => Ok(11),
                "twelve" => Ok(12),
                "thirteen" => Ok(13),
                "fourteen" => Ok(14),
                "fifteen" => Ok(15),
                "sixteen" => Ok(16),
                "seventeen" => Ok(17),
                "eighteen" => Ok(18),
                "nineteen" => Ok(19),
                "twenty" => Ok(20),
                _ => Err("invalid number word"),
            }
        }),
    ))
    .parse(input)
}

pub(super) fn parse_ordinal_day(input: &str) -> IResult<&str, u32> {
    (
        map_res(digit1, |x: &str| x.parse::<u32>()),
        opt(parse_ordinal_suffix),
    )
        .map(|(day, _)| day)
        .map_res(|day| {
            if (1..=31).contains(&day) {
                Ok(day)
            } else {
                Err("invalid day of month")
            }
        })
        .parse(input)
}

fn parse_ordinal_suffix(input: &str) -> IResult<&str, &str> {
    alt((
        tag_no_case("st"),
        tag_no_case("nd"),
        tag_no_case("rd"),
        tag_no_case("th"),
    ))
    .parse(input)
}

pub(super) fn parse_weekday(input: &str) -> IResult<&str, Weekday> {
    alt((
        alt((tag_no_case("monday"), tag_no_case("mon"))).map(|_| Weekday::Mon),
        alt((tag_no_case("tuesday"), tag_no_case("tue"))).map(|_| Weekday::Tue),
        alt((tag_no_case("wednesday"), tag_no_case("wed"))).map(|_| Weekday::Wed),
        alt((tag_no_case("thursday"), tag_no_case("thu"))).map(|_| Weekday::Thu),
        alt((tag_no_case("friday"), tag_no_case("fri"))).map(|_| Weekday::Fri),
        alt((tag_no_case("saturday"), tag_no_case("sat"))).map(|_| Weekday::Sat),
        alt((tag_no_case("sunday"), tag_no_case("sun"))).map(|_| Weekday::Sun),
    ))
    .parse(input)
}

pub(super) fn parse_month(input: &str) -> IResult<&str, Month> {
    alt((
        tag_no_case("january").map(|_| Month::January),
        tag_no_case("february").map(|_| Month::February),
        tag_no_case("march").map(|_| Month::March),
        tag_no_case("april").map(|_| Month::April),
        tag_no_case("may").map(|_| Month::May),
        tag_no_case("june").map(|_| Month::June),
        tag_no_case("july").map(|_| Month::July),
        tag_no_case("august").map(|_| Month::August),
        tag_no_case("september").map(|_| Month::September),
        tag_no_case("october").map(|_| Month::October),
        tag_no_case("november").map(|_| Month::November),
        tag_no_case("december").map(|_| Month::December),
    ))
    .parse(input)
}

#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) enum IntervalUnit {
    #[default]
    Day,
    Week,
    Month,
    Year,
}

impl IntervalUnit {
    pub(super) fn parse(input: &str) -> IResult<&str, Self> {
        alt((
            tag_no_case("day").map(|_| Self::Day),
            tag_no_case("week").map(|_| Self::Week),
            tag_no_case("month").map(|_| Self::Month),
            tag_no_case("year").map(|_| Self::Year),
        ))
        .parse(input)
    }
}

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

    #[test]
    fn parses_relative_directions() {
        assert_eq!(
            parse_relative_direction("next"),
            Ok(("", RelativeDirection::Future))
        );
        assert_eq!(
            parse_relative_direction("last"),
            Ok(("", RelativeDirection::Past))
        );
    }

    #[test]
    fn parses_numbers_as_digits_or_words() {
        assert_eq!(parse_number("0"), Ok(("", 0)));
        assert_eq!(parse_number("seven"), Ok(("", 7)));
        assert_eq!(parse_number("Twenty"), Ok(("", 20)));
    }

    #[test]
    fn parses_ordinal_days() {
        assert_eq!(parse_ordinal_day("1"), Ok(("", 1)));
        assert_eq!(parse_ordinal_day("14th"), Ok(("", 14)));
        assert_eq!(parse_ordinal_day("22nd"), Ok(("", 22)));
    }

    #[test]
    fn rejects_invalid_ordinal_days() {
        assert!(parse_ordinal_day("0").is_err());
        assert!(parse_ordinal_day("32nd").is_err());
        assert!(parse_ordinal_day("hello").is_err());
    }

    #[test]
    fn parses_months() {
        assert_eq!(parse_month("january"), Ok(("", Month::January)));
        assert_eq!(parse_month("april"), Ok(("", Month::April)));
        assert_eq!(parse_month("December"), Ok(("", Month::December)));
    }

    #[test]
    fn rejects_invalid_months() {
        assert!(parse_month("month").is_err());
        assert!(parse_month("jan").is_err());
        assert!(parse_month("hello").is_err());
    }

    #[test]
    fn parses_interval_units() {
        assert_eq!(IntervalUnit::parse("day"), Ok(("", IntervalUnit::Day)));
        assert_eq!(IntervalUnit::parse("week"), Ok(("", IntervalUnit::Week)));
        assert_eq!(IntervalUnit::parse("month"), Ok(("", IntervalUnit::Month)));
        assert_eq!(IntervalUnit::parse("year"), Ok(("", IntervalUnit::Year)));
    }

    #[test]
    fn rejects_invalid_interval_units() {
        assert_eq!(IntervalUnit::parse("days"), Ok(("s", IntervalUnit::Day)));
        assert!(IntervalUnit::parse("hello").is_err());
    }

    #[test]
    fn parses_full_weekday_names() {
        assert_eq!(parse_weekday("monday"), Ok(("", Weekday::Mon)));
        assert_eq!(parse_weekday("tuesday"), Ok(("", Weekday::Tue)));
        assert_eq!(parse_weekday("wednesday"), Ok(("", Weekday::Wed)));
        assert_eq!(parse_weekday("thursday"), Ok(("", Weekday::Thu)));
        assert_eq!(parse_weekday("friday"), Ok(("", Weekday::Fri)));
        assert_eq!(parse_weekday("saturday"), Ok(("", Weekday::Sat)));
        assert_eq!(parse_weekday("sunday"), Ok(("", Weekday::Sun)));
    }

    #[test]
    fn parses_common_weekday_abbreviations() {
        assert_eq!(parse_weekday("mon"), Ok(("", Weekday::Mon)));
        assert_eq!(parse_weekday("tue"), Ok(("", Weekday::Tue)));
        assert_eq!(parse_weekday("wed"), Ok(("", Weekday::Wed)));
        assert_eq!(parse_weekday("thu"), Ok(("", Weekday::Thu)));
        assert_eq!(parse_weekday("fri"), Ok(("", Weekday::Fri)));
        assert_eq!(parse_weekday("sat"), Ok(("", Weekday::Sat)));
        assert_eq!(parse_weekday("sun"), Ok(("", Weekday::Sun)));
    }

    #[test]
    fn parses_weekdays_case_insensitively() {
        assert_eq!(parse_weekday("Monday"), Ok(("", Weekday::Mon)));
        assert_eq!(parse_weekday("THU"), Ok(("", Weekday::Thu)));
        assert_eq!(parse_weekday("SunDay"), Ok(("", Weekday::Sun)));
    }

    #[test]
    fn leaves_remaining_input_for_larger_parsers() {
        assert_eq!(
            parse_weekday("monday next week"),
            Ok((" next week", Weekday::Mon))
        );
    }

    #[test]
    fn rejects_invalid_weekdays() {
        assert!(parse_weekday("hello").is_err());
        assert!(parse_weekday("mo").is_err());
    }
}