haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! `cache.clean.max_age` value type and parser per `CFG-034`.
//!
//! A `max_age` is expressed in a textual form of one or more
//! integer-unit pairs (e.g. `90d`, `12h`, `7d12h`, `1w2d`) that
//! sum to a [`Duration`]. Each unit (`s`, `m`, `h`, `d`, `w`)
//! MAY appear at most once in a single string; a bare integer
//! has no unit and MUST be rejected.

use std::time::Duration;

use snafu::Snafu;

/// Errors produced when parsing a [`MaxAge`] from its textual
/// form per `CFG-034`.
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum MaxAgeParseError {
    /// The supplied string was empty.
    #[snafu(display("max_age cannot be the empty string"))]
    Empty,

    /// A unit byte appeared with no preceding integer.
    #[snafu(display("max_age expects a digit at byte position {position}"))]
    NoDigits {
        /// Byte offset within the input where the digit was
        /// expected.
        position: usize,
    },

    /// A trailing integer carried no unit suffix.
    #[snafu(display("max_age integer `{number}` has no unit suffix"))]
    MissingUnit {
        /// The offending integer string.
        number: String,
    },

    /// A unit byte was not one of `s` / `m` / `h` / `d` / `w`.
    #[snafu(display("max_age unit `{unit}` is not one of s/m/h/d/w"))]
    UnknownUnit {
        /// The offending unit character.
        unit: char,
    },

    /// A unit appeared more than once in the same string.
    #[snafu(display("max_age unit `{unit}` appeared more than once"))]
    DuplicateUnit {
        /// The offending unit character.
        unit: char,
    },

    /// The accumulated duration exceeded the representable range.
    #[snafu(display("max_age `{input}` exceeds the representable duration range"))]
    Overflow {
        /// The full input string that produced the overflow.
        input: String,
    },
}

/// `cache.clean.max_age` value: a non-negative duration expressed
/// per the `CFG-034` grammar.
///
/// Constructed via [`MaxAge::parse`] from its textual form. The
/// zero-duration string (e.g. `0d`) is accepted; an empty string
/// is not.
///
/// # Examples
///
/// ```
/// use std::time::Duration;
/// use haz_domain::settings::cache_clean::max_age::MaxAge;
///
/// assert_eq!(
///     MaxAge::parse("90d").unwrap().as_duration(),
///     Duration::from_secs(90 * 24 * 60 * 60),
/// );
/// assert_eq!(
///     MaxAge::parse("7d12h").unwrap().as_duration(),
///     Duration::from_secs(7 * 24 * 60 * 60 + 12 * 60 * 60),
/// );
/// assert!(MaxAge::parse("90").is_err());
/// assert!(MaxAge::parse("").is_err());
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MaxAge(Duration);

impl MaxAge {
    /// Parse a `max_age` string per the `CFG-034` grammar.
    ///
    /// # Errors
    ///
    /// Returns a [`MaxAgeParseError`] variant describing the first
    /// syntactic or semantic failure encountered in the input.
    pub fn parse(input: &str) -> Result<Self, MaxAgeParseError> {
        if input.is_empty() {
            return Err(MaxAgeParseError::Empty);
        }
        let bytes = input.as_bytes();
        let mut idx = 0usize;
        let mut seen = [false; 5];
        let mut total = Duration::ZERO;

        while idx < bytes.len() {
            let digit_start = idx;
            while idx < bytes.len() && bytes[idx].is_ascii_digit() {
                idx += 1;
            }
            if idx == digit_start {
                return Err(MaxAgeParseError::NoDigits {
                    position: digit_start,
                });
            }
            let number_str = &input[digit_start..idx];

            if idx >= bytes.len() {
                return Err(MaxAgeParseError::MissingUnit {
                    number: number_str.to_owned(),
                });
            }
            let unit_byte = bytes[idx];
            idx += 1;
            let (slot, secs_per_unit): (usize, u64) = match unit_byte {
                b's' => (0, 1),
                b'm' => (1, 60),
                b'h' => (2, 60 * 60),
                b'd' => (3, 60 * 60 * 24),
                b'w' => (4, 60 * 60 * 24 * 7),
                _ => {
                    return Err(MaxAgeParseError::UnknownUnit {
                        unit: unit_byte as char,
                    });
                }
            };
            if seen[slot] {
                return Err(MaxAgeParseError::DuplicateUnit {
                    unit: unit_byte as char,
                });
            }
            seen[slot] = true;

            let n: u64 = number_str.parse().map_err(|_| MaxAgeParseError::Overflow {
                input: input.to_owned(),
            })?;
            let term_secs =
                n.checked_mul(secs_per_unit)
                    .ok_or_else(|| MaxAgeParseError::Overflow {
                        input: input.to_owned(),
                    })?;
            total = total
                .checked_add(Duration::from_secs(term_secs))
                .ok_or_else(|| MaxAgeParseError::Overflow {
                    input: input.to_owned(),
                })?;
        }
        Ok(Self(total))
    }

    /// Borrow the wrapped [`Duration`].
    #[must_use]
    pub const fn as_duration(&self) -> Duration {
        self.0
    }
}

#[cfg(test)]
mod tests {
    use super::{MaxAge, MaxAgeParseError};
    use std::time::Duration;

    fn dur_secs(s: u64) -> Duration {
        Duration::from_secs(s)
    }

    #[test]
    fn parse_accepts_seconds() {
        assert_eq!(MaxAge::parse("45s").unwrap().as_duration(), dur_secs(45));
    }

    #[test]
    fn parse_accepts_minutes() {
        assert_eq!(
            MaxAge::parse("12m").unwrap().as_duration(),
            dur_secs(12 * 60)
        );
    }

    #[test]
    fn parse_accepts_hours() {
        assert_eq!(
            MaxAge::parse("12h").unwrap().as_duration(),
            dur_secs(12 * 60 * 60),
        );
    }

    #[test]
    fn parse_accepts_days() {
        assert_eq!(
            MaxAge::parse("90d").unwrap().as_duration(),
            dur_secs(90 * 24 * 60 * 60),
        );
    }

    #[test]
    fn parse_accepts_weeks() {
        assert_eq!(
            MaxAge::parse("2w").unwrap().as_duration(),
            dur_secs(2 * 7 * 24 * 60 * 60),
        );
    }

    #[test]
    fn parse_accepts_multi_unit_combinations() {
        assert_eq!(
            MaxAge::parse("7d12h").unwrap().as_duration(),
            dur_secs(7 * 24 * 60 * 60 + 12 * 60 * 60),
        );
        assert_eq!(
            MaxAge::parse("1w2d").unwrap().as_duration(),
            dur_secs((7 + 2) * 24 * 60 * 60),
        );
        assert_eq!(
            MaxAge::parse("1h30m15s").unwrap().as_duration(),
            dur_secs(60 * 60 + 30 * 60 + 15),
        );
    }

    #[test]
    fn parse_accepts_zero_valued_pair() {
        // Zero of any unit is a degenerate but unambiguous form.
        assert_eq!(MaxAge::parse("0d").unwrap().as_duration(), Duration::ZERO);
        assert_eq!(MaxAge::parse("0s").unwrap().as_duration(), Duration::ZERO);
    }

    #[test]
    fn parse_rejects_empty_string() {
        assert_eq!(MaxAge::parse(""), Err(MaxAgeParseError::Empty));
    }

    #[test]
    fn parse_rejects_bare_integer() {
        match MaxAge::parse("90") {
            Err(MaxAgeParseError::MissingUnit { number }) => assert_eq!(number, "90"),
            other => panic!("expected MissingUnit, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_trailing_unitless_integer() {
        match MaxAge::parse("12h0") {
            Err(MaxAgeParseError::MissingUnit { number }) => assert_eq!(number, "0"),
            other => panic!("expected MissingUnit, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_duplicate_unit() {
        match MaxAge::parse("5d3d") {
            Err(MaxAgeParseError::DuplicateUnit { unit }) => assert_eq!(unit, 'd'),
            other => panic!("expected DuplicateUnit, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_unknown_unit() {
        match MaxAge::parse("5x") {
            Err(MaxAgeParseError::UnknownUnit { unit }) => assert_eq!(unit, 'x'),
            other => panic!("expected UnknownUnit, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_leading_non_digit() {
        match MaxAge::parse("d5") {
            Err(MaxAgeParseError::NoDigits { position }) => assert_eq!(position, 0),
            other => panic!("expected NoDigits, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_internal_non_digit_after_unit() {
        // `1h x` (with a non-digit after the unit) makes the
        // next-iteration digit-scan fail at the offending byte.
        match MaxAge::parse("1hx") {
            Err(MaxAgeParseError::NoDigits { position }) => assert_eq!(position, 2),
            other => panic!("expected NoDigits, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_overflow_in_term() {
        // u64::MAX seconds is the upper bound; weeks * u64::MAX overflows.
        let huge = format!("{}w", u64::MAX);
        match MaxAge::parse(&huge) {
            Err(MaxAgeParseError::Overflow { input }) => assert_eq!(input, huge),
            other => panic!("expected Overflow, got {other:?}"),
        }
    }
}