timeturner 1.7.1

Command line utility for manipulating date-time strings
Documentation
use chrono::prelude::*;
use chrono_english::{parse_date_string, Dialect};

#[derive(PartialEq, Debug)]
pub enum DateTimeFormat {
    Missing,
    EpochMillis,
    Rfc3339,
    CustomUnzoned,
    CustomZoned,
    English,
}

impl DateTimeFormat {
    fn parse(&self, input: &str) -> Option<ParsedInput> {
        match self {
            Self::Missing => None,
            Self::EpochMillis => parse_from_epoch_millis(input),
            Self::Rfc3339 => parse_from_rfc3339(input),
            Self::CustomUnzoned => parse_custom_unzoned_format(input),
            Self::CustomZoned => parse_custom_zoned_format(input),
            Self::English => parse_from_english(input),
        }
    }
}

#[derive(PartialEq, Debug)]
pub struct ParsedInput {
    pub input_format: DateTimeFormat,
    pub input_zone: Option<FixedOffset>,
    pub value: DateTime<Utc>,
}

fn parse_from_epoch_millis(input: &str) -> Option<ParsedInput> {
    input
        .parse::<i64>()
        .ok()
        .and_then(|e| Utc.timestamp_millis_opt(e).single())
        .map(|d| ParsedInput {
            input_format: DateTimeFormat::EpochMillis,
            input_zone: None,
            value: d,
        })
}

fn parse_from_rfc3339(input: &str) -> Option<ParsedInput> {
    DateTime::parse_from_rfc3339(&input.replace(' ', "T"))
        .ok()
        .map(|d| ParsedInput {
            input_format: DateTimeFormat::Rfc3339,
            input_zone: Some(d.timezone()),
            value: d.with_timezone(&Utc),
        })
}

const CUSTOM_UNZONED_FORMATS: [&str; 6] = [
    "%F %T,%3f",
    "%d %b %Y %H:%M:%S%.3f",
    "%d %b %Y %H:%M:%S,%3f",
    "%F %T%.3f UTC",
    "%T%.3f UTC %F",
    "%F %T%.6f",
];

fn parse_custom_unzoned_format(input: &str) -> Option<ParsedInput> {
    CUSTOM_UNZONED_FORMATS
        .iter()
        .find_map(|s| parse_from_format_unzoned(input, s))
}

fn parse_from_format_unzoned(input: &str, format: &str) -> Option<ParsedInput> {
    Utc.datetime_from_str(input, format)
        .ok()
        .map(|d| ParsedInput {
            input_format: DateTimeFormat::CustomUnzoned,
            input_zone: None,
            value: d.with_timezone(&Utc),
        })
}

const CUSTOM_ZONED_FORMATS: [&str; 2] = ["%F %T%z", "%F %T%.3f%z"];

fn parse_custom_zoned_format(input: &str) -> Option<ParsedInput> {
    CUSTOM_ZONED_FORMATS
        .iter()
        .find_map(|s| parse_from_format_zoned(input, s))
}

fn parse_from_format_zoned(input: &str, format: &str) -> Option<ParsedInput> {
    DateTime::parse_from_str(input, format)
        .ok()
        .map(|d| ParsedInput {
            input_format: DateTimeFormat::CustomZoned,
            input_zone: Some(d.timezone()),
            value: d.with_timezone(&Utc),
        })
}

fn parse_from_english(input: &str) -> Option<ParsedInput> {
    parse_date_string(input, Local::now(), Dialect::Us)
        .ok()
        .map(|d| ParsedInput {
            input_format: DateTimeFormat::English,
            input_zone: None,
            value: d.with_timezone(&Utc),
        })
}

pub fn parse_input(input: &Option<String>) -> Result<ParsedInput, &'static str> {
    input.as_ref().filter(|i| !i.trim().is_empty()).map_or_else(
        || {
            Ok(ParsedInput {
                input_format: DateTimeFormat::Missing,
                input_zone: None,
                value: Utc::now(),
            })
        },
        |i| {
            DateTimeFormat::EpochMillis
                .parse(i)
                .or_else(|| DateTimeFormat::Rfc3339.parse(i))
                .or_else(|| DateTimeFormat::CustomZoned.parse(i))
                .or_else(|| DateTimeFormat::CustomUnzoned.parse(i))
                .or_else(|| DateTimeFormat::English.parse(i))
                .ok_or("Input format not recognized")
        },
    )
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unreadable_literal)]

    use super::*;

    #[test]
    fn missing_input() {
        let now = Utc::now();
        let result = parse_input(&None).unwrap();
        assert_eq!(result.input_format, crate::parsing::DateTimeFormat::Missing);
        assert_eq!(result.input_zone, None);
        assert!(
            result.value.timestamp_millis() >= now.timestamp_millis(),
            "Provided time {} was not after the start of the test {}",
            result.value,
            now
        );

        assert!(
            result.value.timestamp_millis() < now.timestamp_millis() + 1000,
            "Provided time {} was more than one second after the start of the test {}",
            result.value,
            now
        );
    }

    #[test]
    fn empty_input() {
        let now = Utc::now();
        let result = parse_input(&Some(String::from(" "))).unwrap();
        assert_eq!(result.input_format, crate::parsing::DateTimeFormat::Missing);
        assert_eq!(result.input_zone, None);
        assert!(
            result.value.timestamp_millis() >= now.timestamp_millis(),
            "Provided time {} was not after the start of the test {}",
            result.value,
            now
        );

        assert!(
            result.value.timestamp_millis() < now.timestamp_millis() + 1000,
            "Provided time {} was more than one second after the start of the test {}",
            result.value,
            now
        );
    }

    #[test]
    fn epoch_millis_input() {
        let result = parse_input(&Some(String::from("1572213799747"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::EpochMillis);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1572213799747));
    }

    #[test]
    fn rfc3339_input() {
        let result = parse_input(&Some(String::from("2019-10-27T15:03:19.747-07:00"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::Rfc3339);
        assert_eq!(result.input_zone, Some(FixedOffset::west(25200)));
        assert_eq!(result.value, Utc.timestamp_millis(1572213799747));
    }

    #[test]
    fn rfc3339_input_no_partial_seconds() {
        let result = parse_input(&Some(String::from("2019-10-27T15:03:19-07:00"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::Rfc3339);
        assert_eq!(result.input_zone, Some(FixedOffset::west(25200)));
        assert_eq!(result.value, Utc.timestamp_millis(1572213799000));
    }

    #[test]
    fn rfc3339_input_zulu() {
        let result = parse_input(&Some(String::from("2019-10-27T22:03:19.747Z"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::Rfc3339);
        assert_eq!(result.input_zone, Some(FixedOffset::west(0)));
        assert_eq!(result.value, Utc.timestamp_millis(1572213799747));
    }

    #[test]
    fn rfc3339_input_space_instead_of_t() {
        let result = parse_input(&Some(String::from("2019-10-27 15:03:19.747-07:00"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::Rfc3339);
        assert_eq!(result.input_zone, Some(FixedOffset::west(25200)));
        assert_eq!(result.value, Utc.timestamp_millis(1572213799747));
    }

    #[test]
    fn custom_unzoned_rfc3339_like_with_space_and_comma() {
        let result = parse_input(&Some(String::from("2020-12-17 00:00:34,247"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomUnzoned);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1608163234247));
    }

    #[test]
    fn date_spelled_short_month_time_with_dot_input() {
        let result = parse_input(&Some(String::from("03 Feb 2020 01:03:10.534"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomUnzoned);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1580691790534));
    }

    #[test]
    fn date_spelled_short_month_time_with_comma_input() {
        let result = parse_input(&Some(String::from("03 Feb 2020 01:03:10,534"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomUnzoned);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1580691790534));
    }

    #[test]
    fn year_space_date_space_utc() {
        let result = parse_input(&Some(String::from("2019-11-22 09:03:44.00 UTC"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomUnzoned);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1574413424000));
    }

    #[test]
    fn time_space_utc_space_date() {
        let result = parse_input(&Some(String::from("04:10:39 UTC 2020-02-17"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomUnzoned);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1581912639000));
    }

    #[test]
    fn test_casssandra_zoned_no_millis() {
        let result = parse_input(&Some(String::from("2015-03-07 00:59:56+0100"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomZoned);
        assert_eq!(result.input_zone, Some(FixedOffset::east(3600)));
        assert_eq!(result.value, Utc.timestamp_millis(1425686396000));
    }

    #[test]
    fn test_casssandra_zoned_millis() {
        let result = parse_input(&Some(String::from("2015-03-07 00:59:56.001+0100"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomZoned);
        assert_eq!(result.input_zone, Some(FixedOffset::east(3600)));
        assert_eq!(result.value, Utc.timestamp_millis(1425686396001));
    }

    #[test]
    fn test_mysql_datetime() {
        let result = parse_input(&Some(String::from("2021-01-20 18:13:37.842000"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::CustomUnzoned);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1611166417842));
    }

    #[test]
    fn english_input() {
        let result = parse_input(&Some(String::from("May 23, 2020 12:00"))).unwrap();
        assert_eq!(result.input_format, DateTimeFormat::English);
        assert_eq!(result.input_zone, None);
        assert_eq!(result.value, Utc.timestamp_millis(1590260400000));
    }

    #[test]
    fn invalid_input() {
        let result = parse_input(&Some(String::from("not a date"))).err();
        assert_eq!(result, Some("Input format not recognized"));
    }
}