Skip to main content

openbim_mmc/
validation.rs

1use std::collections::{HashMap, HashSet};
2
3use iri_string::types::IriReferenceStr;
4
5use crate::{
6    path::validate_archive_path, ApplicationModel, MetadataEntry, MmcArchive, Relatum,
7    ResourceLocation,
8};
9
10/// Stable machine-readable conformance issue codes.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12#[non_exhaustive]
13pub enum ValidationCode {
14    UnsupportedFormatVersion,
15    TooFewApplicationModels,
16    ApplicationModelMissingRepresentation,
17    ModelDataMissingResource,
18    LinkModelHasNoLinks,
19    DuplicateModelId,
20    DuplicateRepresentationId,
21    DuplicateResourceId,
22    DuplicateMetadataKey,
23
24    DuplicateLinkModelLocation,
25    InvalidResourceLocation,
26    MissingEmbeddedResource,
27    MissingEmbeddedLinkModel,
28    TooFewLinkedModels,
29    UnknownModelReference,
30    UnknownRepresentationReference,
31    UnknownResourceReference,
32    MissingRepresentationDisambiguator,
33    MissingResourceDisambiguator,
34    LinkHasTooFewRelata,
35    LinkHasTooFewModels,
36    RateTargetsUnknownModel,
37}
38
39/// One deterministic conformance finding.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ValidationIssue {
42    pub code: ValidationCode,
43    pub location: String,
44    pub message: String,
45}
46
47/// Complete structural and referential-integrity report for an opened archive.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct ValidationReport {
50    issues: Vec<ValidationIssue>,
51}
52
53impl ValidationReport {
54    #[must_use]
55    pub fn is_valid(&self) -> bool {
56        self.issues.is_empty()
57    }
58
59    #[must_use]
60    pub fn issues(&self) -> &[ValidationIssue] {
61        &self.issues
62    }
63
64    #[must_use]
65    pub fn contains(&self, code: ValidationCode) -> bool {
66        self.issues.iter().any(|issue| issue.code == code)
67    }
68
69    fn push(
70        &mut self,
71        code: ValidationCode,
72        location: impl Into<String>,
73        message: impl Into<String>,
74    ) {
75        self.issues.push(ValidationIssue {
76            code,
77            location: location.into(),
78            message: message.into(),
79        });
80    }
81}
82
83pub(crate) fn validate(archive: &MmcArchive) -> ValidationReport {
84    let container = archive.container();
85    let mut report = ValidationReport::default();
86    validate_metadata(
87        &container.metadata.metadata,
88        "MultiModel.xml/MetaData",
89        &mut report,
90    );
91    if container.metadata.format_version != "2.0.0" {
92        report.push(
93            ValidationCode::UnsupportedFormatVersion,
94            "MultiModel.xml@formatVersion",
95            "MMC 2.0 namespace requires formatVersion 2.0.0",
96        );
97    }
98    if let Some(domain) = &container.metadata.mm_domain {
99        if IriReferenceStr::new(domain).is_err() {
100            report.push(
101                ValidationCode::InvalidResourceLocation,
102                "MultiModel.xml@mmDomain",
103                "mmDomain is not a valid IRI reference",
104            );
105        }
106    }
107    if container.models.is_empty() {
108        report.push(
109            ValidationCode::TooFewApplicationModels,
110            "MultiModel.xml",
111            "MMC 2.0 requires at least one ApplicationModel declaration",
112        );
113    }
114
115    let mut model_ids = HashSet::new();
116    let mut models_by_id = HashMap::new();
117    for (model_index, model) in container.models.iter().enumerate() {
118        let model_location = format!("ApplicationModel[{model_index}]");
119        validate_metadata(&model.metadata, &model_location, &mut report);
120
121        if !model_ids.insert(model.id.as_str()) {
122            report.push(
123                ValidationCode::DuplicateModelId,
124                &model_location,
125                format!("duplicate application-model id {}", model.id),
126            );
127        }
128        models_by_id.entry(model.id.as_str()).or_insert(model);
129        if model.representations.is_empty() {
130            report.push(
131                ValidationCode::ApplicationModelMissingRepresentation,
132                &model_location,
133                "ApplicationModel requires at least one ModelData",
134            );
135        }
136        let mut representation_ids = HashSet::new();
137        for (data_index, data) in model.representations.iter().enumerate() {
138            let data_location = format!("{model_location}/ModelData[{data_index}]");
139            validate_metadata(&data.metadata, &data_location, &mut report);
140
141            if !representation_ids.insert(data.id.as_str()) {
142                report.push(
143                    ValidationCode::DuplicateRepresentationId,
144                    &data_location,
145                    format!(
146                        "duplicate representation id {} in model {}",
147                        data.id, model.id
148                    ),
149                );
150            }
151            if data.resources.is_empty() {
152                report.push(
153                    ValidationCode::ModelDataMissingResource,
154                    &data_location,
155                    "ModelData requires at least one DataRessource",
156                );
157            }
158            let mut resource_ids = HashSet::new();
159            for (resource_index, resource) in data.resources.iter().enumerate() {
160                let resource_location = format!("{data_location}/DataRessource[{resource_index}]");
161                validate_metadata(&resource.metadata, &resource_location, &mut report);
162
163                if !resource_ids.insert(resource.id.as_str()) {
164                    report.push(
165                        ValidationCode::DuplicateResourceId,
166                        &resource_location,
167                        format!(
168                            "duplicate resource id {} in representation {}",
169                            resource.id, data.id
170                        ),
171                    );
172                }
173                validate_location(
174                    archive,
175                    &resource.location,
176                    &resource_location,
177                    ValidationCode::MissingEmbeddedResource,
178                    &mut report,
179                );
180            }
181        }
182    }
183
184    let mut link_locations = HashSet::new();
185    for (reference_index, reference) in container.link_models.iter().enumerate() {
186        let location = format!("LinkModel[{reference_index}]");
187        validate_metadata(&reference.metadata, &location, &mut report);
188        let location_key = match &reference.location {
189            ResourceLocation::Embedded(path) => format!("embedded:{path}"),
190            ResourceLocation::External(uri) => format!("external:{uri}"),
191        };
192        if !link_locations.insert(location_key) {
193            report.push(
194                ValidationCode::DuplicateLinkModelLocation,
195                &location,
196                "duplicate LinkModel location",
197            );
198        }
199        validate_location(
200            archive,
201            &reference.location,
202            &location,
203            ValidationCode::MissingEmbeddedLinkModel,
204            &mut report,
205        );
206        let distinct_linked_models = reference
207            .linked_models
208            .iter()
209            .map(String::as_str)
210            .collect::<HashSet<_>>();
211        if distinct_linked_models.len() < 2 {
212            report.push(
213                ValidationCode::TooFewLinkedModels,
214                &location,
215                "LinkModel requires at least two LinkedModel references",
216            );
217        }
218        for model_id in &reference.linked_models {
219            if !model_ids.contains(model_id.as_str()) {
220                report.push(
221                    ValidationCode::UnknownModelReference,
222                    &location,
223                    format!("LinkedModel references undeclared model {model_id}"),
224                );
225            }
226        }
227    }
228
229    for document in archive.parsed_link_models() {
230        validate_metadata(&document.model().metadata, document.path(), &mut report);
231        if document.model().format_version != "2.0.0" {
232            report.push(
233                ValidationCode::UnsupportedFormatVersion,
234                document.path(),
235                "LinkModel 2.0 namespace requires formatVersion 2.0.0",
236            );
237        }
238        let declared = container
239            .link_models
240            .iter()
241            .find(|reference| reference.location.embedded_path() == Some(document.path()))
242            .map(|reference| {
243                reference
244                    .linked_models
245                    .iter()
246                    .map(String::as_str)
247                    .collect::<HashSet<_>>()
248            })
249            .unwrap_or_default();
250        if document.model().links.is_empty() {
251            report.push(
252                ValidationCode::LinkModelHasNoLinks,
253                document.path(),
254                "LinkModel requires at least one Link",
255            );
256        }
257        for (link_index, link) in document.model().links.iter().enumerate() {
258            let location = format!("{}:Link[{link_index}]", document.path());
259            validate_metadata(&link.metadata, &location, &mut report);
260            if link.relata.len() < 2 {
261                report.push(
262                    ValidationCode::LinkHasTooFewRelata,
263                    &location,
264                    "Link requires at least two Relatum elements",
265                );
266            }
267            let distinct_models = link
268                .relata
269                .iter()
270                .map(|relatum| relatum.model_id.as_str())
271                .collect::<HashSet<_>>();
272            if distinct_models.len() < 2 {
273                report.push(
274                    ValidationCode::LinkHasTooFewModels,
275                    &location,
276                    "Link requires relata from at least two application models",
277                );
278            }
279            for (relatum_index, relatum) in link.relata.iter().enumerate() {
280                let relatum_location = format!("{location}/Relatum[{relatum_index}]");
281                validate_metadata(&relatum.metadata, &relatum_location, &mut report);
282
283                if !model_ids.contains(relatum.model_id.as_str()) {
284                    report.push(
285                        ValidationCode::UnknownModelReference,
286                        &relatum_location,
287                        format!("Relatum references undeclared model {}", relatum.model_id),
288                    );
289                    continue;
290                }
291                if !declared.contains(relatum.model_id.as_str()) {
292                    report.push(
293                        ValidationCode::UnknownModelReference,
294                        &relatum_location,
295                        format!(
296                            "Relatum model {} is not declared by the containing LinkModel",
297                            relatum.model_id
298                        ),
299                    );
300                }
301                if let Some(model) = models_by_id.get(relatum.model_id.as_str()) {
302                    validate_relatum_references(model, relatum, &relatum_location, &mut report);
303                }
304                for rate in &relatum.rates {
305                    if !model_ids.contains(rate.target_model.as_str()) {
306                        report.push(
307                            ValidationCode::RateTargetsUnknownModel,
308                            &relatum_location,
309                            format!("Rate targets undeclared model {}", rate.target_model),
310                        );
311                    }
312                }
313            }
314        }
315    }
316
317    report.issues.sort_by(|left, right| {
318        left.location
319            .cmp(&right.location)
320            .then_with(|| format!("{:?}", left.code).cmp(&format!("{:?}", right.code)))
321            .then_with(|| left.message.cmp(&right.message))
322    });
323    report
324}
325
326fn validate_relatum_references(
327    model: &ApplicationModel,
328    relatum: &Relatum,
329    location: &str,
330    report: &mut ValidationReport,
331) {
332    let representation = match &relatum.format_id {
333        Some(id) => match model.representations.iter().find(|data| data.id == *id) {
334            Some(data) => Some(data),
335            None => {
336                report.push(
337                    ValidationCode::UnknownRepresentationReference,
338                    location,
339                    format!("unknown representation {id} in model {}", model.id),
340                );
341                None
342            }
343        },
344        None if model.representations.len() == 1 => model.representations.first(),
345        None => {
346            report.push(
347                ValidationCode::MissingRepresentationDisambiguator,
348                location,
349                "Relatum requires @f when its application model has multiple representations",
350            );
351            None
352        }
353    };
354    let Some(representation) = representation else {
355        return;
356    };
357    match &relatum.resource_id {
358        Some(id)
359            if !representation
360                .resources
361                .iter()
362                .any(|resource| resource.id == *id) =>
363        {
364            report.push(
365                ValidationCode::UnknownResourceReference,
366                location,
367                format!(
368                    "unknown resource {id} in representation {}",
369                    representation.id
370                ),
371            );
372        }
373        None if representation.resources.len() > 1 => report.push(
374            ValidationCode::MissingResourceDisambiguator,
375            location,
376            "Relatum requires @r when its representation has multiple resources",
377        ),
378        _ => {}
379    }
380}
381
382fn validate_metadata(entries: &[MetadataEntry], location: &str, report: &mut ValidationReport) {
383    let mut keys = HashSet::new();
384    for entry in entries {
385        if !keys.insert((entry.category.as_deref(), entry.key.as_str())) {
386            report.push(
387                ValidationCode::DuplicateMetadataKey,
388                location,
389                format!("duplicate metadata key {:?}/{}", entry.category, entry.key),
390            );
391        }
392    }
393}
394
395fn validate_location(
396    archive: &MmcArchive,
397    location: &ResourceLocation,
398    issue_location: &str,
399    missing_code: ValidationCode,
400    report: &mut ValidationReport,
401) {
402    match location {
403        ResourceLocation::Embedded(path) => {
404            if validate_archive_path(path).is_err() {
405                report.push(
406                    ValidationCode::InvalidResourceLocation,
407                    issue_location,
408                    format!("unsafe embedded location {path}"),
409                );
410            } else if archive.entry(path).is_none() {
411                report.push(
412                    missing_code,
413                    issue_location,
414                    format!("embedded resource is absent: {path}"),
415                );
416            }
417        }
418        ResourceLocation::External(uri) => {
419            if IriReferenceStr::new(uri).is_err() {
420                report.push(
421                    ValidationCode::InvalidResourceLocation,
422                    issue_location,
423                    format!("invalid external IRI: {uri}"),
424                );
425            }
426        }
427    }
428}