use std::fmt;
use regress::Regex;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Scalar {
Text(Text),
Integer(Bounds<i64>),
Number(Bounds<f64>),
Boolean,
Choice(Vec<String>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Text {
pub pattern: Option<String>,
pub min_length: Option<usize>,
pub max_length: Option<usize>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct Bounds<T> {
pub low: Option<Limit<T>>,
pub high: Option<Limit<T>>,
pub multiple_of: Option<T>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Limit<T> {
Inclusive(T),
Exclusive(T),
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum ScalarError {
#[error("`{raw}` is not {wanted}")]
Kind { raw: String, wanted: &'static str },
#[error("`{raw}` is not one of {}", .allowed.join(", "))]
Choice { raw: String, allowed: Vec<String> },
#[error("`{raw}` {rule}")]
Rule { raw: String, rule: String },
#[error("`{pattern}` is not a regular expression: {message}")]
Pattern { pattern: String, message: String },
}
impl ScalarError {
fn rule(raw: &str, rule: String) -> Self {
Self::Rule {
raw: raw.to_owned(),
rule,
}
}
}
impl Scalar {
pub fn parse(&self, raw: &str) -> Result<serde_json::Value, ScalarError> {
match self {
Self::Text(text) => text.check(raw).map(|()| raw.into()),
Self::Integer(bounds) => bounded(bounds, raw),
Self::Number(bounds) => bounded(bounds, raw),
Self::Boolean => raw
.parse::<bool>()
.map(Into::into)
.map_err(|_| ScalarError::Kind {
raw: raw.to_owned(),
wanted: "`true` or `false`",
}),
Self::Choice(allowed) => {
if allowed.iter().any(|value| value == raw) {
Ok(raw.into())
} else {
Err(ScalarError::Choice {
raw: raw.to_owned(),
allowed: allowed.clone(),
})
}
}
}
}
pub fn runnable(&self) -> Result<(), ScalarError> {
match self {
Self::Text(text) => text.pattern.as_deref().map(compiled).transpose().map(drop),
Self::Integer(_) | Self::Number(_) | Self::Boolean | Self::Choice(_) => Ok(()),
}
}
#[must_use]
pub fn value_name(&self) -> &'static str {
match self {
Self::Text(_) | Self::Choice(_) => "STRING",
Self::Integer(_) => "INT",
Self::Number(_) => "NUMBER",
Self::Boolean => "BOOL",
}
}
#[must_use]
pub fn note(&self) -> Option<String> {
let notes = match self {
Self::Text(text) => text.notes(),
Self::Integer(bounds) => bounds.notes(),
Self::Number(bounds) => bounds.notes(),
Self::Boolean | Self::Choice(_) => Vec::new(),
};
(!notes.is_empty()).then(|| notes.join("; "))
}
}
impl Text {
fn check(&self, raw: &str) -> Result<(), ScalarError> {
let length = raw.chars().count();
if let Some(least) = self.min_length
&& length < least
{
return Err(ScalarError::rule(
raw,
format!("is shorter than {least} characters"),
));
}
if let Some(most) = self.max_length
&& length > most
{
return Err(ScalarError::rule(
raw,
format!("is longer than {most} characters"),
));
}
let Some(pattern) = self.pattern.as_deref() else {
return Ok(());
};
if compiled(pattern)?.find(raw).is_none() {
return Err(ScalarError::rule(raw, format!("does not match {pattern}")));
}
Ok(())
}
fn notes(&self) -> Vec<String> {
let mut notes = Vec::new();
if let Some(least) = self.min_length {
notes.push(format!("at least {least} characters"));
}
if let Some(most) = self.max_length {
notes.push(format!("at most {most} characters"));
}
if let Some(pattern) = &self.pattern {
notes.push(format!("matches {pattern}"));
}
notes
}
}
impl<T: Numeric> Bounds<T> {
fn check(&self, value: T, raw: &str) -> Result<(), ScalarError> {
let refuse = |rule: String| ScalarError::rule(raw, format!("is not {rule}"));
if let Some(low) = self.low {
low.floor(value).map_err(&refuse)?;
}
if let Some(high) = self.high {
high.ceiling(value).map_err(&refuse)?;
}
if let Some(step) = self.multiple_of
&& !value.divisible_by(step)
{
return Err(refuse(format!("a multiple of {step}")));
}
Ok(())
}
fn notes(&self) -> Vec<String> {
let mut notes = Vec::new();
if let Some(low) = self.low {
notes.push(low.note("at least", "more than"));
}
if let Some(high) = self.high {
notes.push(high.note("at most", "less than"));
}
if let Some(step) = self.multiple_of {
notes.push(format!("a multiple of {step}"));
}
notes
}
}
impl<T: Numeric> Limit<T> {
fn floor(self, value: T) -> Result<(), String> {
let admits = match self {
Self::Inclusive(limit) => value >= limit,
Self::Exclusive(limit) => value > limit,
};
if admits {
Ok(())
} else {
Err(self.note("at least", "more than"))
}
}
fn ceiling(self, value: T) -> Result<(), String> {
let admits = match self {
Self::Inclusive(limit) => value <= limit,
Self::Exclusive(limit) => value < limit,
};
if admits {
Ok(())
} else {
Err(self.note("at most", "less than"))
}
}
fn note(self, inclusive: &str, exclusive: &str) -> String {
match self {
Self::Inclusive(limit) => format!("{inclusive} {limit}"),
Self::Exclusive(limit) => format!("{exclusive} {limit}"),
}
}
}
fn compiled(pattern: &str) -> Result<Regex, ScalarError> {
Regex::new(pattern).map_err(|error| ScalarError::Pattern {
pattern: pattern.to_owned(),
message: error.to_string(),
})
}
fn bounded<T: Numeric>(bounds: &Bounds<T>, raw: &str) -> Result<serde_json::Value, ScalarError> {
let (value, json) = T::read(raw).ok_or_else(|| ScalarError::Kind {
raw: raw.to_owned(),
wanted: T::KIND,
})?;
bounds.check(value, raw)?;
Ok(json)
}
pub trait Numeric: Copy + PartialOrd + fmt::Display {
const KIND: &'static str;
fn read(raw: &str) -> Option<(Self, serde_json::Value)>;
fn divisible_by(self, step: Self) -> bool;
}
impl Numeric for i64 {
const KIND: &'static str = "an integer";
fn read(raw: &str) -> Option<(Self, serde_json::Value)> {
raw.parse::<Self>().ok().map(|value| (value, value.into()))
}
fn divisible_by(self, step: Self) -> bool {
step != 0 && self % step == 0
}
}
impl Numeric for f64 {
const KIND: &'static str = "a number";
fn read(raw: &str) -> Option<(Self, serde_json::Value)> {
let value = raw.parse::<Self>().ok()?;
Some((value, serde_json::Number::from_f64(value)?.into()))
}
fn divisible_by(self, step: Self) -> bool {
step != 0.0 && (self / step).fract() == 0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn text(pattern: &str) -> Scalar {
Scalar::Text(Text {
pattern: Some(pattern.to_owned()),
..Text::default()
})
}
#[test]
fn a_pattern_is_enforced_and_reads_back_in_the_documents_own_spelling() {
let amount = text(r"^-?[0-9]+(\.[0-9]{1,2})?$");
for raw in ["12.50", "0", "0.0", "-3.07", "1000000.00"] {
assert_eq!(amount.parse(raw), Ok(raw.into()), "{raw}");
}
assert_eq!(
amount.parse("1,50"),
Err(ScalarError::Rule {
raw: "1,50".to_owned(),
rule: r"does not match ^-?[0-9]+(\.[0-9]{1,2})?$".to_owned(),
})
);
for raw in ["12.5x", "", "12.505", ".5", "-", "1e3"] {
assert!(amount.parse(raw).is_err(), "{raw}");
}
}
#[test]
fn an_unanchored_pattern_matches_anywhere_in_the_value() {
let digits = text("[0-9]+");
assert!(digits.parse("ab12cd").is_ok());
assert!(digits.parse("abcd").is_err());
assert!(text("^[A-Z]+$").parse("xyZ").is_err());
}
#[test]
fn a_pattern_the_engine_cannot_read_refuses_every_value() {
let broken = text("[unterminated");
assert!(matches!(
broken.runnable(),
Err(ScalarError::Pattern { .. })
));
assert!(matches!(
broken.parse("anything"),
Err(ScalarError::Pattern { .. })
));
assert_eq!(text("^ok$").runnable(), Ok(()));
}
#[test]
fn lengths_are_counted_in_characters_and_refused_with_the_documents_number() {
let code = Scalar::Text(Text {
min_length: Some(3),
max_length: Some(3),
..Text::default()
});
assert!(code.parse("EUR").is_ok());
assert!(code.parse("€€€").is_ok());
assert_eq!(
code.parse("EU"),
Err(ScalarError::Rule {
raw: "EU".to_owned(),
rule: "is shorter than 3 characters".to_owned(),
})
);
assert_eq!(
code.parse("EURO"),
Err(ScalarError::Rule {
raw: "EURO".to_owned(),
rule: "is longer than 3 characters".to_owned(),
})
);
}
#[test]
fn an_inclusive_bound_admits_its_own_value_and_an_exclusive_one_does_not() {
let inclusive = Scalar::Integer(Bounds {
low: Some(Limit::Inclusive(1)),
high: Some(Limit::Inclusive(100)),
multiple_of: None,
});
assert!(inclusive.parse("1").is_ok());
assert!(inclusive.parse("100").is_ok());
assert_eq!(
inclusive.parse("0"),
Err(ScalarError::Rule {
raw: "0".to_owned(),
rule: "is not at least 1".to_owned(),
})
);
assert_eq!(
inclusive.parse("101"),
Err(ScalarError::Rule {
raw: "101".to_owned(),
rule: "is not at most 100".to_owned(),
})
);
let exclusive = Scalar::Number(Bounds {
low: Some(Limit::Exclusive(0.0)),
high: Some(Limit::Exclusive(1.0)),
multiple_of: None,
});
assert!(exclusive.parse("0.5").is_ok());
assert_eq!(
exclusive.parse("0"),
Err(ScalarError::Rule {
raw: "0".to_owned(),
rule: "is not more than 0".to_owned(),
})
);
assert_eq!(
exclusive.parse("1"),
Err(ScalarError::Rule {
raw: "1".to_owned(),
rule: "is not less than 1".to_owned(),
})
);
}
#[test]
fn a_step_is_enforced_for_both_number_kinds() {
let by_five = Scalar::Integer(Bounds {
multiple_of: Some(5),
..Bounds::default()
});
assert!(by_five.parse("15").is_ok());
assert_eq!(
by_five.parse("7"),
Err(ScalarError::Rule {
raw: "7".to_owned(),
rule: "is not a multiple of 5".to_owned(),
})
);
let by_quarter = Scalar::Number(Bounds {
multiple_of: Some(0.25),
..Bounds::default()
});
assert!(by_quarter.parse("1.75").is_ok());
assert!(by_quarter.parse("1.3").is_err());
}
#[test]
fn a_choice_outside_the_enum_is_rejected_with_the_alternatives() {
let status = Scalar::Choice(vec!["draft".to_owned(), "paid".to_owned()]);
assert_eq!(
status.parse("void"),
Err(ScalarError::Choice {
raw: "void".to_owned(),
allowed: vec!["draft".to_owned(), "paid".to_owned()],
})
);
assert!(status.parse("paid").is_ok());
}
#[test]
fn integers_stay_integers_in_json() {
let plain = Scalar::Integer(Bounds::default());
assert_eq!(plain.parse("5"), Ok(serde_json::json!(5)));
assert_eq!(
plain.parse("5.0"),
Err(ScalarError::Kind {
raw: "5.0".to_owned(),
wanted: "an integer",
})
);
}
#[test]
fn a_number_json_cannot_carry_is_refused_rather_than_nulled() {
let plain = Scalar::Number(Bounds::default());
assert_eq!(plain.parse("1.5"), Ok(serde_json::json!(1.5)));
for raw in ["inf", "-inf", "NaN"] {
assert_eq!(
plain.parse(raw),
Err(ScalarError::Kind {
raw: raw.to_owned(),
wanted: "a number",
}),
"{raw}"
);
}
}
#[test]
fn every_note_is_a_rule_that_is_enforced() {
let code = Scalar::Text(Text {
pattern: Some("^[A-Z]+$".to_owned()),
min_length: Some(2),
max_length: Some(4),
});
assert_eq!(
code.note().as_deref(),
Some("at least 2 characters; at most 4 characters; matches ^[A-Z]+$")
);
assert!(code.parse("A").is_err());
assert!(code.parse("ABCDE").is_err());
assert!(code.parse("ab").is_err());
assert!(code.parse("AB").is_ok());
let count = Scalar::Integer(Bounds {
low: Some(Limit::Inclusive(1)),
high: Some(Limit::Exclusive(10)),
multiple_of: Some(3),
});
assert_eq!(
count.note().as_deref(),
Some("at least 1; less than 10; a multiple of 3")
);
assert_eq!(Scalar::Boolean.note(), None);
assert_eq!(Scalar::Text(Text::default()).note(), None);
}
}