Skip to main content

cimvalidation/
shapes.rs

1//! The shape table both families are validated against.
2//!
3//! Elements are property bags ([`cimmodel::Element`]) with no struct to
4//! downcast to, so the shapes are data and [`crate::bag`] interprets them, the
5//! same way classes are [`cimmodel::base::ClassDef`] rows interpreted by the
6//! decoder. `cimgen` emits the tables into `cgmes_shapes.rs` and
7//! `nc_shapes.rs`; with the `dynamic-shapes` feature they can be built from the
8//! SHACL TTL files at runtime instead.
9//!
10//! Everything a consumer would have to compute per element is resolved when
11//! the table is built: target classes are already family-qualified
12//! `CimDataset::by_type` keys with abstract classes expanded to their concrete
13//! descendants, and paths are already the field keys the decoder stores.
14
15/// Where a property shape's values come from.
16///
17/// Field keys are the local XML element name with the prefix stripped, which
18/// is exactly what the decoder puts in [`cimmodel::Element::fields`].
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Path {
21    /// A field on the element itself.
22    Forward(&'static str),
23    /// `sh:inversePath` — the field on whatever points *at* this element.
24    /// Needs the reverse index, which is why [`ShapeSet`] tracks whether any
25    /// active shape uses one.
26    Inverse(&'static str),
27    /// `sh:alternativePath` — the union of every branch's values. A branch may
28    /// itself be inverse, hence [`AltBranch`] rather than a plain name.
29    Alternative(&'static [AltBranch]),
30    /// A sequence path: `( ^cim:Terminal.ConductingEquipment
31    /// cim:Terminal.phases )`, `( cim:Location.mainAddress
32    /// cim:StreetAddress.status cim:Status.dateTime )`, and — 170 of NCP's 178
33    /// chains, its main association value-type check — `( nc:X.y rdf:type )`.
34    ///
35    /// Stepping *from* an element absent from the dataset makes the result
36    /// unknown and every check on it silent: phase 1 runs per file, so leaving
37    /// the file is normal. A final `rdf:type` step needs nothing from the
38    /// elements it reaches, so they count even when absent, and class checks
39    /// skip the absent ones, as [`Constraint::RefClass`] describes.
40    ///
41    /// The referenced element may belong to *either* family: NCP's value-type
42    /// lists name CGMES classes (`cim17:ACLineSegment`) alongside NC ones, and
43    /// `Element::type_name` answers for both.
44    Chain(&'static [Step]),
45}
46
47/// One step of a [`Path::Chain`].
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Step {
50    Forward(&'static str),
51    /// Needs the reverse index, like [`Path::Inverse`], but as a list of the
52    /// elements pointing here rather than a count.
53    Inverse(&'static str),
54    /// `rdf:type`, last only. Its values are the elements reached; the
55    /// constraints on such a path (`RefClass`, counts) read their classes.
56    Type,
57}
58
59/// One branch of an [`Path::Alternative`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum AltBranch {
62    Forward(&'static str),
63    Inverse(&'static str),
64}
65
66/// `sh:nodeKind`, restricted to the values the profiles actually use.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum NodeKind {
69    /// Written as `rdf:resource`.
70    Iri,
71    /// Written as element text.
72    Literal,
73    BlankNode,
74}
75
76/// A single check on the values a [`Path`] yields.
77#[derive(Debug, Clone, Copy, PartialEq)]
78pub enum Constraint {
79    MinCount(u32),
80    MaxCount(u32),
81    /// The xsd type's local name (`"integer"`, `"dateTime"`, …). A bag holds
82    /// strings, so this is a real parse check rather than the tautology it was
83    /// against a typed struct.
84    Datatype(&'static str),
85    NodeKind(NodeKind),
86    /// `sh:class` — the referenced element must be an instance of this
87    /// family-qualified class, or a descendant of it. Already expanded, so the
88    /// check is a set membership.
89    Class(&'static [&'static str]),
90    /// `sh:in` — the value must be one of these. Enum IRIs are stored as the
91    /// decoder stores them: fragment only.
92    In(&'static [&'static str]),
93    /// `sh:hasValue`, and `sh:in` with exactly one member after
94    /// simplification.
95    HasValue(&'static str),
96    MaxLength(u32),
97    MinLength(u32),
98    /// The referenced element's class must be one of these family-qualified
99    /// names. Produced by `sh:in` on a path ending in `rdf:type`.
100    ///
101    /// Only checked when the referenced element is **present**. Phase-1
102    /// validation runs per file, so a reference out of the current file is
103    /// normal and is a different rule's business — reporting it here would make
104    /// every cross-file association a value-type violation.
105    RefClass(&'static [&'static str]),
106    /// `sh:minInclusive` and friends. Compared as `f64`; a value that does not
107    /// parse as a number is skipped, the way a typed numeric field that failed
108    /// to parse is `None` — malformed numbers are `sh:datatype`'s business.
109    MinInclusive(f64),
110    MaxInclusive(f64),
111    MinExclusive(f64),
112    MaxExclusive(f64),
113    /// `sh:lessThan` — this property's value must be below the named field's
114    /// on the same element. Only checked when both are present and numeric.
115    LessThan(&'static str),
116    LessThanOrEquals(&'static str),
117    /// `sh:not [ sh:class X ]` — the referenced element must not be an
118    /// instance of these family-qualified classes. Silent when the referenced
119    /// element is absent, like [`Constraint::Class`].
120    NotClass(&'static [&'static str]),
121    /// `sh:qualifiedValueShape [ sh:in (..) ]` with `sh:qualifiedMinCount` —
122    /// at least `min` values must be among `allowed`. NCP uses it to ask
123    /// whether a dataset declares conformance to one of a set of profiles.
124    QualifiedIn { allowed: &'static [&'static str], min: u32 },
125    /// `sh:length` — exactly this many characters, e.g. the 16 of an EIC code.
126    Length(u32),
127}
128
129/// One constraint together with how to report it.
130///
131/// SHACL attaches `sh:message`, `sh:name` and `sh:severity` to the constraint,
132/// not to the property shape, and one property shape routinely carries several
133/// constraints with different messages — a cardinality rule and a value-range
134/// rule on the same path. Pairing them here keeps the reported text the one the
135/// schema wrote for that specific check.
136#[derive(Debug)]
137pub struct Check {
138    pub constraint: Constraint,
139    /// Shape IRI, the stable machine-readable rule id a caller can silence.
140    pub rule_id: &'static str,
141    pub name: &'static str,
142    pub message: &'static str,
143    pub description: &'static str,
144    /// `"sh:Violation"`, `"sh:Warning"` or `"sh:Info"`. NC leans on `sh:Info`
145    /// far more than CGMES does — 842 occurrences against 7 — so a caller that
146    /// treats every violation as a failure will report an NC dataset as broken
147    /// when it is merely being advised.
148    pub severity: &'static str,
149}
150
151/// One `sh:property` of a node shape: a path, and what must hold of its values.
152#[derive(Debug)]
153pub struct PropShape {
154    pub path: Path,
155    pub checks: &'static [Check],
156}
157
158/// What a node shape applies to.
159#[derive(Debug)]
160pub enum Target {
161    /// Family-qualified `by_type` keys. An abstract target is expanded to its
162    /// concrete descendants when the table is built — exact-match lookup on an
163    /// abstract class would silently check nothing.
164    Class(&'static [&'static str]),
165    /// `sh:targetSubjectsOf` — every element carrying this field.
166    SubjectsOf(&'static str),
167}
168
169/// How a [`Logic`] combines its branches.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum LogicOp {
172    And,
173    Or,
174    Xone,
175}
176
177/// One branch of a [`Logic`]. It conforms when none of its checks fail — or,
178/// for `[ sh:not X ]` (`negate`), when at least one does. NCP writes material
179/// implication that way: `sh:or ( [ sh:not conformsToNCProfile ] [ P required ] )`.
180#[derive(Debug)]
181pub struct Branch {
182    pub props: &'static [PropShape],
183    pub negate: bool,
184}
185
186/// A node-level `sh:and` / `sh:or` / `sh:xone`. The shape reports once, with
187/// its own name and message, when the combination does not hold; the branch
188/// checks carry no report text.
189#[derive(Debug)]
190pub struct Logic {
191    pub op: LogicOp,
192    pub branches: &'static [Branch],
193    pub rule_id: &'static str,
194    pub name: &'static str,
195    pub message: &'static str,
196    pub description: &'static str,
197    pub severity: &'static str,
198}
199
200/// One `sh:NodeShape`.
201#[derive(Debug)]
202pub struct ShapeDef {
203    pub targets: &'static [Target],
204    /// Borrowed rather than owned: NCP defines ~4,200 distinct property shapes
205    /// and references them ~14,800 times, because a shape like
206    /// `IdentifiedObject.mRID-cardinality` applies to hundreds of classes.
207    /// Sharing one definition per shape keeps the generated table a third of
208    /// the size it would otherwise be.
209    pub props: &'static [&'static PropShape],
210    /// `sh:closed` — the allowed field keys. An element carrying any other key
211    /// is reported.
212    ///
213    /// This is the constraint that only a property bag can answer: generated
214    /// structs drop unknown properties at decode, leaving nothing to compare
215    /// against. 823 NCP shapes use it.
216    pub closed: Option<&'static ClosedShape>,
217    /// Node-level `sh:and` / `sh:or` / `sh:xone`.
218    pub logic: &'static [Logic],
219    /// Every NC profile code whose manifest imports the file this shape came
220    /// from. Plural because four shared constraint files are imported by 17 or
221    /// 18 of the 18 manifests.
222    pub profiles: &'static [&'static str],
223    /// TTL base file name, for reports.
224    pub file: &'static str,
225}
226
227/// The `sh:closed` half of a shape, kept separate because it carries its own
228/// message and severity.
229#[derive(Debug)]
230pub struct ClosedShape {
231    pub allowed: &'static [&'static str],
232    pub rule_id: &'static str,
233    pub name: &'static str,
234    pub message: &'static str,
235    pub description: &'static str,
236    pub severity: &'static str,
237}