1use super::{ValidationError, ValidationResult};
6use crate::effector::v1::{
7 AmmunitionStatus, Authorization, AuthorizationLevel, EffectorCategory, EffectorCommand,
8 EffectorCommandType, EffectorSpec, EffectorStateUpdate, EffectorStatus, EffectorType,
9 FiringSolution, SafetyInterlocks, TargetDesignation,
10};
11
12pub fn validate_safety_interlocks(_interlocks: &SafetyInterlocks) -> ValidationResult<()> {
18 Ok(())
22}
23
24pub fn validate_ammunition_status(status: &AmmunitionStatus) -> ValidationResult<()> {
31 if status.rounds_ready > status.rounds_total {
32 return Err(ValidationError::ConstraintViolation(
33 "rounds_ready cannot exceed rounds_total".to_string(),
34 ));
35 }
36
37 if status.magazine_capacity == 0 && status.magazines_available > 0 {
38 return Err(ValidationError::InvalidValue(
39 "magazine_capacity must be > 0 if magazines_available > 0".to_string(),
40 ));
41 }
42
43 if status.reload_time_remaining_s < 0.0 {
44 return Err(ValidationError::InvalidValue(
45 "reload_time_remaining_s must be non-negative".to_string(),
46 ));
47 }
48
49 Ok(())
50}
51
52pub fn validate_firing_solution(solution: &FiringSolution) -> ValidationResult<()> {
59 if solution.quality < 0.0 || solution.quality > 1.0 {
60 return Err(ValidationError::InvalidValue(format!(
61 "quality {} must be in range [0.0, 1.0]",
62 solution.quality
63 )));
64 }
65
66 if solution.hit_probability < 0.0 || solution.hit_probability > 1.0 {
67 return Err(ValidationError::InvalidValue(format!(
68 "hit_probability {} must be in range [0.0, 1.0]",
69 solution.hit_probability
70 )));
71 }
72
73 if solution.time_to_impact_s < 0.0 {
74 return Err(ValidationError::InvalidValue(
75 "time_to_impact_s must be non-negative".to_string(),
76 ));
77 }
78
79 Ok(())
80}
81
82pub fn validate_target_designation(target: &TargetDesignation) -> ValidationResult<()> {
88 if target.target_track_id.is_empty() {
89 return Err(ValidationError::MissingField("target_track_id".to_string()));
90 }
91
92 if target.range_m < 0.0 {
93 return Err(ValidationError::InvalidValue(
94 "range_m must be non-negative".to_string(),
95 ));
96 }
97
98 Ok(())
99}
100
101pub fn validate_authorization(auth: &Authorization) -> ValidationResult<()> {
109 if auth.authorization_id.is_empty() {
110 return Err(ValidationError::MissingField(
111 "authorization_id".to_string(),
112 ));
113 }
114
115 if auth.authorized_by.is_empty() {
116 return Err(ValidationError::MissingField("authorized_by".to_string()));
117 }
118
119 if auth.level == AuthorizationLevel::Unspecified as i32 {
120 return Err(ValidationError::InvalidValue(
121 "authorization level must be specified".to_string(),
122 ));
123 }
124
125 if auth.authorized_at.is_none() {
126 return Err(ValidationError::MissingField("authorized_at".to_string()));
127 }
128
129 if let (Some(authorized), Some(expires)) = (&auth.authorized_at, &auth.expires_at) {
131 if expires.seconds < authorized.seconds
132 || (expires.seconds == authorized.seconds && expires.nanos < authorized.nanos)
133 {
134 return Err(ValidationError::ConstraintViolation(
135 "expires_at must be after authorized_at".to_string(),
136 ));
137 }
138 }
139
140 Ok(())
141}
142
143pub fn validate_effector_spec(spec: &EffectorSpec) -> ValidationResult<()> {
154 if spec.effector_id.is_empty() {
156 return Err(ValidationError::MissingField("effector_id".to_string()));
157 }
158
159 if spec.name.is_empty() {
160 return Err(ValidationError::MissingField("name".to_string()));
161 }
162
163 if spec.effector_type == EffectorType::Unspecified as i32 {
165 return Err(ValidationError::InvalidValue(
166 "effector_type must be specified".to_string(),
167 ));
168 }
169
170 if spec.category == EffectorCategory::Unspecified as i32 {
172 return Err(ValidationError::InvalidValue(
173 "category must be specified".to_string(),
174 ));
175 }
176
177 if spec.min_range_m < 0.0 {
179 return Err(ValidationError::InvalidValue(
180 "min_range_m must be non-negative".to_string(),
181 ));
182 }
183
184 if spec.max_range_m < spec.min_range_m {
185 return Err(ValidationError::ConstraintViolation(
186 "max_range_m must be >= min_range_m".to_string(),
187 ));
188 }
189
190 if let Some(ref interlocks) = spec.interlocks {
192 validate_safety_interlocks(interlocks)?;
193 }
194
195 if let Some(ref capacity) = spec.capacity {
197 use crate::effector::v1::effector_spec::Capacity;
198 match capacity {
199 Capacity::Ammunition(ammo) => validate_ammunition_status(ammo)?,
200 Capacity::Energy(energy) => {
201 if energy.charge_level < 0.0 || energy.charge_level > 1.0 {
203 return Err(ValidationError::InvalidValue(format!(
204 "charge_level {} must be in range [0.0, 1.0]",
205 energy.charge_level
206 )));
207 }
208 if energy.thermal_level < 0.0 || energy.thermal_level > 1.0 {
209 return Err(ValidationError::InvalidValue(format!(
210 "thermal_level {} must be in range [0.0, 1.0]",
211 energy.thermal_level
212 )));
213 }
214 }
215 Capacity::Dispenser(dispenser) => {
216 if dispenser.units_remaining > dispenser.total_capacity {
218 return Err(ValidationError::ConstraintViolation(
219 "units_remaining cannot exceed total_capacity".to_string(),
220 ));
221 }
222 }
223 }
224 }
225
226 if let Some(ref target) = spec.current_target {
228 validate_target_designation(target)?;
229 }
230
231 if let Some(ref solution) = spec.firing_solution {
233 validate_firing_solution(solution)?;
234 }
235
236 if let Some(ref auth) = spec.current_authorization {
238 validate_authorization(auth)?;
239 }
240
241 Ok(())
242}
243
244pub fn validate_effector_state_update(update: &EffectorStateUpdate) -> ValidationResult<()> {
252 if update.platform_id.is_empty() {
253 return Err(ValidationError::MissingField("platform_id".to_string()));
254 }
255
256 let effector = update
258 .effector
259 .as_ref()
260 .ok_or_else(|| ValidationError::MissingField("effector".to_string()))?;
261 validate_effector_spec(effector)?;
262
263 if update.status == EffectorStatus::Unspecified as i32 {
265 return Err(ValidationError::InvalidValue(
266 "status must be specified".to_string(),
267 ));
268 }
269
270 if update.timestamp.is_none() {
272 return Err(ValidationError::MissingField("timestamp".to_string()));
273 }
274
275 Ok(())
276}
277
278pub fn validate_effector_command(cmd: &EffectorCommand) -> ValidationResult<()> {
288 if cmd.command_id.is_empty() {
289 return Err(ValidationError::MissingField("command_id".to_string()));
290 }
291
292 if cmd.platform_id.is_empty() {
293 return Err(ValidationError::MissingField("platform_id".to_string()));
294 }
295
296 if cmd.effector_id.is_empty() {
297 return Err(ValidationError::MissingField("effector_id".to_string()));
298 }
299
300 if cmd.command_type == EffectorCommandType::EffectorCommandUnspecified as i32 {
301 return Err(ValidationError::InvalidValue(
302 "command_type must be specified".to_string(),
303 ));
304 }
305
306 if cmd.issued_at.is_none() {
307 return Err(ValidationError::MissingField("issued_at".to_string()));
308 }
309
310 if let (Some(issued), Some(expires)) = (&cmd.issued_at, &cmd.expires_at) {
312 if expires.seconds < issued.seconds
313 || (expires.seconds == issued.seconds && expires.nanos < issued.nanos)
314 {
315 return Err(ValidationError::ConstraintViolation(
316 "expires_at must be after issued_at".to_string(),
317 ));
318 }
319 }
320
321 let requires_auth = cmd.command_type == EffectorCommandType::EffectorCommandArm as i32
323 || cmd.command_type == EffectorCommandType::EffectorCommandEngage as i32;
324
325 if requires_auth && cmd.authorization.is_none() {
326 return Err(ValidationError::MissingField(
327 "authorization (required for ARM/ENGAGE commands)".to_string(),
328 ));
329 }
330
331 if let Some(ref auth) = cmd.authorization {
333 validate_authorization(auth)?;
334 }
335
336 if cmd.command_type == EffectorCommandType::EffectorCommandEngage as i32 {
338 if cmd.target.is_none() {
339 return Err(ValidationError::MissingField(
340 "target (required for ENGAGE command)".to_string(),
341 ));
342 }
343 if let Some(ref target) = cmd.target {
344 validate_target_designation(target)?;
345 }
346 }
347
348 Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::common::v1::Timestamp;
355 use crate::effector::v1::{
356 effector_spec::Capacity, DispenserCapacity, EnergyCapacity, EngagementState, RoeStatus,
357 SafetyState,
358 };
359
360 fn valid_kinetic_effector() -> EffectorSpec {
361 EffectorSpec {
362 effector_id: "m240-coax".to_string(),
363 name: "M240 Coaxial Machine Gun".to_string(),
364 effector_type: EffectorType::Kinetic as i32,
365 category: EffectorCategory::Lethal as i32,
366 effector_class: "7.62x51mm NATO".to_string(),
367 max_range_m: 1800.0,
368 min_range_m: 0.0,
369 rate_of_fire: 650.0,
370 safety_state: SafetyState::Safe as i32,
371 interlocks: Some(SafetyInterlocks {
372 master_arm_enabled: false,
373 firing_circuit_ready: true,
374 muzzle_clear: true,
375 feed_ready: true,
376 thermal_ok: true,
377 authorization_valid: false,
378 roe_compliant: true,
379 engagement_zone_valid: false,
380 friendly_clear: true,
381 human_confirmed: false,
382 }),
383 capacity: Some(Capacity::Ammunition(AmmunitionStatus {
384 rounds_ready: 200,
385 rounds_total: 1000,
386 magazine_capacity: 200,
387 magazines_available: 5,
388 ammunition_type: "7.62 NATO Ball".to_string(),
389 reloading: false,
390 reload_time_remaining_s: 0.0,
391 malfunction: false,
392 malfunction_detail: String::new(),
393 })),
394 engagement_state: EngagementState::Idle as i32,
395 current_target: None,
396 firing_solution: None,
397 required_authorization: AuthorizationLevel::Commander as i32,
398 current_authorization: None,
399 roe_status: Some(RoeStatus {
400 roe_id: "ROE-ALPHA-3".to_string(),
401 roe_description: "Weapons tight".to_string(),
402 weapons_status: "TIGHT".to_string(),
403 engagement_authorized: false,
404 denial_reason: String::new(),
405 updated_at: None,
406 }),
407 mount_actuator_id: "turret-main".to_string(),
408 updated_at: None,
409 metadata_json: String::new(),
410 }
411 }
412
413 fn valid_countermeasure_effector() -> EffectorSpec {
414 EffectorSpec {
415 effector_id: "smoke-l".to_string(),
416 name: "Left Smoke Dispenser".to_string(),
417 effector_type: EffectorType::Obscurant as i32,
418 category: EffectorCategory::Defensive as i32,
419 effector_class: "M18 Smoke".to_string(),
420 max_range_m: 50.0,
421 min_range_m: 5.0,
422 rate_of_fire: 0.0,
423 safety_state: SafetyState::Safe as i32,
424 interlocks: None,
425 capacity: Some(Capacity::Dispenser(DispenserCapacity {
426 units_remaining: 4,
427 total_capacity: 4,
428 unit_type: "M18 Smoke Grenade".to_string(),
429 ready: true,
430 })),
431 engagement_state: EngagementState::Idle as i32,
432 current_target: None,
433 firing_solution: None,
434 required_authorization: AuthorizationLevel::Operator as i32,
435 current_authorization: None,
436 roe_status: None,
437 mount_actuator_id: String::new(),
438 updated_at: None,
439 metadata_json: String::new(),
440 }
441 }
442
443 #[test]
444 fn test_valid_kinetic_effector() {
445 let effector = valid_kinetic_effector();
446 assert!(validate_effector_spec(&effector).is_ok());
447 }
448
449 #[test]
450 fn test_valid_countermeasure_effector() {
451 let effector = valid_countermeasure_effector();
452 assert!(validate_effector_spec(&effector).is_ok());
453 }
454
455 #[test]
456 fn test_missing_effector_id() {
457 let mut effector = valid_kinetic_effector();
458 effector.effector_id = String::new();
459 let err = validate_effector_spec(&effector).unwrap_err();
460 assert!(matches!(err, ValidationError::MissingField(f) if f == "effector_id"));
461 }
462
463 #[test]
464 fn test_missing_name() {
465 let mut effector = valid_kinetic_effector();
466 effector.name = String::new();
467 let err = validate_effector_spec(&effector).unwrap_err();
468 assert!(matches!(err, ValidationError::MissingField(f) if f == "name"));
469 }
470
471 #[test]
472 fn test_unspecified_effector_type() {
473 let mut effector = valid_kinetic_effector();
474 effector.effector_type = EffectorType::Unspecified as i32;
475 let err = validate_effector_spec(&effector).unwrap_err();
476 assert!(matches!(err, ValidationError::InvalidValue(_)));
477 }
478
479 #[test]
480 fn test_unspecified_category() {
481 let mut effector = valid_kinetic_effector();
482 effector.category = EffectorCategory::Unspecified as i32;
483 let err = validate_effector_spec(&effector).unwrap_err();
484 assert!(matches!(err, ValidationError::InvalidValue(_)));
485 }
486
487 #[test]
488 fn test_invalid_range_constraint() {
489 let mut effector = valid_kinetic_effector();
490 effector.min_range_m = 1000.0;
491 effector.max_range_m = 500.0; let err = validate_effector_spec(&effector).unwrap_err();
493 assert!(matches!(err, ValidationError::ConstraintViolation(_)));
494 }
495
496 #[test]
497 fn test_invalid_ammunition_rounds() {
498 let mut effector = valid_kinetic_effector();
499 effector.capacity = Some(Capacity::Ammunition(AmmunitionStatus {
500 rounds_ready: 500, rounds_total: 200,
502 magazine_capacity: 100,
503 magazines_available: 2,
504 ammunition_type: "7.62 NATO".to_string(),
505 reloading: false,
506 reload_time_remaining_s: 0.0,
507 malfunction: false,
508 malfunction_detail: String::new(),
509 }));
510 let err = validate_effector_spec(&effector).unwrap_err();
511 assert!(matches!(err, ValidationError::ConstraintViolation(_)));
512 }
513
514 #[test]
515 fn test_invalid_energy_charge_level() {
516 let mut effector = valid_kinetic_effector();
517 effector.capacity = Some(Capacity::Energy(EnergyCapacity {
518 charge_level: 1.5, max_capacity: 1000.0,
520 power_available_kw: 50.0,
521 thermal_level: 0.3,
522 charging: false,
523 charge_time_remaining_s: 0.0,
524 shots_remaining: 10,
525 }));
526 let err = validate_effector_spec(&effector).unwrap_err();
527 assert!(matches!(err, ValidationError::InvalidValue(_)));
528 }
529
530 #[test]
531 fn test_invalid_dispenser_units() {
532 let mut effector = valid_countermeasure_effector();
533 effector.capacity = Some(Capacity::Dispenser(DispenserCapacity {
534 units_remaining: 10, total_capacity: 4,
536 unit_type: "M18 Smoke".to_string(),
537 ready: true,
538 }));
539 let err = validate_effector_spec(&effector).unwrap_err();
540 assert!(matches!(err, ValidationError::ConstraintViolation(_)));
541 }
542
543 #[test]
544 fn test_valid_effector_state_update() {
545 let update = EffectorStateUpdate {
546 platform_id: "IFV-Alpha-1".to_string(),
547 effector: Some(valid_kinetic_effector()),
548 status: EffectorStatus::Operational as i32,
549 timestamp: Some(Timestamp {
550 seconds: 1702000000,
551 nanos: 0,
552 }),
553 };
554 assert!(validate_effector_state_update(&update).is_ok());
555 }
556
557 #[test]
558 fn test_effector_update_missing_platform_id() {
559 let update = EffectorStateUpdate {
560 platform_id: String::new(),
561 effector: Some(valid_kinetic_effector()),
562 status: EffectorStatus::Operational as i32,
563 timestamp: Some(Timestamp {
564 seconds: 1702000000,
565 nanos: 0,
566 }),
567 };
568 let err = validate_effector_state_update(&update).unwrap_err();
569 assert!(matches!(err, ValidationError::MissingField(f) if f == "platform_id"));
570 }
571
572 #[test]
573 fn test_effector_update_unspecified_status() {
574 let update = EffectorStateUpdate {
575 platform_id: "IFV-Alpha-1".to_string(),
576 effector: Some(valid_kinetic_effector()),
577 status: EffectorStatus::Unspecified as i32,
578 timestamp: Some(Timestamp {
579 seconds: 1702000000,
580 nanos: 0,
581 }),
582 };
583 let err = validate_effector_state_update(&update).unwrap_err();
584 assert!(matches!(err, ValidationError::InvalidValue(_)));
585 }
586
587 #[test]
588 fn test_valid_safe_command() {
589 let cmd = EffectorCommand {
590 command_id: "CMD-001".to_string(),
591 platform_id: "IFV-Alpha-1".to_string(),
592 effector_id: "m240-coax".to_string(),
593 command_type: EffectorCommandType::EffectorCommandSafe as i32,
594 target: None,
595 authorization: None, rounds_authorized: 0,
597 issued_by: "operator-1".to_string(),
598 priority: 1,
599 issued_at: Some(Timestamp {
600 seconds: 1702000000,
601 nanos: 0,
602 }),
603 expires_at: None,
604 };
605 assert!(validate_effector_command(&cmd).is_ok());
606 }
607
608 #[test]
609 fn test_arm_command_requires_authorization() {
610 let cmd = EffectorCommand {
611 command_id: "CMD-001".to_string(),
612 platform_id: "IFV-Alpha-1".to_string(),
613 effector_id: "m240-coax".to_string(),
614 command_type: EffectorCommandType::EffectorCommandArm as i32,
615 target: None,
616 authorization: None, rounds_authorized: 0,
618 issued_by: "operator-1".to_string(),
619 priority: 1,
620 issued_at: Some(Timestamp {
621 seconds: 1702000000,
622 nanos: 0,
623 }),
624 expires_at: None,
625 };
626 let err = validate_effector_command(&cmd).unwrap_err();
627 assert!(matches!(err, ValidationError::MissingField(_)));
628 }
629
630 #[test]
631 fn test_engage_command_requires_target() {
632 let cmd = EffectorCommand {
633 command_id: "CMD-001".to_string(),
634 platform_id: "IFV-Alpha-1".to_string(),
635 effector_id: "m240-coax".to_string(),
636 command_type: EffectorCommandType::EffectorCommandEngage as i32,
637 target: None, authorization: Some(Authorization {
639 authorization_id: "AUTH-001".to_string(),
640 authorized_by: "commander-1".to_string(),
641 level: AuthorizationLevel::Commander as i32,
642 authorized_at: Some(Timestamp {
643 seconds: 1702000000,
644 nanos: 0,
645 }),
646 expires_at: None,
647 authorized_target_classes: vec!["hostile_vehicle".to_string()],
648 engagement_zone_id: String::new(),
649 roe_reference: "ROE-ALPHA-3".to_string(),
650 special_instructions: String::new(),
651 }),
652 rounds_authorized: 50,
653 issued_by: "operator-1".to_string(),
654 priority: 1,
655 issued_at: Some(Timestamp {
656 seconds: 1702000000,
657 nanos: 0,
658 }),
659 expires_at: None,
660 };
661 let err = validate_effector_command(&cmd).unwrap_err();
662 assert!(matches!(err, ValidationError::MissingField(_)));
663 }
664
665 #[test]
666 fn test_effector_command_expires_before_issued() {
667 let cmd = EffectorCommand {
668 command_id: "CMD-001".to_string(),
669 platform_id: "IFV-Alpha-1".to_string(),
670 effector_id: "m240-coax".to_string(),
671 command_type: EffectorCommandType::EffectorCommandSafe as i32,
672 target: None,
673 authorization: None,
674 rounds_authorized: 0,
675 issued_by: "operator-1".to_string(),
676 priority: 1,
677 issued_at: Some(Timestamp {
678 seconds: 1702000000,
679 nanos: 0,
680 }),
681 expires_at: Some(Timestamp {
682 seconds: 1701000000, nanos: 0,
684 }),
685 };
686 let err = validate_effector_command(&cmd).unwrap_err();
687 assert!(matches!(err, ValidationError::ConstraintViolation(_)));
688 }
689}