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#[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#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ValidationIssue {
42 pub code: ValidationCode,
43 pub location: String,
44 pub message: String,
45}
46
47#[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}