haz-domain 0.2.0

Shared domain types for the haz task runner (identifiers, paths, errors).
Documentation
//! `cache.clean.max_size` value type and parser per `CFG-035`.
//!
//! A `max_size` is expressed as a non-negative integer followed by
//! an optional unit suffix. The SI-prefix suffixes (`KB`, `MB`,
//! `GB`, `TB`) denote powers of 1000; the IEC-prefix suffixes
//! (`KiB`, `MiB`, `GiB`, `TiB`) denote powers of 1024. The bare
//! suffix `B` and the no-suffix form are both byte counts.

use snafu::Snafu;

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

    /// The input did not begin with at least one ASCII digit.
    #[snafu(display("max_size expects a digit at byte position 0"))]
    NoDigits,

    /// The suffix following the integer was not recognised.
    #[snafu(display("max_size suffix `{suffix}` is not one of B/KB/MB/GB/TB/KiB/MiB/GiB/TiB"))]
    UnknownSuffix {
        /// The offending suffix substring.
        suffix: String,
    },

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

/// `cache.clean.max_size` value: a non-negative byte count
/// expressed per the `CFG-035` grammar.
///
/// Constructed via [`MaxSize::parse`] from its textual form.
///
/// # Examples
///
/// ```
/// use haz_domain::settings::cache_clean::max_size::MaxSize;
///
/// assert_eq!(MaxSize::parse("1024").unwrap().as_bytes(), 1024);
/// assert_eq!(MaxSize::parse("1B").unwrap().as_bytes(), 1);
/// assert_eq!(MaxSize::parse("5GB").unwrap().as_bytes(), 5_000_000_000);
/// assert_eq!(MaxSize::parse("200MiB").unwrap().as_bytes(), 200 * 1024 * 1024);
/// assert!(MaxSize::parse("5gb").is_err());
/// assert!(MaxSize::parse("").is_err());
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MaxSize(u64);

impl MaxSize {
    /// Parse a `max_size` string per the `CFG-035` grammar.
    ///
    /// # Errors
    ///
    /// Returns a [`MaxSizeParseError`] variant describing the first
    /// syntactic or semantic failure encountered in the input.
    pub fn parse(input: &str) -> Result<Self, MaxSizeParseError> {
        if input.is_empty() {
            return Err(MaxSizeParseError::Empty);
        }
        let bytes = input.as_bytes();
        let mut idx = 0usize;
        while idx < bytes.len() && bytes[idx].is_ascii_digit() {
            idx += 1;
        }
        if idx == 0 {
            return Err(MaxSizeParseError::NoDigits);
        }
        let number_str = &input[..idx];
        let suffix = &input[idx..];
        let multiplier: u64 = match suffix {
            "" | "B" => 1,
            "KB" => 1_000,
            "MB" => 1_000_000,
            "GB" => 1_000_000_000,
            "TB" => 1_000_000_000_000,
            "KiB" => 1024,
            "MiB" => 1024 * 1024,
            "GiB" => 1024 * 1024 * 1024,
            "TiB" => 1024_u64.pow(4),
            other => {
                return Err(MaxSizeParseError::UnknownSuffix {
                    suffix: other.to_owned(),
                });
            }
        };
        let n: u64 = number_str
            .parse()
            .map_err(|_| MaxSizeParseError::Overflow {
                input: input.to_owned(),
            })?;
        let total = n
            .checked_mul(multiplier)
            .ok_or_else(|| MaxSizeParseError::Overflow {
                input: input.to_owned(),
            })?;
        Ok(Self(total))
    }

    /// Borrow the wrapped byte count.
    #[must_use]
    pub const fn as_bytes(&self) -> u64 {
        self.0
    }
}

#[cfg(test)]
mod tests {
    use super::{MaxSize, MaxSizeParseError};

    #[test]
    fn parse_accepts_bare_integer_as_bytes() {
        assert_eq!(MaxSize::parse("1024").unwrap().as_bytes(), 1024);
    }

    #[test]
    fn parse_accepts_b_suffix() {
        assert_eq!(MaxSize::parse("1B").unwrap().as_bytes(), 1);
    }

    #[test]
    fn parse_accepts_si_suffixes() {
        assert_eq!(MaxSize::parse("5KB").unwrap().as_bytes(), 5_000);
        assert_eq!(MaxSize::parse("5MB").unwrap().as_bytes(), 5_000_000);
        assert_eq!(MaxSize::parse("5GB").unwrap().as_bytes(), 5_000_000_000);
        assert_eq!(MaxSize::parse("5TB").unwrap().as_bytes(), 5_000_000_000_000);
    }

    #[test]
    fn parse_accepts_iec_suffixes() {
        assert_eq!(MaxSize::parse("5KiB").unwrap().as_bytes(), 5 * 1024);
        assert_eq!(MaxSize::parse("5MiB").unwrap().as_bytes(), 5 * 1024 * 1024);
        assert_eq!(
            MaxSize::parse("5GiB").unwrap().as_bytes(),
            5 * 1024 * 1024 * 1024
        );
        assert_eq!(
            MaxSize::parse("5TiB").unwrap().as_bytes(),
            5_u64 * 1024_u64.pow(4),
        );
    }

    #[test]
    fn parse_accepts_zero() {
        assert_eq!(MaxSize::parse("0").unwrap().as_bytes(), 0);
        assert_eq!(MaxSize::parse("0B").unwrap().as_bytes(), 0);
        assert_eq!(MaxSize::parse("0GB").unwrap().as_bytes(), 0);
    }

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

    #[test]
    fn parse_rejects_lowercase_suffix() {
        match MaxSize::parse("5gb") {
            Err(MaxSizeParseError::UnknownSuffix { suffix }) => assert_eq!(suffix, "gb"),
            other => panic!("expected UnknownSuffix, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_whitespace() {
        match MaxSize::parse("5 GB") {
            Err(MaxSizeParseError::UnknownSuffix { suffix }) => assert_eq!(suffix, " GB"),
            other => panic!("expected UnknownSuffix, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_fractional() {
        // `1.5GB` -> digits halt at `1`; suffix is `.5GB`.
        match MaxSize::parse("1.5GB") {
            Err(MaxSizeParseError::UnknownSuffix { suffix }) => assert_eq!(suffix, ".5GB"),
            other => panic!("expected UnknownSuffix, got {other:?}"),
        }
    }

    #[test]
    fn parse_rejects_leading_non_digit() {
        assert_eq!(MaxSize::parse("GB"), Err(MaxSizeParseError::NoDigits));
    }

    #[test]
    fn parse_rejects_overflow() {
        let huge = format!("{}TiB", u64::MAX);
        match MaxSize::parse(&huge) {
            Err(MaxSizeParseError::Overflow { input }) => assert_eq!(input, huge),
            other => panic!("expected Overflow, got {other:?}"),
        }
    }
}