use std::collections::BTreeMap;
use std::fmt;
use crate::v1_1::bmm::core::bmm_class::BmmClass;
use crate::v1_1::bmm::core::bmm_model::BmmModel;
use crate::v1_1::bmm::core::bmm_property::BmmProperty;
use crate::v1_1::bmm::core::bmm_type::BmmType;
use crate::v1_1::bmm_persistence::create_model::qualify;
use crate::v1_1::bmm_persistence::p_bmm_class::PBmmClass;
use crate::v1_1::bmm_persistence::p_bmm_package::PBmmPackage;
use crate::v1_1::bmm_persistence::p_bmm_schema::PBmmSchema;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PBmmValidityFinding {
ClassNotInExactlyOnePackage {
class: String,
packages: Vec<String>,
},
ClassNameNotUnique {
definitions: Vec<String>,
},
AssertionNotMaterialised {
class: String,
routine: Option<String>,
kind: AssertionKind,
tag: String,
expression: String,
reason: String,
},
ConstantNotMaterialised {
class: String,
constant: String,
},
OverriddenPropertyNonConformance {
class: String,
property: String,
ancestor: String,
redefined_type: String,
overridden_type: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssertionKind {
Invariant,
PreCondition,
PostCondition,
}
impl fmt::Display for AssertionKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Invariant => "invariant",
Self::PreCondition => "pre-condition",
Self::PostCondition => "post-condition",
};
formatter.write_str(text)
}
}
impl fmt::Display for PBmmValidityFinding {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ClassNotInExactlyOnePackage { class, packages } => write!(
formatter,
"class `{class}` is contained within {} package listing(s) ({}); \
a class must be contained within exactly one package",
packages.len(),
packages.join(", ")
),
Self::ClassNameNotUnique { definitions } => write!(
formatter,
"{} class definitions share one name ({}); \
all classes in a BMM model must be uniquely named",
definitions.len(),
definitions.join(", ")
),
Self::AssertionNotMaterialised {
class,
routine,
kind,
tag,
expression,
reason,
} => {
let owner = routine.as_ref().map_or_else(
|| format!("class `{class}`"),
|routine| format!("class `{class}` routine `{routine}`"),
);
write!(
formatter,
"{owner} {kind} `{tag}` ({expression:?}) is not materialisable as a BMM_ASSERTION: {reason}"
)
}
Self::ConstantNotMaterialised { class, constant } => write!(
formatter,
"class `{class}` constant `{constant}` states no value, so it is not \
materialisable as a BMM_CONSTANT"
),
Self::OverriddenPropertyNonConformance {
class,
property,
ancestor,
redefined_type,
overridden_type,
} => write!(
formatter,
"class `{class}` redefines property `{property}` as `{redefined_type}`, \
which does not conform to `{overridden_type}` as declared by `{ancestor}`"
),
}
}
}
#[must_use]
pub fn validate_schema(schema: &PBmmSchema, model: &BmmModel) -> Vec<PBmmValidityFinding> {
let mut findings = Vec::new();
check_package_containment(schema, &mut findings);
check_class_name_uniqueness(schema, &mut findings);
check_overridden_property_conformance(model, &mut findings);
findings
}
fn schema_classes(schema: &PBmmSchema) -> impl Iterator<Item = &PBmmClass> {
in_document_order(schema.primitive_types.as_ref())
.into_iter()
.chain(in_document_order(schema.class_definitions.as_ref()))
}
fn in_document_order(list: Option<&BTreeMap<String, PBmmClass>>) -> Vec<&PBmmClass> {
let mut classes: Vec<&PBmmClass> = list.into_iter().flat_map(BTreeMap::values).collect();
classes.sort_by_key(|class| class.uid());
classes
}
fn check_package_containment(schema: &PBmmSchema, findings: &mut Vec<PBmmValidityFinding>) {
let mut listings: BTreeMap<String, Vec<String>> = BTreeMap::new();
collect_class_listings(&schema.packages, "", &mut listings);
for class in schema_classes(schema) {
let packages = listings
.get(&class.name().to_uppercase())
.cloned()
.unwrap_or_default();
if packages.len() != 1 {
findings.push(PBmmValidityFinding::ClassNotInExactlyOnePackage {
class: class.name().to_owned(),
packages,
});
}
}
}
fn collect_class_listings(
packages: &BTreeMap<String, PBmmPackage>,
prefix: &str,
out: &mut BTreeMap<String, Vec<String>>,
) {
for package in packages.values() {
let path = qualify(prefix, &package.name);
for class in package.classes.iter().flatten() {
out.entry(class.to_uppercase())
.or_default()
.push(path.clone());
}
collect_class_listings(&package.packages, &path, out);
}
}
fn check_class_name_uniqueness(schema: &PBmmSchema, findings: &mut Vec<PBmmValidityFinding>) {
let mut spellings: BTreeMap<String, Vec<&PBmmClass>> = BTreeMap::new();
for class in schema_classes(schema) {
spellings
.entry(class.name().to_uppercase())
.or_default()
.push(class);
}
for classes in spellings.into_values() {
let definitions: Vec<String> = classes.iter().map(|c| c.name().to_owned()).collect();
if definitions.len() > 1 {
findings.push(PBmmValidityFinding::ClassNameNotUnique { definitions });
}
}
}
fn check_overridden_property_conformance(
model: &BmmModel,
findings: &mut Vec<PBmmValidityFinding>,
) {
for class in model.class_definitions.iter().flatten().map(|(_, c)| c) {
for property in class.properties().into_iter().flatten().map(|(_, p)| p) {
let Some((ancestor, overridden)) = nearest_overridden(model, class, property.name())
else {
continue;
};
let redefined_type = property.type_name();
let overridden_type = overridden.type_name();
if !model.type_conforms_to(&redefined_type, &overridden_type) {
findings.push(PBmmValidityFinding::OverriddenPropertyNonConformance {
class: class.name().to_owned(),
property: property.name().to_owned(),
ancestor,
redefined_type,
overridden_type,
});
}
}
}
}
fn nearest_overridden<'a>(
model: &'a BmmModel,
class: &BmmClass,
property: &str,
) -> Option<(String, &'a BmmProperty<BmmType>)> {
model
.all_ancestor_classes(class.name())
.into_iter()
.find_map(|ancestor| {
let declared = model
.class_definition(&ancestor)?
.properties()?
.values()
.find(|candidate| candidate.name() == property)?;
Some((ancestor, declared))
})
}
#[cfg(test)]
mod tests {
#![expect(
clippy::panic_in_result_fn,
reason = "the Book ch11 test shape: `?` propagates the read/model plumbing while the assertions ARE the test — an assertion panic is how these tests fail"
)]
use crate::v1_1::bmm_persistence::create_model::create_bmm_model;
use crate::v1_1::bmm_persistence::error::PBmmReadError;
use crate::v1_1::bmm_persistence::reader::read_schema;
use crate::v1_1::bmm_persistence::validate::PBmmValidityFinding;
use crate::v1_1::bmm_persistence::validate::validate_schema;
fn findings(src: &str) -> Result<Vec<PBmmValidityFinding>, PBmmReadError> {
let schema = read_schema(src)?;
let model = create_bmm_model(&schema)?;
Ok(validate_schema(&schema, &model))
}
const DUPLICATE_LISTING: &str = r#"
bmm_version = <"2.4">
rm_publisher = <"openehr">
schema_name = <"duplicate_listing">
rm_release = <"1.0.2">
packages = <
["ParentPackage"] = <
name = <"ParentPackage">
classes = <"ParentType1", "ParentType1", "Any">
>
>
class_definitions = <
["ParentType1"] = < name = <"ParentType1"> >
["Any"] = < name = <"Any"> >
>
"#;
const CASE_VARIANT_NAMES: &str = r#"
bmm_version = <"2.4">
rm_publisher = <"openehr">
schema_name = <"case_variant_names">
rm_release = <"1.0.2">
packages = <
["org.openehr.first"] = <
name = <"org.openehr.first">
classes = <"Hashable", "Any">
>
["org.openehr.second"] = <
name = <"org.openehr.second">
classes = <"HASHABLE">
>
>
class_definitions = <
["Hashable"] = < name = <"Hashable"> >
["HASHABLE"] = < name = <"HASHABLE"> >
["Any"] = < name = <"Any"> >
>
"#;
const NON_CONFORMANT_OVERRIDE: &str = r#"
bmm_version = <"2.4">
rm_publisher = <"openehr">
schema_name = <"non_conformant_override">
rm_release = <"1.0.2">
packages = <
["ParentPackage"] = <
name = <"ParentPackage">
classes = <"ParentType1", "ChildType1", "String", "Any">
>
>
class_definitions = <
["ParentType1"] = <
name = <"ParentType1">
properties = <
["property_1"] = (P_BMM_SINGLE_PROPERTY) <
name = <"property_1">
type = <"String">
>
>
>
["ChildType1"] = <
name = <"ChildType1">
ancestors = <"ParentType1", ...>
properties = <
["property_1"] = (P_BMM_SINGLE_PROPERTY) <
name = <"property_1">
type = <"ParentType1">
>
>
>
["String"] = < name = <"String"> >
["Any"] = < name = <"Any"> >
>
"#;
const CONFORMANT_OVERRIDE: &str = r#"
bmm_version = <"2.4">
rm_publisher = <"openehr">
schema_name = <"conformant_override">
rm_release = <"1.0.2">
packages = <
["ParentPackage"] = <
name = <"ParentPackage">
classes = <"ParentType1", "ChildType1", "String", "Coded_string", "Any">
>
>
class_definitions = <
["ParentType1"] = <
name = <"ParentType1">
properties = <
["property_1"] = (P_BMM_SINGLE_PROPERTY) <
name = <"property_1">
type = <"String">
>
>
>
["ChildType1"] = <
name = <"ChildType1">
ancestors = <"ParentType1", ...>
properties = <
["property_1"] = (P_BMM_SINGLE_PROPERTY) <
name = <"property_1">
type = <"Coded_string">
>
>
>
["String"] = < name = <"String"> >
["Coded_string"] = <
name = <"Coded_string">
ancestors = <"String", ...>
>
["Any"] = < name = <"Any"> >
>
"#;
#[test]
fn a_class_listed_twice_in_one_package_is_not_in_exactly_one_package()
-> Result<(), PBmmReadError> {
assert_eq!(
findings(DUPLICATE_LISTING)?,
[PBmmValidityFinding::ClassNotInExactlyOnePackage {
class: "ParentType1".to_owned(),
packages: ["ParentPackage".to_owned(), "ParentPackage".to_owned()].to_vec(),
}]
);
Ok(())
}
#[test]
fn two_definitions_differing_only_in_case_are_one_name() -> Result<(), PBmmReadError> {
let observed = findings(CASE_VARIANT_NAMES)?;
assert!(
observed.contains(&PBmmValidityFinding::ClassNameNotUnique {
definitions: ["Hashable".to_owned(), "HASHABLE".to_owned()].to_vec(),
}),
"expected a name-collision finding, got {observed:?}"
);
Ok(())
}
#[test]
fn a_non_conformant_redefinition_is_reported() -> Result<(), PBmmReadError> {
assert_eq!(
findings(NON_CONFORMANT_OVERRIDE)?,
[PBmmValidityFinding::OverriddenPropertyNonConformance {
class: "ChildType1".to_owned(),
property: "property_1".to_owned(),
ancestor: "ParentType1".to_owned(),
redefined_type: "ParentType1".to_owned(),
overridden_type: "String".to_owned(),
}]
);
Ok(())
}
#[test]
fn a_conformant_narrowing_redefinition_is_clean() -> Result<(), PBmmReadError> {
assert_eq!(findings(CONFORMANT_OVERRIDE)?, []);
Ok(())
}
#[test]
fn every_finding_names_its_subject() -> Result<(), PBmmReadError> {
let rendered = findings(NON_CONFORMANT_OVERRIDE)?
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
assert!(rendered.contains("ChildType1"), "{rendered}");
assert!(rendered.contains("property_1"), "{rendered}");
Ok(())
}
}