Skip to main content

cimschema/shacl/
resolve.rs

1//! Resolves parsed SHACL shapes against a family's schema.
2//!
3//! The output is what a property-bag validator needs and nothing more: target
4//! classes as family-qualified `by_type` keys, paths as the field keys the
5//! decoder stores, and class lists already expanded to concrete descendants.
6//! A target class matches itself when concrete and its concrete descendants
7//! when abstract ([`Resolver::targets_of`] says why).
8//!
9//! This lives here rather than in `cimgen` so the build-time generator and the
10//! runtime shape loader in `cimvalidation` resolve *identically* — the same
11//! reason the parser moved out of `cimgen`. The alternative, two
12//! implementations plus a test comparing them, was what the NC class table
13//! ended up doing; sharing the code removes the drift instead of detecting it.
14//!
15//! The types here are owned. `cimvalidation`'s mirror of them is `&'static`,
16//! because the element trait and the violation types demand it, and converting
17//! between the two is mechanical and total — an omission is a compile error.
18
19use std::collections::{HashMap, HashSet};
20
21use crate::family::Family;
22use crate::model::{CimSpecification, CimType};
23use crate::shacl::model::{ConstraintInfo, FileResults, ShapeInfo, LITERALS, NEGATED, UNSUPPORTED};
24use crate::shacl::skip::SkipCollector;
25
26// ---------------------------------------------------------------------------
27// Resolved model
28// ---------------------------------------------------------------------------
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum AltBranch {
32    Forward(String),
33    Inverse(String),
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Path {
38    Forward(String),
39    Inverse(String),
40    Alternative(Vec<AltBranch>),
41    /// A sequence path, e.g. `( ^cim:Terminal.ConductingEquipment
42    /// cim:Terminal.phases )`, or `( nc:X.y rdf:type )` — 170 of NCP's 178
43    /// chains are that last form, follow the association and read the class.
44    Chain(Vec<Step>),
45}
46
47/// One step of a [`Path::Chain`].
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Step {
50    Forward(String),
51    Inverse(String),
52    /// `rdf:type` — the classes of the elements reached so far. Last only.
53    Type,
54}
55
56impl Path {
57    /// Does the path end in `rdf:type`, so that its values are class names?
58    pub fn yields_types(&self) -> bool {
59        match self {
60            Path::Chain(steps) => matches!(steps.last(), Some(Step::Type)),
61            _ => false,
62        }
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum NodeKind {
68    Iri,
69    Literal,
70    BlankNode,
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub enum Constraint {
75    MinCount(u32),
76    MaxCount(u32),
77    Datatype(String),
78    NodeKind(NodeKind),
79    Class(Vec<String>),
80    In(Vec<String>),
81    HasValue(String),
82    MaxLength(u32),
83    MinLength(u32),
84    /// The referenced element's class, from `sh:in` on a path ending in
85    /// `rdf:type`.
86    RefClass(Vec<String>),
87    MinInclusive(f64),
88    MaxInclusive(f64),
89    MinExclusive(f64),
90    MaxExclusive(f64),
91    /// `sh:lessThan` — the field key of the property this one must be below.
92    LessThan(String),
93    LessThanOrEquals(String),
94    /// `sh:not [ sh:class X ]` — the referenced element must *not* be an
95    /// instance of these (already expanded) classes.
96    NotClass(Vec<String>),
97    /// `sh:qualifiedValueShape [ sh:in (..) ]` with `sh:qualifiedMinCount` —
98    /// at least `min` values must be among `allowed`.
99    QualifiedIn { allowed: Vec<String>, min: u32 },
100    /// `sh:length` — exactly this many characters.
101    Length(u32),
102}
103
104/// One branch of a [`Logic`]: it conforms when none of its checks fail, or —
105/// for `[ sh:not X ]` — when at least one does.
106#[derive(Debug, Clone)]
107pub struct Branch {
108    pub props: Vec<PropShape>,
109    pub negate: bool,
110}
111
112/// How a node-level `sh:and` / `sh:or` / `sh:xone` combines its branches.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum LogicOp {
115    And,
116    Or,
117    Xone,
118}
119
120/// A node-level `sh:and` / `sh:or` / `sh:xone` over anonymous property shapes.
121///
122/// A branch conforms when none of its checks fail. The checks inside a branch
123/// carry no report text of their own: the shape reports once, with the node
124/// shape's name and message, when the combination fails.
125#[derive(Debug, Clone)]
126pub struct Logic {
127    pub op: LogicOp,
128    pub branches: Vec<Branch>,
129    pub rule_id: String,
130    pub name: String,
131    pub message: String,
132    pub description: String,
133    pub severity: String,
134}
135
136#[derive(Debug, Clone)]
137pub struct Check {
138    pub constraint: Constraint,
139    pub rule_id: String,
140    pub name: String,
141    pub message: String,
142    pub description: String,
143    pub severity: String,
144}
145
146#[derive(Debug, Clone)]
147pub struct PropShape {
148    pub path: Path,
149    pub checks: Vec<Check>,
150}
151
152#[derive(Debug, Clone)]
153pub enum Target {
154    Class(Vec<String>),
155    SubjectsOf(String),
156}
157
158#[derive(Debug, Clone)]
159pub struct ClosedShape {
160    pub allowed: Vec<String>,
161    pub rule_id: String,
162    pub name: String,
163    pub message: String,
164    pub description: String,
165    pub severity: String,
166}
167
168#[derive(Debug, Clone)]
169pub struct ShapeDef {
170    pub targets: Vec<Target>,
171    pub props: Vec<PropShape>,
172    pub closed: Option<ClosedShape>,
173    pub logic: Vec<Logic>,
174    pub profiles: Vec<String>,
175    pub file: String,
176}
177
178#[derive(Debug, Default)]
179pub struct Stats {
180    pub shapes: usize,
181    pub props: usize,
182    pub checks: usize,
183    pub closed: usize,
184    pub logic: usize,
185}
186
187// ---------------------------------------------------------------------------
188// Resolver
189// ---------------------------------------------------------------------------
190
191/// Resolves simplified IRIs against one family's schema, and against every
192/// other family for the cross-family class lists.
193pub struct Resolver {
194    /// `(namespace, local)` → family-qualified name, this family only.
195    ///
196    /// Owned keys: lookups come from a TTL file's prefix map, whose lifetime is
197    /// unrelated to the schema's. This runs once per shape when the table is
198    /// built, never per element.
199    by_ns: HashMap<(String, String), String>,
200    concrete_of: HashMap<String, Vec<String>>,
201    /// The same, across every family.
202    ///
203    /// NCP's association value-type lists name CGMES classes
204    /// (`cim17:ACLineSegment`) beside NC ones, and the referenced element can
205    /// be either — `Element::type_name` answers for both.
206    any_family: HashMap<(String, String), String>,
207    any_concrete: HashMap<String, Vec<String>>,
208    /// Qualified class → itself and its ancestors, this family only.
209    ancestors: HashMap<String, Vec<String>>,
210}
211
212/// `(namespace, local)` → qualified class name, and qualified class → its
213/// concrete descendants.
214type ClassMaps = (HashMap<(String, String), String>, HashMap<String, Vec<String>>);
215
216fn class_maps(spec: &CimSpecification) -> ClassMaps {
217    let prefix = spec.family.type_prefix;
218    let qualified = |t: &CimType| format!("{prefix}{}", t.id);
219    let by_ns = spec
220        .types
221        .values()
222        .map(|t| ((t.namespace.clone(), t.id.clone()), qualified(t)))
223        .collect();
224
225    let mut concrete: HashMap<String, Vec<String>> = HashMap::new();
226    for t in spec.types.values() {
227        if t.concrete_in.is_empty() {
228            concrete.entry(qualified(t)).or_default();
229            continue;
230        }
231        // Register the concrete class against every ancestor, so an abstract
232        // target resolves to what can actually appear below it.
233        let q = qualified(t);
234        let mut cursor = Some(t);
235        let mut guard = 0;
236        while let Some(c) = cursor {
237            concrete.entry(qualified(c)).or_default().push(q.clone());
238            // A malformed schema could make this cyclic; the import stage does
239            // not guarantee otherwise.
240            guard += 1;
241            if guard > 64 {
242                break;
243            }
244            cursor = spec.types.get(&c.super_type);
245        }
246    }
247    for v in concrete.values_mut() {
248        v.sort();
249        v.dedup();
250    }
251    (by_ns, concrete)
252}
253
254impl Resolver {
255    pub fn new(spec: &CimSpecification, others: &[&CimSpecification]) -> Self {
256        let (by_ns, concrete_of) = class_maps(spec);
257        let mut any_family = by_ns.clone();
258        let mut any_concrete = concrete_of.clone();
259        for other in others {
260            let (b, c) = class_maps(other);
261            any_family.extend(b);
262            for (k, v) in c {
263                any_concrete.entry(k).or_default().extend(v);
264            }
265        }
266        for v in any_concrete.values_mut() {
267            v.sort();
268            v.dedup();
269        }
270        let prefix = spec.family.type_prefix;
271        let ancestors = spec
272            .types
273            .values()
274            .map(|t| {
275                let mut chain = Vec::new();
276                let mut cursor = Some(t);
277                // Bounded for the same reason as in `class_maps`.
278                while let Some(c) = cursor.filter(|_| chain.len() < 64) {
279                    chain.push(format!("{prefix}{}", c.id));
280                    cursor = spec.types.get(&c.super_type);
281                }
282                (format!("{prefix}{}", t.id), chain)
283            })
284            .collect();
285        Self { by_ns, concrete_of, any_family, any_concrete, ancestors }
286    }
287
288    /// The concrete classes a `sh:closed` shape on `target` governs: `target`
289    /// itself when it is concrete (see [`Self::targets_of`]); for an abstract
290    /// one, its concrete descendants less those below a class with a closed
291    /// shape of its own (`closed_classes`).
292    ///
293    /// The APL writes one closed `AllowedProperties` shape per class, listing
294    /// that class's properties, inherited ones included. A subclass with its
295    /// own is held to its own list; held to the superclass's as well, every
296    /// property the subclass adds would be reported. Below an abstract target,
297    /// a subclass without one still falls under the target's.
298    fn closed_concrete(&self, target: &str, closed_classes: &HashSet<String>) -> Vec<String> {
299        if self.is_concrete(target) {
300            return vec![target.to_string()];
301        }
302        let below_target = |c: &String| {
303            c != target && self.ancestors.get(c).is_some_and(|a| a.iter().any(|x| x == target))
304        };
305        self.concrete(target)
306            .iter()
307            .filter(|d| {
308                !self.ancestors.get(*d).is_some_and(|chain| {
309                    chain.iter().any(|a| closed_classes.contains(a) && below_target(a))
310                })
311            })
312            .cloned()
313            .collect()
314    }
315
316    /// Resolve a simplified IRI to this family's qualified class name.
317    ///
318    /// Needs the file's prefix map: the prefix alone does not identify a class,
319    /// because NCP binds `cim` to `https://cim.ucaiug.io/ns#` while CGMES binds
320    /// it to `http://iec.ch/TC57/CIM100#` and the families overlap on 164 local
321    /// names.
322    fn class(&self, iri: &str, prefixes: &HashMap<String, String>) -> Option<&String> {
323        let (prefix, local) = iri.split_once(':')?;
324        let ns = prefixes.get(prefix)?;
325        self.by_ns.get(&(ns.clone(), local.to_string()))
326    }
327
328    fn concrete(&self, qualified: &str) -> &[String] {
329        self.concrete_of.get(qualified).map_or(&[], Vec::as_slice)
330    }
331
332    fn is_concrete(&self, qualified: &str) -> bool {
333        self.concrete(qualified).iter().any(|c| c == qualified)
334    }
335
336    /// The `by_type` keys `sh:targetClass q` matches: `q` alone when it is
337    /// concrete, else its concrete descendants.
338    ///
339    /// SHACL matches subclass instances only through `rdfs:subClassOf` triples
340    /// in the data graph, and CGMES and NC data carry none, so a target class
341    /// matches literally. The APL is written for that: the 600-2 Simple files
342    /// give every concrete class its own node shape repeating its inherited
343    /// properties, the Complex files enumerate subclasses by name, and
344    /// `RegulatingControl-RegulatingEquipment` describes `TapChangerControl` as
345    /// exempt. An abstract class has no instances, so a literal match would
346    /// check nothing; its target expands instead. Of 1,487 CGMES class targets
347    /// 3 are abstract (`Measurement`, `Control`).
348    fn targets_of(&self, qualified: &str) -> Vec<String> {
349        if self.is_concrete(qualified) {
350            vec![qualified.to_string()]
351        } else {
352            self.concrete(qualified).to_vec()
353        }
354    }
355
356    /// Every concrete class the listed IRIs allow, across all families.
357    ///
358    /// Members that resolve nowhere are dropped rather than failing the list:
359    /// NCP's value-type lists name CIM16 classes, which no family here
360    /// declares, so nothing could ever decode as one.
361    fn any_classes(&self, iris: &[String], prefixes: &HashMap<String, String>) -> Vec<String> {
362        let mut out: Vec<String> = Vec::new();
363        for iri in iris {
364            let Some((prefix, local)) = iri.split_once(':') else { continue };
365            let Some(ns) = prefixes.get(prefix) else { continue };
366            if let Some(q) = self.any_family.get(&(ns.clone(), local.to_string())) {
367                match self.any_concrete.get(q) {
368                    Some(c) if !c.is_empty() => out.extend(c.iter().cloned()),
369                    _ => out.push(q.clone()),
370                }
371            }
372        }
373        out.sort();
374        out.dedup();
375        out
376    }
377}
378
379// ---------------------------------------------------------------------------
380// Resolution
381// ---------------------------------------------------------------------------
382
383/// The field key the decoder stores for a property: the local XML element
384/// name, which is the qname with its prefix dropped.
385fn field_key(iri: &str) -> &str {
386    iri.rsplit_once(':').map_or(iri, |(_, local)| local)
387}
388
389/// An `sh:in` / `sh:hasValue` member as the decoder would store it: a string
390/// literal verbatim, an IRI as [`iri_key`] gives it.
391fn value_key(c: &ConstraintInfo, v: &str, prefixes: &HashMap<String, String>) -> String {
392    let literal = c
393        .payload
394        .get(LITERALS)
395        .and_then(|l| l.as_list())
396        .is_some_and(|l| l.iter().any(|x| x == v));
397    if literal { v.to_string() } else { iri_key(v, prefixes) }
398}
399
400/// An IRI the way the decoder stores an `rdf:resource` (its
401/// `strip_fragment`): the part after the last `#`, or the whole IRI when it
402/// has none.
403///
404/// A prefixed name is expanded through the file's prefixes first, so
405/// `cim:Kind.value` and `<http://…#Kind.value>` both come out as
406/// `Kind.value`, while `<https://ap.cim4.eu/Contingency/2.3>` — and a name in
407/// a namespace that ends in `/` — stays whole. An unknown prefix falls back to
408/// the local name.
409fn iri_key(v: &str, prefixes: &HashMap<String, String>) -> String {
410    let full = match v.strip_prefix('<') {
411        Some(inner) => inner.trim_end_matches('>').to_string(),
412        None => match v.split_once(':') {
413            Some((prefix, local)) => match prefixes.get(prefix) {
414                Some(ns) => format!("{ns}{local}"),
415                None => return local.to_string(),
416            },
417            None => v.to_string(),
418        },
419    };
420    match full.rfind('#') {
421        Some(i) => full[i + 1..].to_string(),
422        None => full,
423    }
424}
425
426/// Resolve every shape in every file.
427///
428/// `profiles_of` maps a TTL base file name to the profile codes whose manifest
429/// imports it; a file no manifest imports is left out, since no profile could
430/// ever run it.
431pub fn resolve_shapes(
432    spec: &CimSpecification,
433    others: &[&CimSpecification],
434    files: &[FileResults],
435    profiles_of: &HashMap<String, Vec<String>>,
436    collector: &mut SkipCollector,
437) -> (Vec<ShapeDef>, Stats) {
438    let r = Resolver::new(spec, others);
439    let mut stats = Stats::default();
440    let mut out = Vec::new();
441
442    for fr in files {
443        let profiles = match profiles_of.get(&fr.file_name) {
444            Some(p) if !p.is_empty() => p.clone(),
445            _ => {
446                collector.push("", &fr.file_name, "file", "",
447                    "not imported by any profile manifest");
448                continue;
449            }
450        };
451        // Classes this file gives a closed shape of their own.
452        let closed_classes: HashSet<String> = fr
453            .shapes
454            .iter()
455            .filter(|shape| shape.closed.is_some())
456            .flat_map(|shape| &shape.targets)
457            .filter(|t| t.kind == "targetClass")
458            .filter_map(|t| r.class(&t.value, &fr.prefixes).cloned())
459            .collect();
460        for shape in &fr.shapes {
461            for def in resolve_shape(shape, fr, &profiles, &r, &closed_classes, collector, &mut stats) {
462                stats.shapes += 1;
463                out.push(def);
464            }
465        }
466    }
467    (out, stats)
468}
469
470fn resolve_shape(
471    shape: &ShapeInfo,
472    fr: &FileResults,
473    profiles: &[String],
474    r: &Resolver,
475    closed_classes: &HashSet<String>,
476    collector: &mut SkipCollector,
477    stats: &mut Stats,
478) -> Vec<ShapeDef> {
479    let mut targets: Vec<Target> = Vec::new();
480    // The same, narrowed for the shape's `sh:closed` check.
481    let mut closed_targets: Vec<Target> = Vec::new();
482    let mut target_classes: Vec<String> = Vec::new();
483
484    for t in &shape.targets {
485        match t.kind.as_str() {
486            "targetClass" | "targetNode" => match r.class(&t.value, &fr.prefixes) {
487                Some(q) => {
488                    let concrete = r.targets_of(q);
489                    if concrete.is_empty() {
490                        collector.push(&t.value, "", "sh:targetClass", &shape.name,
491                            "class has no concrete subclass in this family");
492                        continue;
493                    }
494                    target_classes.push(q.clone());
495                    targets.push(Target::Class(concrete));
496                    if shape.closed.is_some() {
497                        let narrowed = r.closed_concrete(q, closed_classes);
498                        if !narrowed.is_empty() {
499                            closed_targets.push(Target::Class(narrowed));
500                        }
501                    }
502                }
503                None => {
504                    // The cim16:/cim17: targets land here: NC shapes on CGMES
505                    // classes. Their elements decode as CGMES, and these shapes
506                    // were skipped while CGMES structs dropped the NC attribute
507                    // they constrain. Elements now keep it, so they could run;
508                    // enabling them is a behaviour change left for later.
509                    collector.push(&t.value, "", "sh:targetClass", &shape.name,
510                        "target class is not in this family's schema (cross-family shape)");
511                    continue;
512                }
513            },
514            "targetSubjectsOf" => {
515                targets.push(Target::SubjectsOf(field_key(&t.value).to_string()));
516                closed_targets.push(Target::SubjectsOf(field_key(&t.value).to_string()));
517            }
518            other => {
519                collector.push(&t.value, "", other, &shape.name,
520                    "unsupported SHACL target mechanism");
521            }
522        }
523    }
524    if targets.is_empty() {
525        return Vec::new();
526    }
527
528    let class_label = target_classes.first().cloned().unwrap_or_default();
529
530    let mut props: Vec<PropShape> = Vec::new();
531    for prop in &shape.properties {
532        if let Some(p) = resolve_prop(prop, fr, &class_label, r, collector, stats) {
533            props.push(p);
534        }
535    }
536
537    let closed = shape.closed.as_ref().map(|allowed| {
538        stats.closed += 1;
539        ClosedShape {
540            allowed: allowed.iter().map(|p| field_key(p).to_string()).collect(),
541            rule_id: shape.id.clone(),
542            name: shape.name.clone(),
543            message: shape.description.clone(),
544            description: shape.description.clone(),
545            // sh:closed shapes are advisory throughout NCP.
546            severity: "sh:Info".to_string(),
547        }
548    });
549
550    let mut logic: Vec<Logic> = Vec::new();
551    for c in &shape.constraints {
552        if let Some(l) = resolve_logic(c, shape, fr, &class_label, r, collector) {
553            logic.push(l);
554        }
555    }
556    stats.logic += logic.len();
557
558    if props.is_empty() && closed.is_none() && logic.is_empty() {
559        return Vec::new();
560    }
561    stats.props += props.len();
562
563    let def = |targets, props, closed, logic| ShapeDef {
564        targets,
565        props,
566        closed,
567        logic,
568        profiles: profiles.to_vec(),
569        file: fr.file_name.clone(),
570    };
571    match closed {
572        None => vec![def(targets, props, None, logic)],
573        // The closed check runs on the narrowed targets; any other check on
574        // the shape keeps the full ones, so a closed shape that also carries
575        // constraints becomes two.
576        Some(closed) => {
577            let mut out = Vec::new();
578            if !props.is_empty() || !logic.is_empty() {
579                out.push(def(targets, props, None, logic));
580            }
581            if !closed_targets.is_empty() {
582                out.push(def(closed_targets, Vec::new(), Some(closed), Vec::new()));
583            }
584            out
585        }
586    }
587}
588
589fn resolve_prop(
590    prop: &ShapeInfo,
591    fr: &FileResults,
592    class_label: &str,
593    r: &Resolver,
594    collector: &mut SkipCollector,
595    stats: &mut Stats,
596) -> Option<PropShape> {
597    let seg = prop.path.first().map_or("", String::as_str);
598    let path = match resolve_path(&prop.path) {
599        Ok(p) => p,
600        Err(reason) => {
601            collector.push(class_label, &prop.path.join(" / "), "sh:path", &prop.name, reason);
602            return None;
603        }
604    };
605
606    // No check that the path names a declared attribute. A property bag reads
607    // whatever key the XML carried, declared or not, so a shape whose path the
608    // class table happens to lack still validates real data —
609    // `dcterms:spatial` on `dcat:Dataset` is exactly that, declared in the RDFS
610    // and absent from the imported table. Verifying here rejected 43
611    // DatasetMetadata shapes whose data is present.
612    //
613    // The cost is that a misspelled path silently matches nothing, and a
614    // cardinality shape on one would over-report. The shapes ship in the same
615    // ENTSO-E release as the RDFS, so that would be a schema bug.
616
617    let is_ref_type = path.yields_types();
618    let mut checks: Vec<Check> = Vec::new();
619    for c in &prop.constraints {
620        match resolve_constraint(c, r, &fr.prefixes, is_ref_type) {
621            Some(constraint) => checks.push(Check {
622                constraint,
623                rule_id: if c.rule_id.is_empty() { prop.id.clone() } else { c.rule_id.clone() },
624                name: if c.name.is_empty() { prop.name.clone() } else { c.name.clone() },
625                message: c.message.clone(),
626                description: if c.description.is_empty() {
627                    prop.description.clone()
628                } else {
629                    c.description.clone()
630                },
631                severity: if c.severity.is_empty() {
632                    "sh:Violation".to_string()
633                } else {
634                    c.severity.clone()
635                },
636            }),
637            None => {
638                collector.push(class_label, seg, &c.component, &c.name,
639                    "constraint component not supported for bag families");
640            }
641        }
642    }
643    if checks.is_empty() {
644        return None;
645    }
646    stats.checks += checks.len();
647    Some(PropShape { path, checks })
648}
649
650/// A property path as `extract_path` encodes it: one segment per step, each
651/// `"iri"`, `"^iri"` (inverse) or `"|a|b"` (alternative).
652fn resolve_path(segs: &[String]) -> Result<Path, &'static str> {
653    match segs {
654        [] => Err("shape has no resolvable path"),
655        // `sh:targetNode cim:X` with `[ sh:inversePath rdf:type ]` counts the
656        // instances of class X: the focus node is the class, not an element,
657        // which the table has no way to say. CGMES's three such rules are
658        // written by hand.
659        [one] if one == "^rdf:type" => {
660            Err("instance count of a class (`[ sh:inversePath rdf:type ]`), written by hand")
661        }
662        [one] => decode_path(one).ok_or("unsupported path form"),
663        _ => {
664            let mut steps = Vec::with_capacity(segs.len());
665            for (i, seg) in segs.iter().enumerate() {
666                let last = i + 1 == segs.len();
667                steps.push(match seg.as_str() {
668                    "rdf:type" if last => Step::Type,
669                    "rdf:type" => return Err("rdf:type before the end of a sequence path"),
670                    s if s.starts_with('|') => {
671                        return Err("alternative path inside a sequence path")
672                    }
673                    s => match s.strip_prefix('^') {
674                        Some(inv) => Step::Inverse(field_key(inv).to_string()),
675                        None => Step::Forward(field_key(s).to_string()),
676                    },
677                });
678            }
679            Ok(Path::Chain(steps))
680        }
681    }
682}
683
684/// Resolve a node-level `sh:and` / `sh:or` / `sh:xone`.
685///
686/// All or nothing: a branch that drops one of its constraints conforms more
687/// often than the schema says, which changes what the combination means rather
688/// than just checking less. So any part that does not resolve skips the whole
689/// combination, as does anything the importer saw in a branch but could not
690/// represent.
691fn resolve_logic(
692    c: &ConstraintInfo,
693    shape: &ShapeInfo,
694    fr: &FileResults,
695    class_label: &str,
696    r: &Resolver,
697    collector: &mut SkipCollector,
698) -> Option<Logic> {
699    let op = match c.component.as_str() {
700        "sh:AndConstraintComponent" => LogicOp::And,
701        "sh:OrConstraintComponent" => LogicOp::Or,
702        "sh:XoneConstraintComponent" => LogicOp::Xone,
703        _ => return None,
704    };
705    let mut skip = |reason: &str| {
706        collector.push(class_label, "", &c.component, &shape.name, reason);
707        None
708    };
709    if c.payload.get(UNSUPPORTED).and_then(|v| v.as_list()).is_some_and(|u| !u.is_empty()) {
710        return skip("logical branch uses a construct the importer does not represent");
711    }
712    let Some(raw) = c.payload.get("branches").and_then(|v| v.as_shapes()) else {
713        return skip("logical constraint without branches");
714    };
715
716    let negated: Vec<usize> = c
717        .payload
718        .get(NEGATED)
719        .and_then(|v| v.as_list())
720        .map(|l| l.iter().filter_map(|i| i.parse().ok()).collect())
721        .unwrap_or_default();
722    let mut branches: Vec<Branch> = Vec::new();
723    for (bi, raw_branch) in raw.iter().enumerate() {
724        // One branch may constrain several paths; group by path.
725        let mut props: Vec<PropShape> = Vec::new();
726        for bc in raw_branch {
727            let Ok(path) = resolve_path(&bc.path) else {
728                return skip("logical branch path does not resolve");
729            };
730            // minCount 0 is vacuous, and is how EnergySourcePQ spells "absent"
731            // next to its maxCount 0.
732            if bc.component == "sh:MinCountConstraintComponent"
733                && bc.payload.get("minCount").and_then(|v| v.as_int()) == Some(0)
734            {
735                continue;
736            }
737            let Some(constraint) = resolve_constraint(bc, r, &fr.prefixes, path.yields_types()) else {
738                return skip("logical branch constraint not supported for bag families");
739            };
740            let check = Check {
741                constraint,
742                rule_id: String::new(),
743                name: String::new(),
744                message: String::new(),
745                description: String::new(),
746                severity: String::new(),
747            };
748            match props.iter_mut().find(|p| p.path == path) {
749                Some(p) => p.checks.push(check),
750                None => props.push(PropShape { path, checks: vec![check] }),
751            }
752        }
753        branches.push(Branch { props, negate: negated.contains(&bi) });
754    }
755    Some(Logic {
756        op,
757        branches,
758        rule_id: c.rule_id.clone(),
759        name: c.name.clone(),
760        message: c.message.clone(),
761        description: c.description.clone(),
762        severity: if c.severity.is_empty() { "sh:Violation".to_string() } else { c.severity.clone() },
763    })
764}
765
766/// Decode the two encodings `extract_path` uses — `"^iri"` for inverse and
767/// `"|a|b"` for alternative.
768fn decode_path(seg: &str) -> Option<Path> {
769    if let Some(alts) = seg.strip_prefix('|') {
770        let branches: Vec<AltBranch> = alts
771            .split('|')
772            .filter(|b| !b.is_empty())
773            .map(|b| match b.strip_prefix('^') {
774                Some(inv) => AltBranch::Inverse(field_key(inv).to_string()),
775                None => AltBranch::Forward(field_key(b).to_string()),
776            })
777            .collect();
778        if branches.is_empty() {
779            return None;
780        }
781        return Some(Path::Alternative(branches));
782    }
783    if let Some(inv) = seg.strip_prefix('^') {
784        return Some(Path::Inverse(field_key(inv).to_string()));
785    }
786    if seg.is_empty() {
787        return None;
788    }
789    Some(Path::Forward(field_key(seg).to_string()))
790}
791
792fn resolve_constraint(
793    c: &ConstraintInfo,
794    r: &Resolver,
795    prefixes: &HashMap<String, String>,
796    is_ref_type: bool,
797) -> Option<Constraint> {
798    let int = |key: &str| c.payload.get(key).and_then(|v| v.as_int());
799    let float = |key: &str| c.payload.get(key).and_then(|v| v.as_float());
800
801    // On a path ending in rdf:type the values are class IRIs, so sh:in is a class
802    // membership test rather than a literal comparison, and it has to resolve
803    // across families.
804    if is_ref_type {
805        return match c.component.as_str() {
806            "sh:InConstraintComponent" => {
807                let values = c.payload.get("in")?.as_list()?;
808                let classes = r.any_classes(values, prefixes);
809                (!classes.is_empty()).then_some(Constraint::RefClass(classes))
810            }
811            "sh:HasValueConstraintComponent" => {
812                let v = c.payload.get("hasValue")?.as_str()?.to_string();
813                let classes = r.any_classes(std::slice::from_ref(&v), prefixes);
814                (!classes.is_empty()).then_some(Constraint::RefClass(classes))
815            }
816            // sh:nodeKind sh:IRI on such a shape means "the association value
817            // must be an IRI", a statement about the field rather than about
818            // the rdf:type values — which is how the shape's own message
819            // ("The value is not IRI") reads.
820            "sh:NodeKindConstraintComponent" => match c.payload.get("nodeKind")?.as_str()? {
821                "sh:IRI" => Some(Constraint::NodeKind(NodeKind::Iri)),
822                _ => None,
823            },
824            "sh:RequiredConstraintComponent" => Some(Constraint::MinCount(1)),
825            "sh:MinCountConstraintComponent" => Some(Constraint::MinCount(int("minCount")? as u32)),
826            "sh:MaxCountConstraintComponent" => Some(Constraint::MaxCount(int("maxCount")? as u32)),
827            _ => None,
828        };
829    }
830
831    match c.component.as_str() {
832        "sh:RequiredConstraintComponent" => Some(Constraint::MinCount(1)),
833        "sh:MinCountConstraintComponent" => Some(Constraint::MinCount(int("minCount")? as u32)),
834        "sh:MaxCountConstraintComponent" => Some(Constraint::MaxCount(int("maxCount")? as u32)),
835        "sh:ExactCountConstraintComponent" => {
836            let n = int("minCount").or_else(|| int("maxCount"))?;
837            Some(Constraint::MinCount(n as u32))
838        }
839        "sh:DatatypeConstraintComponent" => {
840            let dt = c.payload.get("datatype")?.as_str()?;
841            Some(Constraint::Datatype(field_key(dt.trim_end_matches('>')).to_string()))
842        }
843        "sh:NodeKindConstraintComponent" => {
844            let kind = match c.payload.get("nodeKind")?.as_str()? {
845                "sh:IRI" => NodeKind::Iri,
846                "sh:Literal" => NodeKind::Literal,
847                "sh:BlankNode" => NodeKind::BlankNode,
848                // sh:BlankNodeOrIRI and friends allow more than one shape of
849                // node, so there is nothing to assert.
850                _ => return None,
851            };
852            Some(Constraint::NodeKind(kind))
853        }
854        "sh:ClassConstraintComponent" => {
855            let cls = c.payload.get("class")?.as_str()?.to_string();
856            let classes = r.any_classes(std::slice::from_ref(&cls), prefixes);
857            (!classes.is_empty()).then_some(Constraint::Class(classes))
858        }
859        // `sh:or ( [ sh:class A ] [ sh:class B ] )` on one property: each value
860        // must be an instance of one of them, which is `sh:class` over the
861        // union.
862        "sh:OrClassConstraintComponent" => {
863            let classes = r.any_classes(c.payload.get("classes")?.as_list()?, prefixes);
864            (!classes.is_empty()).then_some(Constraint::Class(classes))
865        }
866        // Only the one form the profiles use: a qualified shape that is a
867        // value list. Anything else in it is left unresolved.
868        "sh:QualifiedMinCountConstraintComponent" => {
869            let min = int("qualifiedMinCount")? as u32;
870            let inner = c.payload.get("shape")?.as_shapes()?.first()?;
871            let [ic] = inner.as_slice() else { return None };
872            let allowed: Vec<String> = match ic.component.as_str() {
873                "sh:InConstraintComponent" => {
874                    ic.payload.get("in")?.as_list()?.iter().map(|v| value_key(ic, v, prefixes)).collect()
875                }
876                "sh:HasValueConstraintComponent" => {
877                    vec![value_key(ic, ic.payload.get("hasValue")?.as_str()?, prefixes)]
878                }
879                _ => return None,
880            };
881            Some(Constraint::QualifiedIn { allowed, min })
882        }
883        "sh:NotClassConstraintComponent" => {
884            let cls = c.payload.get("class")?.as_str()?.to_string();
885            let classes = r.any_classes(std::slice::from_ref(&cls), prefixes);
886            (!classes.is_empty()).then_some(Constraint::NotClass(classes))
887        }
888        "sh:InConstraintComponent" => {
889            let values = c.payload.get("in")?.as_list()?;
890            Some(Constraint::In(values.iter().map(|v| value_key(c, v, prefixes)).collect()))
891        }
892        "sh:HasValueConstraintComponent" => {
893            let v = c.payload.get("hasValue")?.as_str()?;
894            Some(Constraint::HasValue(value_key(c, v, prefixes)))
895        }
896        "sh:MaxLengthConstraintComponent" => Some(Constraint::MaxLength(int("maxLength")? as u32)),
897        "sh:MinLengthConstraintComponent" => Some(Constraint::MinLength(int("minLength")? as u32)),
898        "sh:LengthConstraintComponent" => Some(Constraint::Length(int("length")? as u32)),
899        "sh:MinInclusiveConstraintComponent" => Some(Constraint::MinInclusive(float("minInclusive")?)),
900        "sh:MaxInclusiveConstraintComponent" => Some(Constraint::MaxInclusive(float("maxInclusive")?)),
901        "sh:MinExclusiveConstraintComponent" => Some(Constraint::MinExclusive(float("minExclusive")?)),
902        "sh:MaxExclusiveConstraintComponent" => Some(Constraint::MaxExclusive(float("maxExclusive")?)),
903        // The other property is read from the same element, so it resolves to
904        // a field key exactly as the path does.
905        "sh:LessThanConstraintComponent" => {
906            let other = c.payload.get("lessThan")?.as_str()?;
907            Some(Constraint::LessThan(field_key(other).to_string()))
908        }
909        "sh:LessThanOrEqualsConstraintComponent" => {
910            let other = c.payload.get("lessThanOrEquals")?.as_str()?;
911            Some(Constraint::LessThanOrEquals(field_key(other).to_string()))
912        }
913        _ => None,
914    }
915}
916
917// ---------------------------------------------------------------------------
918// End-to-end
919// ---------------------------------------------------------------------------
920
921/// Everything a bag family's validator needs, read from a SHACL directory.
922#[derive(Debug)]
923pub struct ShapeTable {
924    pub shapes: Vec<ShapeDef>,
925    /// Profile IRI → short code, for `dcterms:conformsTo`.
926    pub profile_iris: Vec<(String, String)>,
927    /// Every profile code, sorted.
928    pub profiles: Vec<String>,
929    pub stats: Stats,
930}
931
932/// Read a family's SHACL directory into a resolved shape table.
933///
934/// `dir` is the directory holding the constraint files; the profile manifests
935/// are expected in `dir/Validation` and the profile descriptors in
936/// `dir/../PROF`, which is how the ENTSO-E library lays them out.
937pub fn load_shape_table(
938    family: &'static Family,
939    spec: &CimSpecification,
940    others: &[&CimSpecification],
941    dir: &std::path::Path,
942    collector: &mut SkipCollector,
943) -> Result<ShapeTable, Box<dyn std::error::Error>> {
944    let mut ttl_paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
945        .filter_map(|e| e.ok())
946        .map(|e| e.path())
947        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ttl"))
948        .collect();
949    ttl_paths.sort();
950    if ttl_paths.is_empty() {
951        return Err(format!("no SHACL .ttl files in {}", dir.display()).into());
952    }
953
954    let profiles_of: HashMap<String, Vec<String>> = match family.shacl_manifest {
955        Some(manifest) => {
956            let mut m: HashMap<String, Vec<String>> = HashMap::new();
957            for (stem, tag) in manifest {
958                m.entry((*stem).to_string()).or_default().push((*tag).to_string());
959            }
960            m
961        }
962        None => profile_files(&dir.join("Validation"))?,
963    };
964
965    let mut files: Vec<FileResults> = Vec::new();
966    for path in &ttl_paths {
967        match crate::shacl::ttl_import::import_ttl_file(path) {
968            Ok(fr) => files.push(fr),
969            Err(e) => eprintln!("warning: skipping {}: {e}", path.display()),
970        }
971    }
972    // Simplification drops constraints too (deactivated shapes, vacuous
973    // minCount=0), and those belong in the same accounting as the resolution
974    // skips — an unreported drop reads like a rule that passed.
975    for (_, entries) in crate::shacl::simplify::simplify(&mut files) {
976        for e in entries {
977            collector.push(
978                e.class_names.first().map(String::as_str).unwrap_or(""),
979                &e.prop,
980                &e.component,
981                &e.name,
982                &e.reason,
983            );
984        }
985    }
986
987    let (shapes, stats) = resolve_shapes(spec, others, &files, &profiles_of, collector);
988
989    // Profile identity — which IRI a dataset declares, which code that is —
990    // comes from the PROF descriptors for every family. A dataset names them in
991    // `dcterms:conformsTo` (NC) or `md:Model.profile` (CGMES).
992    let prof_dir = dir.parent().map_or_else(|| dir.join("PROF"), |p| p.join("PROF"));
993    let index = crate::import::import_profile_index(&prof_dir)?;
994    let mut profile_iris: Vec<(String, String)> = index
995        .iter()
996        .flat_map(|p| p.iris.iter().map(|i| (i.clone(), p.keyword.clone())))
997        .collect();
998    profile_iris.sort();
999    let mut profiles: Vec<String> = index.iter().map(|p| p.keyword.clone()).collect();
1000    profiles.sort();
1001
1002    Ok(ShapeTable { shapes, profile_iris, profiles, stats })
1003}
1004
1005/// The profile code given to files only the combined manifest imports.
1006///
1007/// NCP's per-profile manifests import the Simple files; every Complex file
1008/// (`AssessedElement-AP-Con-Complex-SHACL.ttl`, …, and
1009/// `NC-AP-Con-Complex-Common-SHACL.ttl`) is imported by the combined
1010/// `NCP-AP-Con-Complex-Validation` manifest alone, whose keyword is `ALL`. Its
1011/// rules relate datasets to one another, so they run once on the merged
1012/// dataset (`cimvalidation::validate_crossprofile`) under this code.
1013pub const MERGED_PROFILE: &str = "ALL";
1014
1015/// TTL base file name → the profile codes whose manifest imports it.
1016///
1017/// A file the combined manifest imports and no per-profile one does gets
1018/// [`MERGED_PROFILE`]; one imported by both keeps only its per-profile codes,
1019/// since those already run it on every file.
1020pub fn profile_files(
1021    dir: &std::path::Path,
1022) -> Result<HashMap<String, Vec<String>>, Box<dyn std::error::Error>> {
1023    let mut map: HashMap<String, Vec<String>> = HashMap::new();
1024    let mut merged_only: Vec<String> = Vec::new();
1025    let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(dir)?
1026        .filter_map(|e| e.ok())
1027        .map(|e| e.path())
1028        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ttl"))
1029        .collect();
1030    paths.sort();
1031    for path in &paths {
1032        let m = crate::shacl::ttl_import::import_manifest(path)?;
1033        if m.profile == MERGED_PROFILE {
1034            merged_only = m.imports;
1035            continue;
1036        }
1037        for file in m.imports {
1038            map.entry(file).or_default().push(m.profile.clone());
1039        }
1040    }
1041    for file in merged_only {
1042        map.entry(file).or_insert_with(|| vec![MERGED_PROFILE.to_string()]);
1043    }
1044    for v in map.values_mut() {
1045        v.sort();
1046        v.dedup();
1047    }
1048    Ok(map)
1049}