phytrace_sdk/core/
validation.rs

1//! Event validation using the validator crate.
2//!
3//! Provides runtime validation of UDM events to ensure data quality.
4
5use validator::Validate;
6
7use crate::error::{PhyTraceError, PhyTraceResult};
8use crate::models::event::UdmEvent;
9
10/// Validation levels.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ValidationLevel {
13    /// No validation (fastest).
14    None,
15    /// Basic validation (required fields only).
16    Basic,
17    /// Full validation (all constraints).
18    Full,
19}
20
21/// Validator for UDM events.
22#[derive(Debug, Clone)]
23pub struct Validator {
24    level: ValidationLevel,
25}
26
27impl Default for Validator {
28    fn default() -> Self {
29        Self {
30            level: ValidationLevel::Basic,
31        }
32    }
33}
34
35impl Validator {
36    /// Create a new validator with the specified level.
37    pub fn new(level: ValidationLevel) -> Self {
38        Self { level }
39    }
40
41    /// Validate an event.
42    pub fn validate(&self, event: &UdmEvent) -> PhyTraceResult<()> {
43        match self.level {
44            ValidationLevel::None => Ok(()),
45            ValidationLevel::Basic => self.validate_basic(event),
46            ValidationLevel::Full => self.validate_full(event),
47        }
48    }
49
50    /// Basic validation - required envelope fields.
51    fn validate_basic(&self, event: &UdmEvent) -> PhyTraceResult<()> {
52        // Check required envelope fields
53        if event.event_id.is_empty() {
54            return Err(PhyTraceError::Validation(
55                "event_id is required".to_string(),
56            ));
57        }
58
59        if event.udm_version.is_empty() {
60            return Err(PhyTraceError::Validation(
61                "udm_version is required".to_string(),
62            ));
63        }
64
65        if event.sdk_version.is_empty() {
66            return Err(PhyTraceError::Validation(
67                "sdk_version is required".to_string(),
68            ));
69        }
70
71        Ok(())
72    }
73
74    /// Full validation - all constraints including domain validations.
75    fn validate_full(&self, event: &UdmEvent) -> PhyTraceResult<()> {
76        // First do basic validation
77        self.validate_basic(event)?;
78
79        // Validate the event struct itself
80        event
81            .validate()
82            .map_err(|e| PhyTraceError::Validation(format!("Event validation failed: {}", e)))?;
83
84        // Validate each domain if present
85        if let Some(ref identity) = event.identity {
86            self.validate_identity(identity)?;
87        }
88
89        if let Some(ref location) = event.location {
90            self.validate_location(location)?;
91        }
92
93        if let Some(ref power) = event.power {
94            self.validate_power(power)?;
95        }
96
97        if let Some(ref safety) = event.safety {
98            self.validate_safety(safety)?;
99        }
100
101        Ok(())
102    }
103
104    /// Validate identity domain.
105    fn validate_identity(
106        &self,
107        identity: &crate::models::domains::IdentityDomain,
108    ) -> PhyTraceResult<()> {
109        // source_id should be present and non-empty
110        if let Some(ref source_id) = identity.source_id {
111            if source_id.is_empty() {
112                return Err(PhyTraceError::Validation(
113                    "identity.source_id cannot be empty".to_string(),
114                ));
115            }
116        }
117        Ok(())
118    }
119
120    /// Validate location domain.
121    fn validate_location(
122        &self,
123        location: &crate::models::domains::LocationDomain,
124    ) -> PhyTraceResult<()> {
125        // Check latitude/longitude ranges if present
126        if let Some(lat) = location.latitude {
127            if !(-90.0..=90.0).contains(&lat) {
128                return Err(PhyTraceError::Validation(format!(
129                    "latitude must be between -90 and 90, got {}",
130                    lat
131                )));
132            }
133        }
134
135        if let Some(lon) = location.longitude {
136            if !(-180.0..=180.0).contains(&lon) {
137                return Err(PhyTraceError::Validation(format!(
138                    "longitude must be between -180 and 180, got {}",
139                    lon
140                )));
141            }
142        }
143
144        // Check heading range if present (using degrees)
145        if let Some(heading) = location.heading_deg {
146            if !(0.0..=360.0).contains(&heading) && !(-180.0..=180.0).contains(&heading) {
147                return Err(PhyTraceError::Validation(format!(
148                    "heading_deg should be in range [0, 360] or [-180, 180], got {}",
149                    heading
150                )));
151            }
152        }
153
154        Ok(())
155    }
156
157    /// Validate power domain.
158    fn validate_power(&self, power: &crate::models::domains::PowerDomain) -> PhyTraceResult<()> {
159        if let Some(ref battery) = power.battery {
160            // Validate SOC percentage
161            if let Some(soc) = battery.state_of_charge_pct {
162                if !(0.0..=100.0).contains(&soc) {
163                    return Err(PhyTraceError::Validation(format!(
164                        "battery.state_of_charge_pct must be between 0 and 100, got {}",
165                        soc
166                    )));
167                }
168            }
169
170            // Validate health percentage
171            if let Some(health) = battery.state_of_health_pct {
172                if !(0.0..=100.0).contains(&health) {
173                    return Err(PhyTraceError::Validation(format!(
174                        "battery.state_of_health_pct must be between 0 and 100, got {}",
175                        health
176                    )));
177                }
178            }
179
180            // Validate voltage (should be positive)
181            if let Some(voltage) = battery.voltage_v {
182                if voltage < 0.0 {
183                    return Err(PhyTraceError::Validation(format!(
184                        "battery.voltage_v must be non-negative, got {}",
185                        voltage
186                    )));
187                }
188            }
189        }
190
191        Ok(())
192    }
193
194    /// Validate safety domain.
195    fn validate_safety(&self, safety: &crate::models::domains::SafetyDomain) -> PhyTraceResult<()> {
196        // Validate proximity distances (should be non-negative)
197        if let Some(ref proximity) = safety.proximity {
198            if let Some(closest) = proximity.closest_distance_m {
199                if closest < 0.0 {
200                    return Err(PhyTraceError::Validation(format!(
201                        "safety.proximity.closest_distance_m must be non-negative, got {}",
202                        closest
203                    )));
204                }
205            }
206        }
207
208        Ok(())
209    }
210}
211
212/// Convenience function for quick validation.
213pub fn validate_event(event: &UdmEvent) -> PhyTraceResult<()> {
214    Validator::default().validate(event)
215}
216
217/// Convenience function for full validation.
218pub fn validate_event_full(event: &UdmEvent) -> PhyTraceResult<()> {
219    Validator::new(ValidationLevel::Full).validate(event)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::models::domains::power::Battery;
226    use crate::models::domains::safety::ProximityInfo;
227    use crate::models::domains::{IdentityDomain, LocationDomain, PowerDomain, SafetyDomain};
228    use crate::models::enums::SourceType;
229
230    fn valid_event() -> UdmEvent {
231        UdmEvent::new(SourceType::Amr).with_identity(IdentityDomain {
232            source_id: Some("robot-001".to_string()),
233            ..Default::default()
234        })
235    }
236
237    #[test]
238    fn test_basic_validation_passes() {
239        let event = valid_event();
240        let validator = Validator::new(ValidationLevel::Basic);
241        assert!(validator.validate(&event).is_ok());
242    }
243
244    #[test]
245    fn test_full_validation_passes() {
246        let event = valid_event();
247        let validator = Validator::new(ValidationLevel::Full);
248        assert!(validator.validate(&event).is_ok());
249    }
250
251    #[test]
252    fn test_empty_event_id_fails() {
253        let mut event = valid_event();
254        event.event_id = String::new();
255
256        let validator = Validator::new(ValidationLevel::Basic);
257        assert!(validator.validate(&event).is_err());
258    }
259
260    #[test]
261    fn test_invalid_latitude_fails() {
262        let event = valid_event().with_location(LocationDomain {
263            latitude: Some(100.0), // Invalid: should be -90 to 90
264            ..Default::default()
265        });
266
267        let validator = Validator::new(ValidationLevel::Full);
268        assert!(validator.validate(&event).is_err());
269    }
270
271    #[test]
272    fn test_invalid_soc_fails() {
273        let event = valid_event().with_power(PowerDomain {
274            battery: Some(Battery {
275                state_of_charge_pct: Some(150.0), // Invalid: should be 0-100
276                ..Default::default()
277            }),
278            ..Default::default()
279        });
280
281        let validator = Validator::new(ValidationLevel::Full);
282        assert!(validator.validate(&event).is_err());
283    }
284
285    #[test]
286    fn test_validation_none_always_passes() {
287        let mut event = valid_event();
288        event.event_id = String::new(); // Would normally fail
289
290        let validator = Validator::new(ValidationLevel::None);
291        assert!(validator.validate(&event).is_ok());
292    }
293
294    // Additional tests for improved coverage
295
296    #[test]
297    fn test_default_validator() {
298        let validator = Validator::default();
299        let event = valid_event();
300        assert!(validator.validate(&event).is_ok());
301    }
302
303    #[test]
304    fn test_convenience_validate_event() {
305        let event = valid_event();
306        assert!(validate_event(&event).is_ok());
307    }
308
309    #[test]
310    fn test_convenience_validate_event_full() {
311        let event = valid_event();
312        assert!(validate_event_full(&event).is_ok());
313    }
314
315    #[test]
316    fn test_empty_udm_version_fails() {
317        let mut event = valid_event();
318        event.udm_version = String::new();
319
320        let validator = Validator::new(ValidationLevel::Basic);
321        let result = validator.validate(&event);
322        assert!(result.is_err());
323        assert!(result.unwrap_err().to_string().contains("udm_version"));
324    }
325
326    #[test]
327    fn test_empty_sdk_version_fails() {
328        let mut event = valid_event();
329        event.sdk_version = String::new();
330
331        let validator = Validator::new(ValidationLevel::Basic);
332        let result = validator.validate(&event);
333        assert!(result.is_err());
334        assert!(result.unwrap_err().to_string().contains("sdk_version"));
335    }
336
337    #[test]
338    fn test_empty_identity_source_id_fails() {
339        let event = valid_event().with_identity(IdentityDomain {
340            source_id: Some(String::new()), // Empty string
341            ..Default::default()
342        });
343
344        let validator = Validator::new(ValidationLevel::Full);
345        let result = validator.validate(&event);
346        assert!(result.is_err());
347        assert!(result
348            .unwrap_err()
349            .to_string()
350            .contains("identity.source_id"));
351    }
352
353    #[test]
354    fn test_identity_without_source_id_passes() {
355        // source_id is None, not empty string - should pass
356        let event = valid_event().with_identity(IdentityDomain {
357            source_id: None,
358            ..Default::default()
359        });
360
361        let validator = Validator::new(ValidationLevel::Full);
362        assert!(validator.validate(&event).is_ok());
363    }
364
365    #[test]
366    fn test_invalid_negative_latitude_fails() {
367        let event = valid_event().with_location(LocationDomain {
368            latitude: Some(-100.0), // Invalid: should be -90 to 90
369            ..Default::default()
370        });
371
372        let validator = Validator::new(ValidationLevel::Full);
373        let result = validator.validate(&event);
374        assert!(result.is_err());
375        assert!(result.unwrap_err().to_string().contains("latitude"));
376    }
377
378    #[test]
379    fn test_invalid_longitude_too_high_fails() {
380        let event = valid_event().with_location(LocationDomain {
381            longitude: Some(200.0), // Invalid: should be -180 to 180
382            ..Default::default()
383        });
384
385        let validator = Validator::new(ValidationLevel::Full);
386        let result = validator.validate(&event);
387        assert!(result.is_err());
388        assert!(result.unwrap_err().to_string().contains("longitude"));
389    }
390
391    #[test]
392    fn test_invalid_longitude_too_low_fails() {
393        let event = valid_event().with_location(LocationDomain {
394            longitude: Some(-200.0), // Invalid: should be -180 to 180
395            ..Default::default()
396        });
397
398        let validator = Validator::new(ValidationLevel::Full);
399        let result = validator.validate(&event);
400        assert!(result.is_err());
401        assert!(result.unwrap_err().to_string().contains("longitude"));
402    }
403
404    #[test]
405    fn test_invalid_heading_fails() {
406        let event = valid_event().with_location(LocationDomain {
407            heading_deg: Some(400.0), // Invalid: outside both [0, 360] and [-180, 180]
408            ..Default::default()
409        });
410
411        let validator = Validator::new(ValidationLevel::Full);
412        let result = validator.validate(&event);
413        assert!(result.is_err());
414        assert!(result.unwrap_err().to_string().contains("heading_deg"));
415    }
416
417    #[test]
418    fn test_valid_heading_0_360_passes() {
419        let event = valid_event().with_location(LocationDomain {
420            heading_deg: Some(270.0), // Valid: in [0, 360]
421            ..Default::default()
422        });
423
424        let validator = Validator::new(ValidationLevel::Full);
425        assert!(validator.validate(&event).is_ok());
426    }
427
428    #[test]
429    fn test_valid_heading_negative_passes() {
430        let event = valid_event().with_location(LocationDomain {
431            heading_deg: Some(-90.0), // Valid: in [-180, 180]
432            ..Default::default()
433        });
434
435        let validator = Validator::new(ValidationLevel::Full);
436        assert!(validator.validate(&event).is_ok());
437    }
438
439    #[test]
440    fn test_valid_location_with_all_fields() {
441        let event = valid_event().with_location(LocationDomain {
442            latitude: Some(45.0),
443            longitude: Some(-122.0),
444            heading_deg: Some(180.0),
445            ..Default::default()
446        });
447
448        let validator = Validator::new(ValidationLevel::Full);
449        assert!(validator.validate(&event).is_ok());
450    }
451
452    #[test]
453    fn test_invalid_soc_negative_fails() {
454        let event = valid_event().with_power(PowerDomain {
455            battery: Some(Battery {
456                state_of_charge_pct: Some(-10.0), // Invalid: should be 0-100
457                ..Default::default()
458            }),
459            ..Default::default()
460        });
461
462        let validator = Validator::new(ValidationLevel::Full);
463        let result = validator.validate(&event);
464        assert!(result.is_err());
465        assert!(result
466            .unwrap_err()
467            .to_string()
468            .contains("state_of_charge_pct"));
469    }
470
471    #[test]
472    fn test_invalid_soh_too_high_fails() {
473        let event = valid_event().with_power(PowerDomain {
474            battery: Some(Battery {
475                state_of_health_pct: Some(110.0), // Invalid: should be 0-100
476                ..Default::default()
477            }),
478            ..Default::default()
479        });
480
481        let validator = Validator::new(ValidationLevel::Full);
482        let result = validator.validate(&event);
483        assert!(result.is_err());
484        assert!(result
485            .unwrap_err()
486            .to_string()
487            .contains("state_of_health_pct"));
488    }
489
490    #[test]
491    fn test_invalid_soh_negative_fails() {
492        let event = valid_event().with_power(PowerDomain {
493            battery: Some(Battery {
494                state_of_health_pct: Some(-5.0), // Invalid: should be 0-100
495                ..Default::default()
496            }),
497            ..Default::default()
498        });
499
500        let validator = Validator::new(ValidationLevel::Full);
501        let result = validator.validate(&event);
502        assert!(result.is_err());
503        assert!(result
504            .unwrap_err()
505            .to_string()
506            .contains("state_of_health_pct"));
507    }
508
509    #[test]
510    fn test_invalid_voltage_negative_fails() {
511        let event = valid_event().with_power(PowerDomain {
512            battery: Some(Battery {
513                voltage_v: Some(-12.0), // Invalid: should be non-negative
514                ..Default::default()
515            }),
516            ..Default::default()
517        });
518
519        let validator = Validator::new(ValidationLevel::Full);
520        let result = validator.validate(&event);
521        assert!(result.is_err());
522        assert!(result.unwrap_err().to_string().contains("voltage_v"));
523    }
524
525    #[test]
526    fn test_valid_power_with_all_fields() {
527        let event = valid_event().with_power(PowerDomain {
528            battery: Some(Battery {
529                state_of_charge_pct: Some(75.0),
530                state_of_health_pct: Some(95.0),
531                voltage_v: Some(48.0),
532                ..Default::default()
533            }),
534            ..Default::default()
535        });
536
537        let validator = Validator::new(ValidationLevel::Full);
538        assert!(validator.validate(&event).is_ok());
539    }
540
541    #[test]
542    fn test_power_without_battery_passes() {
543        let event = valid_event().with_power(PowerDomain {
544            battery: None,
545            ..Default::default()
546        });
547
548        let validator = Validator::new(ValidationLevel::Full);
549        assert!(validator.validate(&event).is_ok());
550    }
551
552    #[test]
553    fn test_invalid_proximity_distance_negative_fails() {
554        let event = valid_event().with_safety(SafetyDomain {
555            proximity: Some(ProximityInfo {
556                closest_distance_m: Some(-1.0), // Invalid: should be non-negative
557                ..Default::default()
558            }),
559            ..Default::default()
560        });
561
562        let validator = Validator::new(ValidationLevel::Full);
563        let result = validator.validate(&event);
564        assert!(result.is_err());
565        assert!(result
566            .unwrap_err()
567            .to_string()
568            .contains("closest_distance_m"));
569    }
570
571    #[test]
572    fn test_valid_proximity_distance_passes() {
573        let event = valid_event().with_safety(SafetyDomain {
574            proximity: Some(ProximityInfo {
575                closest_distance_m: Some(2.5),
576                ..Default::default()
577            }),
578            ..Default::default()
579        });
580
581        let validator = Validator::new(ValidationLevel::Full);
582        assert!(validator.validate(&event).is_ok());
583    }
584
585    #[test]
586    fn test_safety_without_proximity_passes() {
587        let event = valid_event().with_safety(SafetyDomain {
588            proximity: None,
589            ..Default::default()
590        });
591
592        let validator = Validator::new(ValidationLevel::Full);
593        assert!(validator.validate(&event).is_ok());
594    }
595
596    #[test]
597    fn test_proximity_without_closest_distance_passes() {
598        let event = valid_event().with_safety(SafetyDomain {
599            proximity: Some(ProximityInfo {
600                closest_distance_m: None,
601                humans_nearby: Some(2),
602                ..Default::default()
603            }),
604            ..Default::default()
605        });
606
607        let validator = Validator::new(ValidationLevel::Full);
608        assert!(validator.validate(&event).is_ok());
609    }
610
611    #[test]
612    fn test_full_validation_with_all_domains() {
613        let event = valid_event()
614            .with_identity(IdentityDomain {
615                source_id: Some("robot-001".to_string()),
616                ..Default::default()
617            })
618            .with_location(LocationDomain {
619                latitude: Some(37.7749),
620                longitude: Some(-122.4194),
621                heading_deg: Some(45.0),
622                ..Default::default()
623            })
624            .with_power(PowerDomain {
625                battery: Some(Battery {
626                    state_of_charge_pct: Some(80.0),
627                    state_of_health_pct: Some(98.0),
628                    voltage_v: Some(24.0),
629                    ..Default::default()
630                }),
631                ..Default::default()
632            })
633            .with_safety(SafetyDomain {
634                proximity: Some(ProximityInfo {
635                    closest_distance_m: Some(3.0),
636                    ..Default::default()
637                }),
638                ..Default::default()
639            });
640
641        let validator = Validator::new(ValidationLevel::Full);
642        assert!(validator.validate(&event).is_ok());
643    }
644
645    #[test]
646    fn test_edge_case_latitude_boundaries() {
647        // Test exact boundaries
648        let event_min = valid_event().with_location(LocationDomain {
649            latitude: Some(-90.0),
650            ..Default::default()
651        });
652        let event_max = valid_event().with_location(LocationDomain {
653            latitude: Some(90.0),
654            ..Default::default()
655        });
656
657        let validator = Validator::new(ValidationLevel::Full);
658        assert!(validator.validate(&event_min).is_ok());
659        assert!(validator.validate(&event_max).is_ok());
660    }
661
662    #[test]
663    fn test_edge_case_longitude_boundaries() {
664        // Test exact boundaries
665        let event_min = valid_event().with_location(LocationDomain {
666            longitude: Some(-180.0),
667            ..Default::default()
668        });
669        let event_max = valid_event().with_location(LocationDomain {
670            longitude: Some(180.0),
671            ..Default::default()
672        });
673
674        let validator = Validator::new(ValidationLevel::Full);
675        assert!(validator.validate(&event_min).is_ok());
676        assert!(validator.validate(&event_max).is_ok());
677    }
678
679    #[test]
680    fn test_edge_case_soc_boundaries() {
681        // Test exact boundaries
682        let event_min = valid_event().with_power(PowerDomain {
683            battery: Some(Battery {
684                state_of_charge_pct: Some(0.0),
685                ..Default::default()
686            }),
687            ..Default::default()
688        });
689        let event_max = valid_event().with_power(PowerDomain {
690            battery: Some(Battery {
691                state_of_charge_pct: Some(100.0),
692                ..Default::default()
693            }),
694            ..Default::default()
695        });
696
697        let validator = Validator::new(ValidationLevel::Full);
698        assert!(validator.validate(&event_min).is_ok());
699        assert!(validator.validate(&event_max).is_ok());
700    }
701
702    #[test]
703    fn test_edge_case_zero_voltage_passes() {
704        let event = valid_event().with_power(PowerDomain {
705            battery: Some(Battery {
706                voltage_v: Some(0.0), // Zero is valid (non-negative)
707                ..Default::default()
708            }),
709            ..Default::default()
710        });
711
712        let validator = Validator::new(ValidationLevel::Full);
713        assert!(validator.validate(&event).is_ok());
714    }
715
716    #[test]
717    fn test_edge_case_zero_proximity_passes() {
718        let event = valid_event().with_safety(SafetyDomain {
719            proximity: Some(ProximityInfo {
720                closest_distance_m: Some(0.0), // Zero is valid (non-negative)
721                ..Default::default()
722            }),
723            ..Default::default()
724        });
725
726        let validator = Validator::new(ValidationLevel::Full);
727        assert!(validator.validate(&event).is_ok());
728    }
729}