#![expect(
clippy::disallowed_types,
reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
exchanges), whose shapes belong to the artifacts and the SUT"
)]
use std::fmt;
use thiserror::Error;
pub const MAX_NESTING: usize = 32;
#[derive(Debug, Error)]
#[error("invalid decision-table literal {text:?}: {reason}")]
pub struct LiteralError {
text: String,
reason: String,
}
impl LiteralError {
fn new(text: &str, reason: impl Into<String>) -> Self {
Self {
text: text.to_owned(),
reason: reason.into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Literal {
Null,
Bool(bool),
Integer(i64),
Real(f64),
Text(String),
Range {
lo: f64,
hi: f64,
},
Iso8601Range {
lo: String,
hi: String,
},
List(Vec<Literal>),
UnitRange {
units: String,
lo: f64,
hi: f64,
},
TermCode {
terminology: String,
code: String,
rubric: Option<String>,
},
Ordinal {
value: i64,
symbol: Box<Literal>,
},
Scale {
value: f64,
symbol: Box<Literal>,
},
Quantity {
magnitude: f64,
units: String,
},
}
impl Literal {
pub fn from_cell(value: &serde_json::Value) -> Result<Self, LiteralError> {
match value {
serde_json::Value::Null => Ok(Self::Null),
serde_json::Value::Bool(b) => Ok(Self::Bool(*b)),
serde_json::Value::Number(n) => n
.as_i64()
.map(Self::Integer)
.or_else(|| n.as_f64().map(Self::Real))
.ok_or_else(|| LiteralError::new(&n.to_string(), "number is neither i64 nor f64")),
serde_json::Value::String(s) => Self::from_text(s),
other => Err(LiteralError::new(
&other.to_string(),
"cell must be a scalar or grammar string",
)),
}
}
pub fn from_text(s: &str) -> Result<Self, LiteralError> {
Self::from_text_nested(s, 0)
}
fn from_text_nested(s: &str, depth: usize) -> Result<Self, LiteralError> {
if depth > MAX_NESTING {
return Err(LiteralError::new(
s,
format!("literal nests past the {MAX_NESTING}-level ceiling"),
));
}
let t = s.trim();
if t.starts_with('[') {
return Self::parse_list(t, depth);
}
if let Some((value, symbol)) = t.split_once('|') {
if let Ok(value) = value.trim().parse::<i64>() {
let symbol = Self::from_text_nested(symbol, depth + 1)?;
return Ok(Self::Ordinal {
value,
symbol: Box::new(symbol),
});
}
if let Ok(value) = value.trim().parse::<f64>() {
let symbol = Self::from_text_nested(symbol, depth + 1)?;
return Ok(Self::Scale {
value,
symbol: Box::new(symbol),
});
}
}
if let Some((head, _)) = t.split_once("::")
&& !head.is_empty()
&& head.trim().chars().all(is_term_lexeme_char)
{
return Self::parse_term_code(t);
}
if t.contains("..") {
return Self::parse_scoped_range(t);
}
if let Some(q) = Self::try_quantity(t) {
return Ok(q);
}
Ok(Self::Text(t.to_owned()))
}
fn parse_list(t: &str, depth: usize) -> Result<Self, LiteralError> {
let inner = t
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.ok_or_else(|| LiteralError::new(t, "unterminated list"))?;
let mut items = Vec::new();
for item in split_top_level(inner) {
let item = item.trim();
if item.is_empty() {
return Err(LiteralError::new(t, "empty list item"));
}
items.push(Self::from_text_nested(item, depth + 1)?);
}
Ok(Self::List(items))
}
fn parse_scoped_range(t: &str) -> Result<Self, LiteralError> {
let (units, range) = match t.rsplit_once(' ') {
Some((units, range)) if range.contains("..") => (Some(units.trim()), range.trim()),
_ => (None, t),
};
let (lo_raw, hi_raw) = range
.split_once("..")
.ok_or_else(|| LiteralError::new(t, "range must be a..b"))?;
let (lo_raw, hi_raw) = (lo_raw.trim(), hi_raw.trim());
let (Ok(lo), Ok(hi)) = (lo_raw.parse::<f64>(), hi_raw.parse::<f64>()) else {
if units.is_none() && is_iso8601_lexeme(lo_raw) && is_iso8601_lexeme(hi_raw) {
return Ok(Self::Iso8601Range {
lo: lo_raw.to_owned(),
hi: hi_raw.to_owned(),
});
}
return Err(LiteralError::new(
t,
"range bounds must both be numbers or both ISO 8601 lexemes",
));
};
match units {
Some(units) if !units.is_empty() => Ok(Self::UnitRange {
units: units.to_owned(),
lo,
hi,
}),
_ => Ok(Self::Range { lo, hi }),
}
}
fn parse_term_code(t: &str) -> Result<Self, LiteralError> {
let (terminology, rest) = t.split_once("::").ok_or_else(|| {
LiteralError::new(t, "terminology code must be <terminology>::<code>")
})?;
let terminology = terminology.trim();
if terminology.is_empty() || !terminology.chars().all(is_term_lexeme_char) {
return Err(LiteralError::new(t, "terminology name must be one word"));
}
let (code, rubric) = match rest.split_once('(') {
Some((code, rubric)) => {
let rubric = rubric
.strip_suffix(')')
.ok_or_else(|| LiteralError::new(t, "unterminated rubric"))?;
(code.trim(), Some(rubric.trim().to_owned()))
}
None => (rest.trim(), None),
};
if code.is_empty() || !code.chars().all(is_term_lexeme_char) {
return Err(LiteralError::new(t, "code must be one word"));
}
Ok(Self::TermCode {
terminology: terminology.to_owned(),
code: code.to_owned(),
rubric,
})
}
fn try_quantity(t: &str) -> Option<Self> {
let (magnitude, units) = t.split_once(' ')?;
let magnitude: f64 = magnitude.trim().parse().ok()?;
let units = units.trim();
if units.is_empty() || units.contains(char::is_whitespace) {
return None;
}
Some(Self::Quantity {
magnitude,
units: units.to_owned(),
})
}
}
fn is_term_lexeme_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')
}
fn is_iso8601_lexeme(s: &str) -> bool {
static ISO: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
#[expect(clippy::unwrap_used, reason = "a compile-time-constant pattern")]
regex::Regex::new(
r"^(\d{4}(-\d{2}(-\d{2})?)?(T\d{2}(:\d{2}(:\d{2}(\.\d+)?)?)?(Z|[+-]\d{2}(:?\d{2})?)?)?|\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)?|P(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?)$",
)
.unwrap()
});
!s.is_empty() && s != "P" && ISO.is_match(s)
}
fn split_top_level(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut depth = 0_i32;
let mut start = 0_usize;
for (i, c) in s.char_indices() {
match c {
'[' | '(' => depth += 1,
']' | ')' => depth -= 1,
',' if depth == 0 => {
parts.push(s.get(start..i).unwrap_or_default());
start = i + 1;
}
_ => {}
}
}
parts.push(s.get(start..).unwrap_or_default());
parts
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViolationCategory {
RmSchema,
RmInvariant,
Iso8601,
Constraint,
}
impl ViolationCategory {
fn parse(token: &str) -> Option<Self> {
match token {
"rm_schema" => Some(Self::RmSchema),
"rm_invariant" => Some(Self::RmInvariant),
"iso8601" => Some(Self::Iso8601),
"constraint" => Some(Self::Constraint),
_ => None,
}
}
#[must_use]
pub fn token(self) -> &'static str {
match self {
Self::RmSchema => "rm_schema",
Self::RmInvariant => "rm_invariant",
Self::Iso8601 => "iso8601",
Self::Constraint => "constraint",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViolationRef {
pub category: ViolationCategory,
pub argument: Option<String>,
pub description: String,
}
impl ViolationRef {
pub fn parse(raw: &str) -> Result<Self, LiteralError> {
let (head, description) = raw.split_once(':').ok_or_else(|| {
LiteralError::new(raw, "violation must be <category>[(arg)]: <description>")
})?;
let description = description.trim();
if description.is_empty() {
return Err(LiteralError::new(raw, "violation description is empty"));
}
let head = head.trim();
let (token, argument) = match head.split_once('(') {
Some((token, arg)) => {
let arg = arg
.strip_suffix(')')
.ok_or_else(|| LiteralError::new(raw, "unterminated category argument"))?;
(token.trim(), Some(arg.trim().to_owned()))
}
None => (head, None),
};
let category = ViolationCategory::parse(token)
.ok_or_else(|| LiteralError::new(raw, "unknown violation category"))?;
match category {
ViolationCategory::RmInvariant
| ViolationCategory::Iso8601
| ViolationCategory::Constraint
if argument.is_none() =>
{
return Err(LiteralError::new(
raw,
"category requires a (name) argument",
));
}
ViolationCategory::RmSchema if argument.is_some() => {
return Err(LiteralError::new(raw, "rm_schema takes no argument"));
}
_ => {}
}
Ok(Self {
category,
argument,
description: description.to_owned(),
})
}
}
impl fmt::Display for ViolationRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.argument {
Some(arg) => write!(f, "{}({arg}): {}", self.category.token(), self.description),
None => write!(f, "{}: {}", self.category.token(), self.description),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn official_literals_parse() {
assert!(matches!(
Literal::from_text("5.0..10.0").unwrap(),
Literal::Range { .. }
));
let list = Literal::from_text("[cm 5.0..10.0, m]").unwrap();
let Literal::List(items) = list else {
panic!("expected list")
};
assert!(matches!(items.first(), Some(Literal::UnitRange { .. })));
assert!(matches!(items.get(1), Some(Literal::Text(t)) if t == "m"));
assert!(matches!(
Literal::from_text("openehr::122 (length)").unwrap(),
Literal::TermCode {
rubric: Some(_),
..
}
));
assert!(matches!(
Literal::from_text("local::at0005").unwrap(),
Literal::TermCode { rubric: None, .. }
));
assert!(matches!(
Literal::from_text("1|[local::at0005]").unwrap(),
Literal::Ordinal { .. }
));
assert!(matches!(
Literal::from_text("100 mg").unwrap(),
Literal::Quantity { .. }
));
assert!(matches!(
Literal::from_cell(&serde_json::Value::Null).unwrap(),
Literal::Null
));
assert!(matches!(
Literal::from_text("cm").unwrap(),
Literal::Text(_)
));
}
#[test]
fn iso8601_ranges_and_scale_tuples_parse() {
assert!(matches!(
Literal::from_text("2020-01..2030-12").unwrap(),
Literal::Iso8601Range { .. }
));
assert!(matches!(
Literal::from_text("2000-01-01T00:00:00.0..2010-12-31T23:59:59.999999").unwrap(),
Literal::Iso8601Range { .. }
));
assert!(matches!(
Literal::from_text("PT0S..PT2H").unwrap(),
Literal::Iso8601Range { .. }
));
assert!(matches!(
Literal::from_text("10:00..12:00").unwrap(),
Literal::Iso8601Range { .. }
));
assert!(matches!(
Literal::from_text("1900..2030").unwrap(),
Literal::Range { .. }
));
assert!(matches!(
Literal::from_text("1.5|[local::at0005]").unwrap(),
Literal::Scale { .. }
));
assert!(Literal::from_text("banana..apple").is_err());
let list = Literal::from_text("[1.5|[local::at0005], 2.4|[local::at0006]]").unwrap();
let Literal::List(items) = list else {
panic!("expected list")
};
assert!(matches!(items.first(), Some(Literal::Scale { .. })));
}
#[test]
fn structured_looking_strings_must_parse() {
assert!(Literal::from_text("5.0..").is_err());
assert!(Literal::from_text("[cm 5.0..10.0, m").is_err());
assert!(Literal::from_text("openehr:: (length").is_err());
}
#[test]
fn deep_nesting_is_refused_rather_than_overflowing_the_stack() {
let lists = format!("{}1{}", "[".repeat(4000), "]".repeat(4000));
let error = Literal::from_text(&lists).expect_err("nested lists past the ceiling");
assert!(error.to_string().contains("ceiling"), "{error}");
let tuples = format!("{}1", "1|".repeat(4000));
let error = Literal::from_text(&tuples).expect_err("chained tuples past the ceiling");
assert!(error.to_string().contains("ceiling"), "{error}");
assert!(Literal::from_text("[1.5|[local::at0005], 2.4|[local::at0006]]").is_ok());
}
#[test]
fn violations_parse() {
let v =
ViolationRef::parse("constraint(C_DV_QUANTITY.list): magnitude not in range for unit")
.unwrap();
assert_eq!(v.category, ViolationCategory::Constraint);
assert_eq!(v.argument.as_deref(), Some("C_DV_QUANTITY.list"));
let v = ViolationRef::parse("rm_schema: magnitude and units are mandatory").unwrap();
assert_eq!(v.category, ViolationCategory::RmSchema);
assert!(ViolationRef::parse("rm_invariant: missing name").is_err());
assert!(ViolationRef::parse("bogus: nope").is_err());
assert!(ViolationRef::parse("rm_schema(arg): no args allowed").is_err());
}
#[test]
fn a_composite_cell_has_no_literal_production() {
let error = Literal::from_cell(&serde_json::json!({ "magnitude": 5 }))
.expect_err("an object cell has no production");
assert!(
error
.to_string()
.contains("cell must be a scalar or grammar string"),
"{error}"
);
let error = Literal::from_cell(&serde_json::json!([1, 2]))
.expect_err("an array cell has no production");
assert!(
error
.to_string()
.contains("cell must be a scalar or grammar string"),
"{error}"
);
}
#[test]
fn an_empty_list_item_is_refused() {
let error = Literal::from_text("[cm, , m]").expect_err("an empty item is not a value");
assert!(error.to_string().contains("empty list item"), "{error}");
}
#[test]
fn a_term_code_carries_one_word_for_its_code() {
let error =
Literal::from_text("openehr::at 0005").expect_err("a two-word code is not a code");
assert!(
error.to_string().contains("code must be one word"),
"{error}"
);
}
#[test]
fn a_multi_word_tail_is_text_rather_than_a_quantity() {
assert_eq!(
Literal::from_text("100 mg per day").unwrap(),
Literal::Text("100 mg per day".to_owned())
);
}
#[test]
fn violations_render_back_and_refuse_a_missing_description() {
for raw in [
"constraint(C_DV_QUANTITY.list): magnitude not in range for unit",
"rm_invariant(limits_consistent): lower exceeds upper",
"iso8601(duration): the P designator carries no components",
"rm_schema: magnitude and units are mandatory",
] {
assert_eq!(ViolationRef::parse(raw).unwrap().to_string(), raw);
}
let error = ViolationRef::parse("rm_schema").expect_err("a head alone is not a violation");
assert!(
error
.to_string()
.contains("violation must be <category>[(arg)]: <description>"),
"{error}"
);
let error = ViolationRef::parse("rm_schema: ").expect_err("an empty description");
assert!(
error.to_string().contains("violation description is empty"),
"{error}"
);
}
}