coda-api 0.4.0

Coda API client
Documentation
use errgonomic::{handle, handle_opt};
use std::num::ParseIntError;
use thiserror::Error;
use time::Duration;

const SECONDS_PER_MINUTE: i64 = 60;
const SECONDS_PER_HOUR: i64 = 3_600;
const SECONDS_PER_DAY: i64 = 86_400;

pub fn parse_duration_value(source: &impl AsRef<str>) -> Result<Option<Duration>, DurationValueParserError> {
    use DurationValueParserError::*;
    let trimmed = source.as_ref().trim();
    if trimmed.is_empty() {
        return Ok(None);
    }

    let mut tokens = trimmed.split_whitespace();
    let number_str = handle_opt!(tokens.next(), NumberNotFound);
    let unit_str = handle_opt!(tokens.next(), UnitNotFound);
    parse_duration_component(number_str, unit_str)
        .and_then(|duration_initial| {
            core::iter::from_fn(|| tokens.next().map(|number_str| (number_str, tokens.next()))).try_fold(duration_initial, |duration_total, (number_str, unit_str_opt)| {
                let unit_str = handle_opt!(unit_str_opt, UnitNotFound);
                parse_duration_component(number_str, unit_str).and_then(|duration| duration_total.checked_add(duration).ok_or(DurationOverflow))
            })
        })
        .map(Some)
}

fn parse_duration_component(number_str: &str, unit_str: &str) -> Result<Duration, DurationValueParserError> {
    use DurationValueParserError::*;
    let number = handle!(number_str.parse::<i64>(), NumberParseFailed);
    duration_from_number_and_unit(number, unit_str)
}

fn duration_from_number_and_unit(number: i64, unit_str: &str) -> Result<Duration, DurationValueParserError> {
    use DurationValueParserError::*;
    match unit_str.to_ascii_lowercase().as_str() {
        "second" | "seconds" | "sec" | "secs" => Ok(Duration::seconds(number)),
        "minute" | "minutes" | "min" | "mins" => checked_duration_from_seconds(number, SECONDS_PER_MINUTE),
        "hour" | "hours" | "hr" | "hrs" => checked_duration_from_seconds(number, SECONDS_PER_HOUR),
        "day" | "days" => checked_duration_from_seconds(number, SECONDS_PER_DAY),
        _ => Err(UnitUnexpected {
            unit: unit_str.to_owned(),
        }),
    }
}

fn checked_duration_from_seconds(number: i64, seconds_per_unit: i64) -> Result<Duration, DurationValueParserError> {
    use DurationValueParserError::*;
    match number.checked_mul(seconds_per_unit) {
        Some(seconds) => Ok(Duration::seconds(seconds)),
        None => Err(DurationOverflow),
    }
}

#[derive(Debug, PartialEq, Error)]
pub enum DurationValueParserError {
    #[error("duration value does not contain a number")]
    NumberNotFound,
    #[error("duration value does not contain a unit")]
    UnitNotFound,
    #[error("failed to parse duration number: {source}")]
    NumberParseFailed { source: ParseIntError },
    #[error("unexpected duration unit: {unit}")]
    UnitUnexpected { unit: String },
    #[error("duration value is too large")]
    DurationOverflow,
}

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

    #[test]
    fn must_parse_simple_duration() {
        assert_eq!(parse_duration_value(&"2 hrs"), Ok(Some(Duration::hours(2))));
    }

    #[test]
    fn must_parse_complex_duration() {
        assert_eq!(parse_duration_value(&"2 hrs 30 mins"), Ok(Some(Duration::hours(2) + Duration::minutes(30))));
    }
}