Skip to main content

ifc_schema/
completeness.rs

1//! Prove a hand-written entity inventory is complete against the normative
2//! EXPRESS schema.
3//!
4//! # Why this exists
5//!
6//! Several crates publish a `const` list describing itself as the complete
7//! inventory for one IFC schema, and assert its own length in a test. That
8//! gate cannot fail: trimming the list trims the expectation with it. The
9//! material inventory was short one entity for exactly this reason.
10//!
11//! # What this checks
12//!
13//! An inventory declares the roots it covers. Every entity the schema
14//! declares as a descendant of those roots must appear in the inventory.
15//! The schema supplies the expectation, so deleting a name makes the
16//! check fail rather than lowering the bar.
17//!
18//! This proves the inventory names every entity. It does not prove the
19//! crate implements them; that is the job of each crate behaviour tests.
20
21use crate::Schema;
22use std::collections::BTreeSet;
23
24/// What an inventory got wrong, in schema terms.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct InventoryGap {
27    /// Declared by the schema under a covered root, absent from the list.
28    pub missing: BTreeSet<String>,
29    /// Named by the list, not declared by the schema under any root.
30    pub unknown: BTreeSet<String>,
31}
32
33impl InventoryGap {
34    /// Whether the inventory matched the schema exactly.
35    #[must_use]
36    pub fn is_empty(&self) -> bool {
37        self.missing.is_empty() && self.unknown.is_empty()
38    }
39}
40
41impl std::fmt::Display for InventoryGap {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        if !self.missing.is_empty() {
44            writeln!(f, "declared by the schema but absent from the inventory:")?;
45            for name in &self.missing {
46                writeln!(f, "    {name}")?;
47            }
48        }
49        if !self.unknown.is_empty() {
50            writeln!(f, "named by the inventory but not declared under any root:")?;
51            for name in &self.unknown {
52                writeln!(f, "    {name}")?;
53            }
54        }
55        Ok(())
56    }
57}
58
59/// Every entity the schema declares at or below `roots`, upper-cased.
60///
61/// Walks the declared supertype chain of every entity rather than the
62/// subtype lists, because an entity can be reached through a chain whose
63/// intermediate links are abstract.
64#[must_use]
65pub fn descendants_of(schema: &Schema, roots: &[&str]) -> BTreeSet<String> {
66    let wanted: BTreeSet<String> = roots.iter().map(|r| r.to_ascii_uppercase()).collect();
67    let mut found = BTreeSet::new();
68    for name in schema.entity_names() {
69        let upper = name.to_ascii_uppercase();
70        if wanted.contains(&upper) {
71            found.insert(upper);
72            continue;
73        }
74        if schema
75            .supertypes(name)
76            .iter()
77            .any(|s| wanted.contains(&s.to_ascii_uppercase()))
78        {
79            found.insert(upper);
80        }
81    }
82    found
83}
84
85/// Compare a published inventory against what the schema declares.
86///
87/// `roots` are the entities the inventory claims to cover, including every
88/// entity below them. Names are compared case-insensitively.
89#[must_use]
90pub fn audit_inventory(schema: &Schema, roots: &[&str], inventory: &[&str]) -> InventoryGap {
91    let expected = descendants_of(schema, roots);
92    let actual: BTreeSet<String> = inventory.iter().map(|e| e.to_ascii_uppercase()).collect();
93    InventoryGap {
94        missing: expected.difference(&actual).cloned().collect(),
95        unknown: actual.difference(&expected).cloned().collect(),
96    }
97}