fdate 0.2.2

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

pub(super) fn parse_absolute(input: &str) -> IResult<&str, NaiveDate> {
    alt((
        parse_absolute_date_iso,
        parse_absolute_date_freedom_units,
        parse_absolute_date_day_month_year,
        parse_absolute_date_month_day_year,
    ))
    .parse(input)
}

fn parse_absolute_date_iso(input: &str) -> IResult<&str, NaiveDate> {
    (
        map_res(digit1, str::parse::<u32>),
        char('-'),
        map_res(digit1, str::parse::<u32>),
        char('-'),
        map_res(digit1, str::parse::<u32>),
    )
        .map(|(year, _, month, _, day)| (year, month, day))
        .map_res(|(year, month, day)| {
            NaiveDate::from_ymd_opt(year as i32, month, day).ok_or("invalid date")
        })
        .parse(input)
}

fn parse_absolute_date_freedom_units(input: &str) -> IResult<&str, NaiveDate> {
    (
        map_res(digit1, str::parse::<u32>),
        char('/'),
        map_res(digit1, str::parse::<u32>),
        char('/'),
        map_res(digit1, str::parse::<u32>),
    )
        .map(|(month, _, day, _, year)| (year, month, day))
        .map_res(|(year, month, day)| {
            NaiveDate::from_ymd_opt(year as i32, month, day).ok_or("invalid date")
        })
        .parse(input)
}

fn parse_absolute_date_day_month_year(input: &str) -> IResult<&str, NaiveDate> {
    (
        parse_ordinal_day,
        multispace1,
        parse_month_name,
        multispace1,
        parse_year,
    )
        .map(|(day, _, month, _, year)| (year, month, day))
        .map_res(|(year, month, day)| {
            NaiveDate::from_ymd_opt(year, month, day).ok_or("invalid date")
        })
        .parse(input)
}

fn parse_absolute_date_month_day_year(input: &str) -> IResult<&str, NaiveDate> {
    (
        parse_month_name,
        multispace1,
        parse_ordinal_day,
        multispace1,
        parse_year,
    )
        .map(|(month, _, day, _, year)| (year, month, day))
        .map_res(|(year, month, day)| {
            NaiveDate::from_ymd_opt(year, month, day).ok_or("invalid date")
        })
        .parse(input)
}

fn parse_ordinal_day(input: &str) -> IResult<&str, u32> {
    (
        map_res(digit1, str::parse::<u32>),
        opt(alt((
            tag_no_case("st"),
            tag_no_case("nd"),
            tag_no_case("rd"),
            tag_no_case("th"),
        ))),
    )
        .map(|(day, _)| day)
        .parse(input)
}

fn parse_month_name(input: &str) -> IResult<&str, u32> {
    alt((
        tag_no_case("january").map(|_| 1),
        tag_no_case("february").map(|_| 2),
        tag_no_case("march").map(|_| 3),
        tag_no_case("april").map(|_| 4),
        tag_no_case("may").map(|_| 5),
        tag_no_case("june").map(|_| 6),
        tag_no_case("july").map(|_| 7),
        tag_no_case("august").map(|_| 8),
        tag_no_case("september").map(|_| 9),
        tag_no_case("october").map(|_| 10),
        tag_no_case("november").map(|_| 11),
        tag_no_case("december").map(|_| 12),
    ))
    .parse(input)
}

fn parse_year(input: &str) -> IResult<&str, i32> {
    map_res(digit1, |year: &str| -> Result<i32, &'static str> {
        match year.len() {
            2 => year
                .parse::<i32>()
                .map(|x| 2000 + x)
                .map_err(|_| "invalid year"),
            4 => year.parse::<i32>().map_err(|_| "invalid year"),
            _ => Err("invalid year"),
        }
    })
    .parse(input)
}

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

    #[test]
    fn parses_absolute_dates() {
        assert_eq!(
            parse_absolute("2000-10-10"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 10, 10).unwrap()))
        );
        assert_eq!(
            parse_absolute("2000-1-1"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 1, 1).unwrap()))
        );
        assert_eq!(
            parse_absolute("10/20/2000"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 10, 20).unwrap()))
        );
        assert_eq!(
            parse_absolute("1/2/2000"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 1, 2).unwrap()))
        );
        assert_eq!(
            parse_absolute("15th April 2000"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 4, 15).unwrap()))
        );
        assert_eq!(
            parse_absolute("15 April 2000"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 4, 15).unwrap()))
        );
        assert_eq!(
            parse_absolute("April 15th 00"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 4, 15).unwrap()))
        );
        assert_eq!(
            parse_absolute("April 15 00"),
            Ok(("", NaiveDate::from_ymd_opt(2000, 4, 15).unwrap()))
        );
    }

    #[test]
    fn rejects_invalid_absolute_dates() {
        assert!(parse_absolute("2000-13-1").is_err());
        assert!(parse_absolute("2000-2-30").is_err());
        assert!(parse_absolute("13/20/2000").is_err());
        assert!(parse_absolute("2/30/2000").is_err());
        assert!(parse_absolute("31st April 2000").is_err());
        assert!(parse_absolute("April 31st 00").is_err());
        assert!(parse_absolute("Smarch 15th 00").is_err());
        assert!(parse_absolute("2000/10/20").is_err());
        assert!(parse_absolute("hello").is_err());
    }

    #[test]
    fn leaves_remaining_input_for_larger_parsers() {
        assert_eq!(
            parse_absolute("2000-10-10 and more"),
            Ok((" and more", NaiveDate::from_ymd_opt(2000, 10, 10).unwrap()))
        );
        assert_eq!(
            parse_absolute("April 15th 00 please"),
            Ok((" please", NaiveDate::from_ymd_opt(2000, 4, 15).unwrap()))
        );
    }
}