Skip to main content

cimvalidation/
lib.rs

1pub mod violation;
2pub use violation::Violation;
3
4pub mod sparql;
5pub mod detect;
6pub use detect::{detect_config, detect_nc_profiles};
7
8use std::collections::HashSet;
9
10#[derive(Debug, Default, Clone)]
11pub struct Config {
12    /// Profile short names to validate (e.g. "EQ", "SSH"). Empty = all detected.
13    pub profiles: Vec<String>,
14    /// True if SV profile (power-flow results) is present.
15    pub solved: bool,
16    /// True if SV profile is absent.
17    pub not_solved: bool,
18    /// Run common cross-profile checks.
19    pub common: bool,
20    /// Run CIMdesk modeling quality checks.
21    pub quality: bool,
22    /// Rule IDs to suppress in the output.
23    pub silenced_rules: Vec<String>,
24    /// When non-empty, enables the EQBD2 base voltage check.
25    pub eqbd_base_voltage_ids: Option<HashSet<String>>,
26}
27
28pub mod helpers;
29mod par;
30
31// ── property-bag families ──────────────────────────────────────────────────
32//
33// Elements of both families are property bags; each family's shapes are a
34// data table interpreted by `bag`.
35pub mod bag;
36pub mod shapes;
37pub mod shape_source;
38pub mod nc_shapes;
39pub mod nc_profiles;
40
41/// The NC shape table in force: loaded from SHACL if the `dynamic-shapes`
42/// feature is on and a directory was supplied, otherwise the generated one.
43pub fn nc_shapes() -> &'static [shapes::ShapeDef] {
44    static R: std::sync::OnceLock<&'static [shapes::ShapeDef]> = std::sync::OnceLock::new();
45    R.get_or_init(|| shape_source::resolve("nc", nc_shapes::SHAPES))
46}
47
48/// Profile IRI → short code, and every short code.
49pub type ProfileIndex = (&'static [(&'static str, &'static str)], &'static [&'static str]);
50
51/// The NC profile index in force. Loaded together with the shapes, since an
52/// index and a table from different releases would run the wrong rules.
53pub fn nc_profile_index() -> ProfileIndex {
54    static R: std::sync::OnceLock<ProfileIndex> = std::sync::OnceLock::new();
55    *R.get_or_init(|| {
56        shape_source::resolve_profiles("nc", nc_profiles::PROFILE_IRIS, nc_profiles::PROFILES)
57    })
58}
59
60/// Run one NC profile's shapes against a dataset, and the hand-written rules
61/// of the files every NC profile imports (`sparql::nc::validate_local`).
62///
63/// Separate from `validate_profile_local` so a caller can drive it with an
64/// explicit profile code, without depending on header detection.
65pub fn validate_nc_profile(
66    dataset: &cimmodel::CimDataset,
67    profile: &str,
68    cfg: &Config,
69) -> Vec<Violation> {
70    validate_nc_profile_in(dataset, &[], profile, cfg)
71}
72
73/// [`validate_nc_profile`] on one file of a set, as [`validate_profile_local_in`].
74pub fn validate_nc_profile_in(
75    dataset: &cimmodel::CimDataset,
76    set: &[cimmodel::CimDataset],
77    profile: &str,
78    cfg: &Config,
79) -> Vec<Violation> {
80    let mut v = bag::validate_profile_in(dataset, set, profile, nc_shapes(), cfg);
81    // Every NC profile's manifest imports the files these come from, and no
82    // other code names one.
83    if nc_profile_index().1.contains(&profile) {
84        v.extend(sparql::nc::validate_local(dataset));
85    }
86    v
87}
88
89/// The NC rules that relate datasets: the Complex files only the combined
90/// manifest imports, as table shapes and hand-written rules, on the merged
91/// dataset. [`validate_crossprofile`] runs this when an NC profile is in play.
92pub fn validate_nc_merged(dataset: &cimmodel::CimDataset, cfg: &Config) -> Vec<Violation> {
93    let mut v = bag::validate_profile(dataset, MERGED_PROFILE, nc_shapes(), cfg);
94    v.extend(sparql::nc::validate_merged(dataset));
95    v
96}
97
98/// The profile code of the shapes [`validate_nc_merged`] runs: the combined
99/// manifest's keyword, as `cimschema::shacl::resolve::MERGED_PROFILE` assigns it.
100/// Repeated here because `cimoxide-schema` is optional; a test holds the two
101/// together.
102pub const MERGED_PROFILE: &str = "ALL";
103
104
105// ── CGMES shape table ──────────────────────────────────────────────────────
106//
107// CGMES runs through the same interpreter as NC, reading the typed elements'
108// decoder blocks; it replaced ~250k lines of generated per-check functions.
109// Its constraint files carry tags rather than profile codes — see
110// `cimschema::shacl::cgmes_manifest` — so one table holds the local,
111// not-solved, cross-profile and header rules apart.
112pub mod cgmes_shapes;
113pub mod cgmes_profiles;
114
115/// The CGMES shape table in force: loaded from SHACL if the `dynamic-shapes`
116/// feature is on and a directory was supplied, otherwise the generated one.
117pub fn cgmes_shapes() -> &'static [shapes::ShapeDef] {
118    static R: std::sync::OnceLock<&'static [shapes::ShapeDef]> = std::sync::OnceLock::new();
119    R.get_or_init(|| shape_source::resolve("cgmes", cgmes_shapes::SHAPES))
120}
121
122/// The CGMES shapes carrying one manifest tag, indexed once per process.
123fn cgmes_tagged(tag: &str) -> &'static [&'static shapes::ShapeDef] {
124    type Index = std::collections::HashMap<&'static str, Vec<&'static shapes::ShapeDef>>;
125    static IDX: std::sync::OnceLock<Index> = std::sync::OnceLock::new();
126    let idx = IDX.get_or_init(|| {
127        let mut idx = Index::new();
128        for shape in cgmes_shapes() {
129            for tag in shape.profiles {
130                idx.entry(*tag).or_default().push(shape);
131            }
132        }
133        idx
134    });
135    idx.get(tag).map_or(&[], Vec::as_slice)
136}
137
138fn run_cgmes(dataset: &cimmodel::CimDataset, active: &[&shapes::ShapeDef]) -> Vec<Violation> {
139    bag::validate_shapes(dataset, bag::Source::Cgmes, active)
140}
141
142fn run_cgmes_in(
143    dataset: &cimmodel::CimDataset,
144    set: &[cimmodel::CimDataset],
145    active: &[&shapes::ShapeDef],
146) -> Vec<Violation> {
147    bag::validate_shapes_in(dataset, set, bag::Source::Cgmes, active)
148}
149
150// ── public two-phase API ───────────────────────────────────────────────────
151
152/// Phase 1 — header: validate FullModel/DifferenceModel rules for a single file's dataset.
153pub fn validate_header(dataset: &cimmodel::CimDataset, _cfg: &Config) -> Vec<Violation> {
154    run_cgmes(dataset, cgmes_tagged("HDR"))
155}
156
157/// The SHACL half of [`validate_profile_local`] for one CGMES profile: its
158/// local rules. Without the hand-written `sparql` rules or the `cfg.profiles`
159/// filter.
160pub fn validate_profile_shacl(dataset: &cimmodel::CimDataset, profile: &str, _cfg: &Config) -> Vec<Violation> {
161    run_cgmes(dataset, cgmes_tagged(profile))
162}
163
164/// The SHACL half of [`validate_crossprofile`]: every enabled profile's
165/// cross-profile rules on the merged dataset, its not-solved (`!NS`, the
166/// NotSolvedMAS files) rules when `cfg.not_solved` — written for a model
167/// authority set, like the hand-written ones in [`sparql::mas_groups`] — and
168/// the rules for every profile when `cfg.common`.
169pub fn validate_crossprofile_shacl(dataset: &cimmodel::CimDataset, cfg: &Config) -> Vec<Violation> {
170    let has = |p: &str| cfg.profiles.is_empty() || cfg.profiles.iter().any(|x| x == p);
171    let mut active: Vec<&shapes::ShapeDef> = cimschema_cross_profiles()
172        .iter()
173        .filter(|p| has(p))
174        .flat_map(|p| cgmes_tagged(&format!("X:{p}")).iter().copied())
175        .collect();
176    if cfg.not_solved {
177        for p in cgmes_profile_index().1.iter().filter(|p| has(p)) {
178            active.extend_from_slice(cgmes_tagged(&format!("{p}!NS")));
179        }
180    }
181    if cfg.common {
182        active.extend_from_slice(cgmes_tagged("COMMON"));
183    }
184    run_cgmes(dataset, &active)
185}
186
187/// The CGMES profile index in force: `md:Model.profile` IRI → short code, and
188/// every short code, read from `CGMES/PROF`. Loaded together with the shapes,
189/// like [`nc_profile_index`].
190pub fn cgmes_profile_index() -> ProfileIndex {
191    static R: std::sync::OnceLock<ProfileIndex> = std::sync::OnceLock::new();
192    *R.get_or_init(|| {
193        shape_source::resolve_profiles("cgmes", cgmes_profiles::PROFILE_IRIS, cgmes_profiles::PROFILES)
194    })
195}
196
197/// The profiles with cross-profile rules. Mirrors
198/// `cimschema::shacl::cgmes_manifest::CROSS_PROFILES`, which this crate does
199/// not depend on outside the `dynamic-shapes` feature.
200fn cimschema_cross_profiles() -> &'static [&'static str] {
201    &["DL", "DY", "GL", "OP", "SV", "TP"]
202}
203
204
205/// Phase 1 — per-profile: run the local (non-crossprofile) SHACL rules and the SPARQL
206/// rules for one profile.
207///
208/// Pass the single-file dataset for the profile and the combined config (solved/not_solved
209/// must reflect the full set of files, not just this file). If `cfg.profiles` is non-empty
210/// and does not include `profile`, returns an empty vec — this filter applies to both the
211/// SHACL and SPARQL checks.
212pub fn validate_profile_local(dataset: &cimmodel::CimDataset, profile: &str, cfg: &Config) -> Vec<Violation> {
213    validate_profile_local_in(dataset, &[], profile, cfg)
214}
215
216/// [`validate_profile_local`] on `dataset`, one of the files `set` validated
217/// together, as [`validate_files`] runs it. The rules still see `dataset`
218/// alone, except that an NC class check (`sh:class`, `sh:in` on a type) finds
219/// the type of a referenced object in whichever file of `set` holds it: a
220/// schedule's unit, a remedial action's equipment. Without the set those
221/// checks are silent on every association that leaves the file. CGMES's
222/// per-profile value types are written for one file and stay so
223/// (`bag::any_type`).
224pub fn validate_profile_local_in(
225    dataset: &cimmodel::CimDataset,
226    set: &[cimmodel::CimDataset],
227    profile: &str,
228    cfg: &Config,
229) -> Vec<Violation> {
230    if !cfg.profiles.is_empty() && !cfg.profiles.iter().any(|p| p == profile) {
231        return Vec::new();
232    }
233    // NC profile codes cannot collide with CGMES ones, so one flat
234    // `Config::profiles` list carries both families. The CGMES codes are
235    // checked first: asking the NC index loads the NC table — 170 ms when it
236    // comes from SHACL — for data that has no NC in it.
237    if !cgmes_profile_index().1.contains(&profile) && nc_profile_index().1.contains(&profile) {
238        return validate_nc_profile_in(dataset, set, profile, cfg);
239    }
240
241    let mut v = run_cgmes_in(dataset, set, cgmes_tagged(profile));
242    v.extend(sparql::validate_profile_local(dataset, profile, cfg));
243    v
244}
245
246
247/// Phase 2 — crossprofile: run crossprofile SHACL + cross-profile SPARQL on the merged dataset.
248///
249/// The two halves run concurrently; the SPARQL half spreads its rule groups
250/// over threads of its own.
251pub fn validate_crossprofile(dataset: &cimmodel::CimDataset, cfg: &Config) -> Vec<Violation> {
252    let (sparql, mut v) = par::par_two(
253        || sparql::validate_crossprofile(dataset, cfg),
254        || validate_crossprofile_shacl(dataset, cfg),
255    );
256    v.extend(sparql);
257    // CGMES codes first, as in `validate_profile_local`: data without NC
258    // never loads the NC table.
259    let cgmes = cgmes_profile_index().1;
260    if cfg.profiles.iter().any(|p| !cgmes.contains(&p.as_str()) && nc_profile_index().1.contains(&p.as_str())) {
261        v.extend(validate_nc_merged(dataset, cfg));
262    }
263    v
264}
265
266/// Build a combined `Config` by auto-detecting profiles/solved-state across all files,
267/// then applying explicit overrides (each `None`/default leaves the detected value).
268pub fn combined_config(
269    per_file: &[cimmodel::CimDataset],
270    profiles: Option<Vec<String>>,
271    solved: Option<bool>,
272    common: bool,
273    quality: bool,
274    silenced_rules: Vec<String>,
275) -> Config {
276    let mut cfg = Config::default();
277    for ds in per_file {
278        let c = detect_config(ds);
279        for p in c.profiles.into_iter().chain(detect_nc_profiles(ds)) {
280            if !cfg.profiles.contains(&p) {
281                cfg.profiles.push(p);
282            }
283        }
284        cfg.solved |= c.solved;
285    }
286    cfg.not_solved = !cfg.solved;
287    if let Some(p) = profiles { cfg.profiles = p; }
288    if let Some(s) = solved { cfg.solved = s; cfg.not_solved = !s; }
289    cfg.common = common;
290    cfg.quality = quality;
291    cfg.silenced_rules = silenced_rules;
292    cfg
293}
294
295/// Run full two-phase validation: per-file local checks (header + per-profile SHACL/SPARQL)
296/// in parallel, then crossprofile checks on the merged dataset, then apply rule silencing.
297///
298/// Consumes `per_file` since it merges them into one dataset for phase 2.
299pub fn validate_files(per_file: Vec<cimmodel::CimDataset>, cfg: &Config) -> Vec<Violation> {
300    validate_files_traced(per_file, cfg).into_iter().map(|(_, v)| v).collect()
301}
302
303/// [`validate_files`], with each finding's origin: the index into `per_file`
304/// of the file it was found in, or `None` for a finding on the merged dataset.
305/// An object several files write is reported against one of them, which
306/// `cimlsp` needs to put the finding there.
307pub fn validate_files_traced(per_file: Vec<cimmodel::CimDataset>, cfg: &Config) -> Vec<(Option<usize>, Violation)> {
308    let set = per_file.as_slice();
309    let mut violations: Vec<(Option<usize>, Violation)> = par::par_each(set, |ds| {
310        let mut v = validate_header(ds, cfg);
311        let mut profiles = detect_config(ds).profiles;
312        profiles.extend(detect_nc_profiles(ds));
313        for profile in &profiles {
314            v.extend(validate_profile_local_in(ds, set, profile, cfg));
315        }
316        v
317    })
318    .into_iter()
319    .enumerate()
320    .flat_map(|(i, found)| found.into_iter().map(move |v| (Some(i), v)))
321    .collect();
322
323    let mut merged = cimmodel::CimDataset::new();
324    for ds in per_file {
325        merged.merge(ds);
326    }
327    violations.extend(validate_crossprofile(&merged, cfg).into_iter().map(|v| (None, v)));
328
329    if !cfg.silenced_rules.is_empty() {
330        let silenced: HashSet<&str> = cfg.silenced_rules.iter().map(String::as_str).collect();
331        violations.retain(|(_, v)| !silenced.contains(v.rule_id.as_str()));
332    }
333    violations
334}