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}