gldf_rs/
validation.rs

1//! Validation engine for GLDF files.
2//!
3//! Provides validation of GLDF products against the schema rules,
4//! checking for required fields, reference integrity, and data consistency.
5
6use crate::gldf::GldfProduct;
7use std::collections::{HashMap, HashSet};
8
9/// Severity level of a validation issue.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ValidationLevel {
12    /// Critical error - the GLDF is invalid and cannot be used
13    Error,
14    /// Warning - the GLDF may work but has potential issues
15    Warning,
16    /// Informational - suggestion for improvement
17    Info,
18}
19
20/// A validation error or warning.
21#[derive(Debug, Clone)]
22pub struct ValidationError {
23    /// The path to the problematic field (e.g., "variants\[0\].geometry")
24    pub path: String,
25    /// The severity level
26    pub level: ValidationLevel,
27    /// Human-readable description of the issue
28    pub message: String,
29    /// Error code for programmatic handling
30    pub code: &'static str,
31}
32
33impl ValidationError {
34    /// Creates a new validation error.
35    pub fn error(path: impl Into<String>, code: &'static str, message: impl Into<String>) -> Self {
36        Self {
37            path: path.into(),
38            level: ValidationLevel::Error,
39            message: message.into(),
40            code,
41        }
42    }
43
44    /// Creates a new validation warning.
45    pub fn warning(
46        path: impl Into<String>,
47        code: &'static str,
48        message: impl Into<String>,
49    ) -> Self {
50        Self {
51            path: path.into(),
52            level: ValidationLevel::Warning,
53            message: message.into(),
54            code,
55        }
56    }
57
58    /// Creates a new informational validation message.
59    pub fn info(path: impl Into<String>, code: &'static str, message: impl Into<String>) -> Self {
60        Self {
61            path: path.into(),
62            level: ValidationLevel::Info,
63            message: message.into(),
64            code,
65        }
66    }
67}
68
69/// Result of validating a GLDF product.
70#[derive(Debug, Clone, Default)]
71pub struct ValidationResult {
72    /// All validation issues found
73    pub errors: Vec<ValidationError>,
74}
75
76impl ValidationResult {
77    /// Creates an empty validation result.
78    pub fn new() -> Self {
79        Self { errors: Vec::new() }
80    }
81
82    /// Adds an error to the result.
83    pub fn add(&mut self, error: ValidationError) {
84        self.errors.push(error);
85    }
86
87    /// Returns true if there are any errors (not warnings or info).
88    pub fn has_errors(&self) -> bool {
89        self.errors
90            .iter()
91            .any(|e| e.level == ValidationLevel::Error)
92    }
93
94    /// Returns true if there are any warnings.
95    pub fn has_warnings(&self) -> bool {
96        self.errors
97            .iter()
98            .any(|e| e.level == ValidationLevel::Warning)
99    }
100
101    /// Returns true if the result is completely clean (no issues at all).
102    pub fn is_valid(&self) -> bool {
103        self.errors.is_empty()
104    }
105
106    /// Returns only errors (excludes warnings and info).
107    pub fn errors_only(&self) -> Vec<&ValidationError> {
108        self.errors
109            .iter()
110            .filter(|e| e.level == ValidationLevel::Error)
111            .collect()
112    }
113
114    /// Returns only warnings.
115    pub fn warnings_only(&self) -> Vec<&ValidationError> {
116        self.errors
117            .iter()
118            .filter(|e| e.level == ValidationLevel::Warning)
119            .collect()
120    }
121
122    /// Returns the count of issues by level.
123    pub fn count_by_level(&self) -> (usize, usize, usize) {
124        let errors = self
125            .errors
126            .iter()
127            .filter(|e| e.level == ValidationLevel::Error)
128            .count();
129        let warnings = self
130            .errors
131            .iter()
132            .filter(|e| e.level == ValidationLevel::Warning)
133            .count();
134        let info = self
135            .errors
136            .iter()
137            .filter(|e| e.level == ValidationLevel::Info)
138            .count();
139        (errors, warnings, info)
140    }
141}
142
143/// Validates a GLDF product.
144///
145/// # Arguments
146/// * `product` - The GLDF product to validate
147/// * `embedded_files` - Map of file IDs to their binary content (for checking file references)
148///
149/// # Returns
150/// A `ValidationResult` containing all validation issues found.
151pub fn validate_gldf(
152    product: &GldfProduct,
153    embedded_files: &HashMap<String, Vec<u8>>,
154) -> ValidationResult {
155    let mut result = ValidationResult::new();
156
157    // Collect all defined file IDs for reference checking
158    let file_ids: HashSet<&str> = product
159        .general_definitions
160        .files
161        .file
162        .iter()
163        .map(|f| f.id.as_str())
164        .collect();
165
166    // Validate header
167    validate_header(product, &mut result);
168
169    // Validate file definitions
170    validate_files(product, embedded_files, &mut result);
171
172    // Validate photometries
173    validate_photometries(product, &file_ids, &mut result);
174
175    // Validate geometries
176    validate_geometries(product, &file_ids, &mut result);
177
178    // Validate light sources
179    validate_light_sources(product, &mut result);
180
181    // Validate emitters
182    validate_emitters(product, &mut result);
183
184    // Validate variants
185    validate_variants(product, &mut result);
186
187    // Check ID uniqueness
188    validate_id_uniqueness(product, &mut result);
189
190    result
191}
192
193fn validate_header(product: &GldfProduct, result: &mut ValidationResult) {
194    let header = &product.header;
195
196    // Author is required
197    if header.author.is_empty() {
198        result.add(ValidationError::error(
199            "header.author",
200            "HEADER_001",
201            "Author is required",
202        ));
203    }
204
205    // Manufacturer is required
206    if header.manufacturer.is_empty() {
207        result.add(ValidationError::error(
208            "header.manufacturer",
209            "HEADER_002",
210            "Manufacturer is required",
211        ));
212    }
213
214    // Format version check - major should be at least 1
215    let version = &header.format_version;
216    if version.major < 1 {
217        result.add(ValidationError::warning(
218            "header.formatVersion",
219            "HEADER_003",
220            "Format version major should be at least 1",
221        ));
222    }
223
224    // Creation time code recommended
225    if header.creation_time_code.is_empty() {
226        result.add(ValidationError::info(
227            "header.creationTimeCode",
228            "HEADER_004",
229            "Consider adding a creation time code",
230        ));
231    }
232}
233
234fn validate_files(
235    product: &GldfProduct,
236    embedded_files: &HashMap<String, Vec<u8>>,
237    result: &mut ValidationResult,
238) {
239    for (i, file) in product.general_definitions.files.file.iter().enumerate() {
240        let path = format!("generalDefinitions.files[{}]", i);
241
242        // File ID is required
243        if file.id.is_empty() {
244            result.add(ValidationError::error(
245                format!("{}.id", path),
246                "FILE_001",
247                "File ID is required",
248            ));
249        }
250
251        // Content type is required
252        if file.content_type.is_empty() {
253            result.add(ValidationError::error(
254                format!("{}.contentType", path),
255                "FILE_002",
256                "Content type is required",
257            ));
258        }
259
260        // File name is required
261        if file.file_name.is_empty() {
262            result.add(ValidationError::error(
263                format!("{}.fileName", path),
264                "FILE_003",
265                "File name is required",
266            ));
267        }
268
269        // For local files, check if embedded content exists
270        if file.type_attr != "url" && !file.id.is_empty() && !embedded_files.contains_key(&file.id)
271        {
272            result.add(ValidationError::warning(
273                path.to_string(),
274                "FILE_004",
275                format!(
276                    "Embedded file '{}' not found for file definition '{}'",
277                    file.file_name, file.id
278                ),
279            ));
280        }
281
282        // Validate content type format
283        let valid_content_types = [
284            "ldc/eulumdat",
285            "ldc/ies",
286            "geo/l3d",
287            "geo/m3d",
288            "geo/r3d",
289            "image/png",
290            "image/jpg",
291            "image/jpeg",
292            "image/svg",
293            "document/pdf",
294            "spectrum/txt",
295            "sensor/sens-ldt",
296            "symbol/dxf",
297            "symbol/svg",
298            "other",
299        ];
300
301        if !file.content_type.is_empty()
302            && !valid_content_types.iter().any(|ct| {
303                file.content_type
304                    .starts_with(ct.split('/').next().unwrap_or(""))
305            })
306        {
307            result.add(ValidationError::warning(
308                format!("{}.contentType", path),
309                "FILE_005",
310                format!("Unusual content type: '{}'", file.content_type),
311            ));
312        }
313    }
314}
315
316fn validate_photometries(
317    product: &GldfProduct,
318    file_ids: &HashSet<&str>,
319    result: &mut ValidationResult,
320) {
321    if let Some(ref photometries) = product.general_definitions.photometries {
322        for (i, photometry) in photometries.photometry.iter().enumerate() {
323            let path = format!("generalDefinitions.photometries[{}]", i);
324
325            // Check photometry ID
326            if photometry.id.is_empty() {
327                result.add(ValidationError::error(
328                    format!("{}.id", path),
329                    "PHOT_001",
330                    "Photometry ID is required",
331                ));
332            }
333
334            // Check file reference if present
335            if let Some(ref file_ref) = photometry.photometry_file_reference {
336                if !file_ids.contains(file_ref.file_id.as_str()) {
337                    result.add(ValidationError::error(
338                        format!("{}.photometryFileReference.fileId", path),
339                        "PHOT_002",
340                        format!(
341                            "Referenced file '{}' not found in file definitions",
342                            file_ref.file_id
343                        ),
344                    ));
345                }
346            }
347        }
348    }
349}
350
351fn validate_geometries(
352    product: &GldfProduct,
353    file_ids: &HashSet<&str>,
354    result: &mut ValidationResult,
355) {
356    if let Some(ref geometries) = product.general_definitions.geometries {
357        // Validate simple geometries
358        for (i, geom) in geometries.simple_geometry.iter().enumerate() {
359            let path = format!("generalDefinitions.geometries.simpleGeometry[{}]", i);
360
361            if geom.id.is_empty() {
362                result.add(ValidationError::error(
363                    format!("{}.id", path),
364                    "GEOM_001",
365                    "Geometry ID is required",
366                ));
367            }
368        }
369
370        // Validate model geometries
371        for (i, geom) in geometries.model_geometry.iter().enumerate() {
372            let path = format!("generalDefinitions.geometries.modelGeometry[{}]", i);
373
374            if geom.id.is_empty() {
375                result.add(ValidationError::error(
376                    format!("{}.id", path),
377                    "GEOM_002",
378                    "Model geometry ID is required",
379                ));
380            }
381
382            // Check geometry file references
383            for (j, file_ref) in geom.geometry_file_reference.iter().enumerate() {
384                if !file_ids.contains(file_ref.file_id.as_str()) {
385                    result.add(ValidationError::error(
386                        format!("{}.geometryFileReference[{}].fileId", path, j),
387                        "GEOM_003",
388                        format!("Referenced geometry file '{}' not found", file_ref.file_id),
389                    ));
390                }
391            }
392        }
393    }
394}
395
396fn validate_light_sources(product: &GldfProduct, result: &mut ValidationResult) {
397    if let Some(ref light_sources) = product.general_definitions.light_sources {
398        // Validate fixed light sources
399        for (i, source) in light_sources.fixed_light_source.iter().enumerate() {
400            let path = format!("generalDefinitions.lightSources.fixedLightSource[{}]", i);
401
402            if source.id.is_empty() {
403                result.add(ValidationError::error(
404                    format!("{}.id", path),
405                    "LS_001",
406                    "Fixed light source ID is required",
407                ));
408            }
409        }
410
411        // Validate changeable light sources
412        for (i, source) in light_sources.changeable_light_source.iter().enumerate() {
413            let path = format!(
414                "generalDefinitions.lightSources.changeableLightSource[{}]",
415                i
416            );
417
418            if source.id.is_empty() {
419                result.add(ValidationError::error(
420                    format!("{}.id", path),
421                    "LS_002",
422                    "Changeable light source ID is required",
423                ));
424            }
425        }
426    }
427}
428
429fn validate_emitters(product: &GldfProduct, result: &mut ValidationResult) {
430    if let Some(ref emitters) = product.general_definitions.emitters {
431        for (i, emitter) in emitters.emitter.iter().enumerate() {
432            let path = format!("generalDefinitions.emitters[{}]", i);
433
434            if emitter.id.is_empty() {
435                result.add(ValidationError::error(
436                    format!("{}.id", path),
437                    "EMIT_001",
438                    "Emitter ID is required",
439                ));
440            }
441        }
442    }
443}
444
445fn validate_variants(product: &GldfProduct, result: &mut ValidationResult) {
446    if let Some(ref variants) = product.product_definitions.variants {
447        if variants.variant.is_empty() {
448            result.add(ValidationError::warning(
449                "productDefinitions.variants",
450                "VAR_001",
451                "No variants defined - consider adding at least one variant",
452            ));
453        }
454
455        for (i, variant) in variants.variant.iter().enumerate() {
456            let path = format!("productDefinitions.variants[{}]", i);
457
458            if variant.id.is_empty() {
459                result.add(ValidationError::error(
460                    format!("{}.id", path),
461                    "VAR_002",
462                    "Variant ID is required",
463                ));
464            }
465        }
466    } else {
467        result.add(ValidationError::warning(
468            "productDefinitions.variants",
469            "VAR_003",
470            "No variants section - consider adding variants",
471        ));
472    }
473}
474
475fn validate_id_uniqueness(product: &GldfProduct, result: &mut ValidationResult) {
476    // Check file ID uniqueness
477    let mut file_ids = HashSet::new();
478    for file in &product.general_definitions.files.file {
479        if !file.id.is_empty() && !file_ids.insert(&file.id) {
480            result.add(ValidationError::error(
481                format!("generalDefinitions.files.{}", file.id),
482                "UNIQUE_001",
483                format!("Duplicate file ID: '{}'", file.id),
484            ));
485        }
486    }
487
488    // Check variant ID uniqueness
489    if let Some(ref variants) = product.product_definitions.variants {
490        let mut variant_ids = HashSet::new();
491        for variant in &variants.variant {
492            if !variant.id.is_empty() && !variant_ids.insert(&variant.id) {
493                result.add(ValidationError::error(
494                    format!("productDefinitions.variants.{}", variant.id),
495                    "UNIQUE_002",
496                    format!("Duplicate variant ID: '{}'", variant.id),
497                ));
498            }
499        }
500    }
501
502    // Check photometry ID uniqueness
503    if let Some(ref photometries) = product.general_definitions.photometries {
504        let mut phot_ids = HashSet::new();
505        for photometry in &photometries.photometry {
506            if !photometry.id.is_empty() && !phot_ids.insert(&photometry.id) {
507                result.add(ValidationError::error(
508                    format!("generalDefinitions.photometries.{}", photometry.id),
509                    "UNIQUE_003",
510                    format!("Duplicate photometry ID: '{}'", photometry.id),
511                ));
512            }
513        }
514    }
515
516    // Check emitter ID uniqueness
517    if let Some(ref emitters) = product.general_definitions.emitters {
518        let mut emitter_ids = HashSet::new();
519        for emitter in &emitters.emitter {
520            if !emitter.id.is_empty() && !emitter_ids.insert(&emitter.id) {
521                result.add(ValidationError::error(
522                    format!("generalDefinitions.emitters.{}", emitter.id),
523                    "UNIQUE_004",
524                    format!("Duplicate emitter ID: '{}'", emitter.id),
525                ));
526            }
527        }
528    }
529}
530
531impl GldfProduct {
532    /// Validates this GLDF product.
533    ///
534    /// # Arguments
535    /// * `embedded_files` - Map of file IDs to their binary content
536    ///
537    /// # Returns
538    /// A `ValidationResult` containing all validation issues.
539    pub fn validate(&self, embedded_files: &HashMap<String, Vec<u8>>) -> ValidationResult {
540        validate_gldf(self, embedded_files)
541    }
542
543    /// Validates this GLDF product without checking embedded files.
544    ///
545    /// This is useful when you only want to validate the structure.
546    pub fn validate_structure(&self) -> ValidationResult {
547        validate_gldf(self, &HashMap::new())
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn test_validation_result_creation() {
557        let mut result = ValidationResult::new();
558        assert!(result.is_valid());
559        assert!(!result.has_errors());
560
561        result.add(ValidationError::error("test", "TEST_001", "Test error"));
562        assert!(!result.is_valid());
563        assert!(result.has_errors());
564    }
565
566    #[test]
567    fn test_validation_levels() {
568        let mut result = ValidationResult::new();
569        result.add(ValidationError::error("a", "E001", "Error"));
570        result.add(ValidationError::warning("b", "W001", "Warning"));
571        result.add(ValidationError::info("c", "I001", "Info"));
572
573        let (errors, warnings, info) = result.count_by_level();
574        assert_eq!(errors, 1);
575        assert_eq!(warnings, 1);
576        assert_eq!(info, 1);
577    }
578
579    #[test]
580    fn test_default_product_validation() {
581        let product = GldfProduct::default();
582        let result = product.validate_structure();
583
584        // Default product should have some errors (missing required fields)
585        assert!(result.has_errors());
586    }
587}