Skip to main content

peat_schema/validation/
command.rs

1//! HierarchicalCommand validators
2//!
3//! Validates command messages (MissionTask) for Peat Protocol.
4
5use super::{ValidationError, ValidationResult};
6use crate::command::v1::HierarchicalCommand;
7
8/// Validate a HierarchicalCommand (MissionTask)
9///
10/// Validates:
11/// - command_id is present
12/// - originator_id is present
13/// - target is present and valid
14/// - issued_at timestamp is present
15/// - If expires_at is set, it must be after issued_at
16pub fn validate_hierarchical_command(cmd: &HierarchicalCommand) -> ValidationResult<()> {
17    // Check required fields
18    if cmd.command_id.is_empty() {
19        return Err(ValidationError::MissingField("command_id".to_string()));
20    }
21
22    if cmd.originator_id.is_empty() {
23        return Err(ValidationError::MissingField("originator_id".to_string()));
24    }
25
26    // Target is required
27    let target = cmd
28        .target
29        .as_ref()
30        .ok_or_else(|| ValidationError::MissingField("target".to_string()))?;
31
32    // Target must have at least one target_id (unless broadcast)
33    if target.target_ids.is_empty() && target.scope != 4 {
34        // 4 = BROADCAST
35        return Err(ValidationError::MissingField(
36            "target.target_ids (required for non-broadcast commands)".to_string(),
37        ));
38    }
39
40    // issued_at is required
41    if cmd.issued_at.is_none() {
42        return Err(ValidationError::MissingField("issued_at".to_string()));
43    }
44
45    // If expires_at is set, validate it comes after issued_at
46    if let (Some(issued), Some(expires)) = (&cmd.issued_at, &cmd.expires_at) {
47        if expires.seconds < issued.seconds
48            || (expires.seconds == issued.seconds && expires.nanos < issued.nanos)
49        {
50            return Err(ValidationError::ConstraintViolation(
51                "expires_at must be after issued_at".to_string(),
52            ));
53        }
54    }
55
56    // Command type must be specified
57    if cmd.command_type.is_none() {
58        return Err(ValidationError::MissingField("command_type".to_string()));
59    }
60
61    Ok(())
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::command::v1::{
68        command_target::Scope, mission_order::MissionType, CommandTarget, MissionOrder,
69    };
70    use crate::common::v1::Timestamp;
71
72    fn valid_command() -> HierarchicalCommand {
73        HierarchicalCommand {
74            command_id: "CMD-001".to_string(),
75            originator_id: "HQ-1".to_string(),
76            target: Some(CommandTarget {
77                scope: Scope::Squad as i32,
78                target_ids: vec!["Alpha".to_string()],
79            }),
80            command_type: Some(
81                crate::command::v1::hierarchical_command::CommandType::MissionOrder(MissionOrder {
82                    mission_type: MissionType::Isr as i32,
83                    mission_id: "ISR-001".to_string(),
84                    description: "Conduct ISR in sector Alpha".to_string(),
85                    objective_location: None,
86                    start_time: None,
87                    end_time: None,
88                    roe: None,
89                }),
90            ),
91            priority: 1,
92            buffer_policy: 1,
93            conflict_policy: 1,
94            acknowledgment_policy: 2,
95            leader_change_policy: 1,
96            issued_at: Some(Timestamp {
97                seconds: 1702000000,
98                nanos: 0,
99            }),
100            expires_at: None,
101            version: 1,
102        }
103    }
104
105    #[test]
106    fn test_valid_command() {
107        let cmd = valid_command();
108        assert!(validate_hierarchical_command(&cmd).is_ok());
109    }
110
111    #[test]
112    fn test_missing_command_id() {
113        let mut cmd = valid_command();
114        cmd.command_id = String::new();
115        let err = validate_hierarchical_command(&cmd).unwrap_err();
116        assert!(matches!(err, ValidationError::MissingField(f) if f == "command_id"));
117    }
118
119    #[test]
120    fn test_missing_originator_id() {
121        let mut cmd = valid_command();
122        cmd.originator_id = String::new();
123        let err = validate_hierarchical_command(&cmd).unwrap_err();
124        assert!(matches!(err, ValidationError::MissingField(f) if f == "originator_id"));
125    }
126
127    #[test]
128    fn test_missing_target() {
129        let mut cmd = valid_command();
130        cmd.target = None;
131        let err = validate_hierarchical_command(&cmd).unwrap_err();
132        assert!(matches!(err, ValidationError::MissingField(f) if f == "target"));
133    }
134
135    #[test]
136    fn test_missing_issued_at() {
137        let mut cmd = valid_command();
138        cmd.issued_at = None;
139        let err = validate_hierarchical_command(&cmd).unwrap_err();
140        assert!(matches!(err, ValidationError::MissingField(f) if f == "issued_at"));
141    }
142
143    #[test]
144    fn test_expires_before_issued() {
145        let mut cmd = valid_command();
146        cmd.issued_at = Some(Timestamp {
147            seconds: 1702000000,
148            nanos: 0,
149        });
150        cmd.expires_at = Some(Timestamp {
151            seconds: 1701000000, // Before issued
152            nanos: 0,
153        });
154        let err = validate_hierarchical_command(&cmd).unwrap_err();
155        assert!(matches!(err, ValidationError::ConstraintViolation(_)));
156    }
157
158    #[test]
159    fn test_missing_command_type() {
160        let mut cmd = valid_command();
161        cmd.command_type = None;
162        let err = validate_hierarchical_command(&cmd).unwrap_err();
163        assert!(matches!(err, ValidationError::MissingField(f) if f == "command_type"));
164    }
165}