#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumericError {
pub raw: String,
pub reason: &'static str,
}
impl std::fmt::Display for NumericError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "numeric {:?} is not a u64: {}", self.raw, self.reason)
}
}
impl std::error::Error for NumericError {}
pub fn to_numeric_text(value: u64) -> String {
value.to_string()
}
pub fn from_numeric_text(raw: &str) -> Result<u64, NumericError> {
let bad = |reason: &'static str| NumericError {
raw: raw.to_owned(),
reason,
};
if raw.is_empty() {
return Err(bad("empty"));
}
if !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad(
"expected only ASCII digits (scale-0 numeric renders bare)",
));
}
raw.parse::<u64>().map_err(|_| bad("out of u64 range"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_boundary_survives_the_round_trip() {
for value in [
0,
1,
9_007_199_254_740_992, 9_007_199_254_740_993, 9_223_372_036_854_775_807, 9_223_372_036_854_775_808, u64::MAX,
] {
let text = to_numeric_text(value);
assert_eq!(from_numeric_text(&text), Ok(value), "round trip of {value}");
}
assert_eq!(to_numeric_text(u64::MAX), "18446744073709551615");
}
proptest::proptest! {
#[test]
fn a_counter_survives_the_round_trip_whatever_it_is(value: u64) {
proptest::prop_assert_eq!(from_numeric_text(&to_numeric_text(value)), Ok(value));
}
#[test]
fn only_bare_ascii_digits_parse(
raw in proptest::prop_oneof![
r"[0-9]{0,24}",
r"[-+0-9.eE ]{0,12}",
r"\PC*",
],
) {
let bare = !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_digit());
let parsed = from_numeric_text(&raw);
if bare {
proptest::prop_assert!(parsed.is_ok() || raw.parse::<u64>().is_err());
} else {
proptest::prop_assert!(parsed.is_err(), "{raw:?} parsed as a counter");
}
}
}
#[test]
fn anything_that_is_not_a_bare_integer_is_refused() {
for raw in [
"",
" 4",
"4 ",
"+4",
"-1",
"4.0",
"4e2",
"NaN",
"0x10",
"18446744073709551616", "99999999999999999999", ] {
assert!(
from_numeric_text(raw).is_err(),
"{raw:?} must not parse as a counter"
);
}
}
}