Skip to main content

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        validator.validate(&event).unwrap();
242    }
243
244    #[test]
245    fn test_full_validation_passes() {
246        let event = valid_event();
247        let validator = Validator::new(ValidationLevel::Full);
248        validator.validate(&event).unwrap();
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        validator.validate(&event).unwrap_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        validator.validate(&event).unwrap_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        validator.validate(&event).unwrap_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        validator.validate(&event).unwrap();
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        validator.validate(&event).unwrap();
301    }
302
303    #[test]
304    fn test_convenience_validate_event() {
305        let event = valid_event();
306        validate_event(&event).unwrap();
307    }
308
309    #[test]
310    fn test_convenience_validate_event_full() {
311        let event = valid_event();
312        validate_event_full(&event).unwrap();
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.unwrap_err().to_string().contains("udm_version"));
323    }
324
325    #[test]
326    fn test_empty_sdk_version_fails() {
327        let mut event = valid_event();
328        event.sdk_version = String::new();
329
330        let validator = Validator::new(ValidationLevel::Basic);
331        let result = validator.validate(&event);
332        assert!(result.unwrap_err().to_string().contains("sdk_version"));
333    }
334
335    #[test]
336    fn test_empty_identity_source_id_fails() {
337        let event = valid_event().with_identity(IdentityDomain {
338            source_id: Some(String::new()), // Empty string
339            ..Default::default()
340        });
341
342        let validator = Validator::new(ValidationLevel::Full);
343        let result = validator.validate(&event);
344        assert!(result
345            .unwrap_err()
346            .to_string()
347            .contains("identity.source_id"));
348    }
349
350    #[test]
351    fn test_identity_without_source_id_passes() {
352        // source_id is None, not empty string - should pass
353        let event = valid_event().with_identity(IdentityDomain {
354            source_id: None,
355            ..Default::default()
356        });
357
358        let validator = Validator::new(ValidationLevel::Full);
359        validator.validate(&event).unwrap();
360    }
361
362    #[test]
363    fn test_invalid_negative_latitude_fails() {
364        let event = valid_event().with_location(LocationDomain {
365            latitude: Some(-100.0), // Invalid: should be -90 to 90
366            ..Default::default()
367        });
368
369        let validator = Validator::new(ValidationLevel::Full);
370        let result = validator.validate(&event);
371        assert!(result.unwrap_err().to_string().contains("latitude"));
372    }
373
374    #[test]
375    fn test_invalid_longitude_too_high_fails() {
376        let event = valid_event().with_location(LocationDomain {
377            longitude: Some(200.0), // Invalid: should be -180 to 180
378            ..Default::default()
379        });
380
381        let validator = Validator::new(ValidationLevel::Full);
382        let result = validator.validate(&event);
383        assert!(result.unwrap_err().to_string().contains("longitude"));
384    }
385
386    #[test]
387    fn test_invalid_longitude_too_low_fails() {
388        let event = valid_event().with_location(LocationDomain {
389            longitude: Some(-200.0), // Invalid: should be -180 to 180
390            ..Default::default()
391        });
392
393        let validator = Validator::new(ValidationLevel::Full);
394        let result = validator.validate(&event);
395        assert!(result.unwrap_err().to_string().contains("longitude"));
396    }
397
398    #[test]
399    fn test_invalid_heading_fails() {
400        let event = valid_event().with_location(LocationDomain {
401            heading_deg: Some(400.0), // Invalid: outside both [0, 360] and [-180, 180]
402            ..Default::default()
403        });
404
405        let validator = Validator::new(ValidationLevel::Full);
406        let result = validator.validate(&event);
407        assert!(result.unwrap_err().to_string().contains("heading_deg"));
408    }
409
410    #[test]
411    fn test_valid_heading_0_360_passes() {
412        let event = valid_event().with_location(LocationDomain {
413            heading_deg: Some(270.0), // Valid: in [0, 360]
414            ..Default::default()
415        });
416
417        let validator = Validator::new(ValidationLevel::Full);
418        validator.validate(&event).unwrap();
419    }
420
421    #[test]
422    fn test_valid_heading_negative_passes() {
423        let event = valid_event().with_location(LocationDomain {
424            heading_deg: Some(-90.0), // Valid: in [-180, 180]
425            ..Default::default()
426        });
427
428        let validator = Validator::new(ValidationLevel::Full);
429        validator.validate(&event).unwrap();
430    }
431
432    #[test]
433    fn test_valid_location_with_all_fields() {
434        let event = valid_event().with_location(LocationDomain {
435            latitude: Some(45.0),
436            longitude: Some(-122.0),
437            heading_deg: Some(180.0),
438            ..Default::default()
439        });
440
441        let validator = Validator::new(ValidationLevel::Full);
442        validator.validate(&event).unwrap();
443    }
444
445    #[test]
446    fn test_invalid_soc_negative_fails() {
447        let event = valid_event().with_power(PowerDomain {
448            battery: Some(Battery {
449                state_of_charge_pct: Some(-10.0), // Invalid: should be 0-100
450                ..Default::default()
451            }),
452            ..Default::default()
453        });
454
455        let validator = Validator::new(ValidationLevel::Full);
456        let result = validator.validate(&event);
457        assert!(result
458            .unwrap_err()
459            .to_string()
460            .contains("state_of_charge_pct"));
461    }
462
463    #[test]
464    fn test_invalid_soh_too_high_fails() {
465        let event = valid_event().with_power(PowerDomain {
466            battery: Some(Battery {
467                state_of_health_pct: Some(110.0), // Invalid: should be 0-100
468                ..Default::default()
469            }),
470            ..Default::default()
471        });
472
473        let validator = Validator::new(ValidationLevel::Full);
474        let result = validator.validate(&event);
475        assert!(result
476            .unwrap_err()
477            .to_string()
478            .contains("state_of_health_pct"));
479    }
480
481    #[test]
482    fn test_invalid_soh_negative_fails() {
483        let event = valid_event().with_power(PowerDomain {
484            battery: Some(Battery {
485                state_of_health_pct: Some(-5.0), // Invalid: should be 0-100
486                ..Default::default()
487            }),
488            ..Default::default()
489        });
490
491        let validator = Validator::new(ValidationLevel::Full);
492        let result = validator.validate(&event);
493        assert!(result
494            .unwrap_err()
495            .to_string()
496            .contains("state_of_health_pct"));
497    }
498
499    #[test]
500    fn test_invalid_voltage_negative_fails() {
501        let event = valid_event().with_power(PowerDomain {
502            battery: Some(Battery {
503                voltage_v: Some(-12.0), // Invalid: should be non-negative
504                ..Default::default()
505            }),
506            ..Default::default()
507        });
508
509        let validator = Validator::new(ValidationLevel::Full);
510        let result = validator.validate(&event);
511        assert!(result.unwrap_err().to_string().contains("voltage_v"));
512    }
513
514    #[test]
515    fn test_valid_power_with_all_fields() {
516        let event = valid_event().with_power(PowerDomain {
517            battery: Some(Battery {
518                state_of_charge_pct: Some(75.0),
519                state_of_health_pct: Some(95.0),
520                voltage_v: Some(48.0),
521                ..Default::default()
522            }),
523            ..Default::default()
524        });
525
526        let validator = Validator::new(ValidationLevel::Full);
527        validator.validate(&event).unwrap();
528    }
529
530    #[test]
531    fn test_power_without_battery_passes() {
532        let event = valid_event().with_power(PowerDomain {
533            battery: None,
534            ..Default::default()
535        });
536
537        let validator = Validator::new(ValidationLevel::Full);
538        validator.validate(&event).unwrap();
539    }
540
541    #[test]
542    fn test_invalid_proximity_distance_negative_fails() {
543        let event = valid_event().with_safety(SafetyDomain {
544            proximity: Some(ProximityInfo {
545                closest_distance_m: Some(-1.0), // Invalid: should be non-negative
546                ..Default::default()
547            }),
548            ..Default::default()
549        });
550
551        let validator = Validator::new(ValidationLevel::Full);
552        let result = validator.validate(&event);
553        assert!(result
554            .unwrap_err()
555            .to_string()
556            .contains("closest_distance_m"));
557    }
558
559    #[test]
560    fn test_valid_proximity_distance_passes() {
561        let event = valid_event().with_safety(SafetyDomain {
562            proximity: Some(ProximityInfo {
563                closest_distance_m: Some(2.5),
564                ..Default::default()
565            }),
566            ..Default::default()
567        });
568
569        let validator = Validator::new(ValidationLevel::Full);
570        validator.validate(&event).unwrap();
571    }
572
573    #[test]
574    fn test_safety_without_proximity_passes() {
575        let event = valid_event().with_safety(SafetyDomain {
576            proximity: None,
577            ..Default::default()
578        });
579
580        let validator = Validator::new(ValidationLevel::Full);
581        validator.validate(&event).unwrap();
582    }
583
584    #[test]
585    fn test_proximity_without_closest_distance_passes() {
586        let event = valid_event().with_safety(SafetyDomain {
587            proximity: Some(ProximityInfo {
588                closest_distance_m: None,
589                humans_nearby: Some(2),
590                ..Default::default()
591            }),
592            ..Default::default()
593        });
594
595        let validator = Validator::new(ValidationLevel::Full);
596        validator.validate(&event).unwrap();
597    }
598
599    #[test]
600    fn test_full_validation_with_all_domains() {
601        let event = valid_event()
602            .with_identity(IdentityDomain {
603                source_id: Some("robot-001".to_string()),
604                ..Default::default()
605            })
606            .with_location(LocationDomain {
607                latitude: Some(37.7749),
608                longitude: Some(-122.4194),
609                heading_deg: Some(45.0),
610                ..Default::default()
611            })
612            .with_power(PowerDomain {
613                battery: Some(Battery {
614                    state_of_charge_pct: Some(80.0),
615                    state_of_health_pct: Some(98.0),
616                    voltage_v: Some(24.0),
617                    ..Default::default()
618                }),
619                ..Default::default()
620            })
621            .with_safety(SafetyDomain {
622                proximity: Some(ProximityInfo {
623                    closest_distance_m: Some(3.0),
624                    ..Default::default()
625                }),
626                ..Default::default()
627            });
628
629        let validator = Validator::new(ValidationLevel::Full);
630        validator.validate(&event).unwrap();
631    }
632
633    #[test]
634    fn test_edge_case_latitude_boundaries() {
635        // Test exact boundaries
636        let event_min = valid_event().with_location(LocationDomain {
637            latitude: Some(-90.0),
638            ..Default::default()
639        });
640        let event_max = valid_event().with_location(LocationDomain {
641            latitude: Some(90.0),
642            ..Default::default()
643        });
644
645        let validator = Validator::new(ValidationLevel::Full);
646        validator.validate(&event_min).unwrap();
647        validator.validate(&event_max).unwrap();
648    }
649
650    #[test]
651    fn test_edge_case_longitude_boundaries() {
652        // Test exact boundaries
653        let event_min = valid_event().with_location(LocationDomain {
654            longitude: Some(-180.0),
655            ..Default::default()
656        });
657        let event_max = valid_event().with_location(LocationDomain {
658            longitude: Some(180.0),
659            ..Default::default()
660        });
661
662        let validator = Validator::new(ValidationLevel::Full);
663        validator.validate(&event_min).unwrap();
664        validator.validate(&event_max).unwrap();
665    }
666
667    #[test]
668    fn test_edge_case_soc_boundaries() {
669        // Test exact boundaries
670        let event_min = valid_event().with_power(PowerDomain {
671            battery: Some(Battery {
672                state_of_charge_pct: Some(0.0),
673                ..Default::default()
674            }),
675            ..Default::default()
676        });
677        let event_max = valid_event().with_power(PowerDomain {
678            battery: Some(Battery {
679                state_of_charge_pct: Some(100.0),
680                ..Default::default()
681            }),
682            ..Default::default()
683        });
684
685        let validator = Validator::new(ValidationLevel::Full);
686        validator.validate(&event_min).unwrap();
687        validator.validate(&event_max).unwrap();
688    }
689
690    #[test]
691    fn test_edge_case_zero_voltage_passes() {
692        let event = valid_event().with_power(PowerDomain {
693            battery: Some(Battery {
694                voltage_v: Some(0.0), // Zero is valid (non-negative)
695                ..Default::default()
696            }),
697            ..Default::default()
698        });
699
700        let validator = Validator::new(ValidationLevel::Full);
701        validator.validate(&event).unwrap();
702    }
703
704    #[test]
705    fn test_edge_case_zero_proximity_passes() {
706        let event = valid_event().with_safety(SafetyDomain {
707            proximity: Some(ProximityInfo {
708                closest_distance_m: Some(0.0), // Zero is valid (non-negative)
709                ..Default::default()
710            }),
711            ..Default::default()
712        });
713
714        let validator = Validator::new(ValidationLevel::Full);
715        validator.validate(&event).unwrap();
716    }
717}