Skip to main content

nros_core/
action.rs

1//! ROS 2 Action types
2//!
3//! Actions provide asynchronous goal-based communication with feedback.
4//! An action client sends a goal to an action server, which executes the goal
5//! and provides feedback during execution and a result upon completion.
6//!
7//! ## Action Communication Pattern
8//!
9//! Actions use 5 underlying communication channels:
10//! - `send_goal` service: Submit a new goal
11//! - `cancel_goal` service: Request cancellation
12//! - `get_result` service: Retrieve final result
13//! - `feedback` topic: Progress updates during execution
14//! - `status` topic: Goal state transitions
15//!
16//! ## Example
17//!
18//! ```text
19//! use nros_core::{RosAction, GoalStatus};
20//!
21//! // Define an action type
22//! struct Fibonacci;
23//!
24//! impl RosAction for Fibonacci {
25//!     type Goal = FibonacciGoal;
26//!     type Result = FibonacciResult;
27//!     type Feedback = FibonacciFeedback;
28//!
29//!     const ACTION_NAME: &'static str = "example_interfaces::action::dds_::Fibonacci_";
30//!     const ACTION_HASH: &'static str = "...";
31//! }
32//! ```
33
34use crate::types::RosMessage;
35use core::fmt;
36use nros_serdes::{CdrReader, CdrWriter, DeserError, Deserialize, SerError, Serialize};
37
38/// Trait for ROS 2 action types
39///
40/// This trait defines the associated types and metadata for a ROS 2 action.
41/// Actions consist of three user-facing message types plus five action-protocol
42/// envelope types used on the wire (Phase 212.K.7.1.d/b):
43///
44/// User-facing:
45/// - `Goal`: Sent by client to initiate the action
46/// - `Result`: Returned by server when action completes
47/// - `Feedback`: Sent by server during execution to report progress
48///
49/// Wire envelopes (auto-emitted by `nros generate rust`):
50/// - `SendGoalRequest` / `SendGoalResponse`: `<Action>_SendGoal` service shape
51/// - `GetResultRequest` / `GetResultResponse`: `<Action>_GetResult` service shape
52/// - `FeedbackMessage`: `<Action>_FeedbackMessage` topic shape
53pub trait RosAction: Sized {
54    /// Goal message sent by client to initiate the action
55    type Goal: RosMessage;
56
57    /// Result message returned by server upon completion
58    type Result: RosMessage + Default;
59
60    /// Feedback message sent by server during execution
61    type Feedback: RosMessage;
62
63    /// `<Action>_SendGoal_Request` service envelope (wire-level send-goal request)
64    type SendGoalRequest: RosMessage;
65
66    /// `<Action>_SendGoal_Response` service envelope (wire-level send-goal reply)
67    type SendGoalResponse: RosMessage;
68
69    /// `<Action>_GetResult_Request` service envelope (wire-level get-result request)
70    type GetResultRequest: RosMessage;
71
72    /// `<Action>_GetResult_Response` service envelope (wire-level get-result reply)
73    type GetResultResponse: RosMessage;
74
75    /// `<Action>_FeedbackMessage` topic envelope (wire-level feedback message)
76    type FeedbackMessage: RosMessage;
77
78    /// Action type name (e.g., "example_interfaces::action::dds_::Fibonacci_")
79    const ACTION_NAME: &'static str;
80
81    /// Type hash for discovery (RIHS format)
82    const ACTION_HASH: &'static str;
83
84    /// Register the fixed ROS 2 action-protocol message types this action needs
85    /// at runtime beyond its own 8 envelopes — the `action_msgs` types the
86    /// cancel/status plumbing serializes (`CancelGoal_{Request,Response}`,
87    /// `GoalStatusArray`). The 8 `RosAction`-associated envelopes are registered
88    /// generically by the executor (`register_type::<A::Goal>()` …); these three
89    /// are NOT associated types (they live in `action_msgs`, which `nros-core`
90    /// cannot name), so the generated `impl RosAction` overrides this to register
91    /// them with the active RMW backend.
92    ///
93    /// Default: no-op (`Ok(())`) — keeps every existing `impl RosAction` valid
94    /// (RFC-0044 / phase-244 E3, non-breaking). Returns `Err(())` on a backend
95    /// registration failure; the caller maps it onto its own error type
96    /// (`nros-core` cannot name `nros-node::NodeError`).
97    // Unit error is deliberate — `nros-core` sits below `nros-node` and cannot
98    // name `NodeError`; the caller re-maps `Err(())` onto its own type.
99    #[allow(clippy::result_unit_err)]
100    fn register_protocol_types() -> Result<(), ()> {
101        Ok(())
102    }
103}
104
105/// Goal status states
106///
107/// These states match `action_msgs/msg/GoalStatus` from ROS 2.
108/// A goal progresses through these states during its lifecycle.
109#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
110#[repr(i8)]
111pub enum GoalStatus {
112    /// Status has not been set
113    #[default]
114    Unknown = 0,
115    /// Goal has been accepted and is awaiting execution
116    Accepted = 1,
117    /// Goal is currently being executed
118    Executing = 2,
119    /// Goal is in the process of being canceled
120    Canceling = 3,
121    /// Goal completed successfully
122    Succeeded = 4,
123    /// Goal was canceled before completion
124    Canceled = 5,
125    /// Goal was aborted due to an error
126    Aborted = 6,
127}
128
129impl GoalStatus {
130    /// Check if the goal is in a terminal state (completed, canceled, or aborted)
131    pub fn is_terminal(&self) -> bool {
132        matches!(
133            self,
134            GoalStatus::Succeeded | GoalStatus::Canceled | GoalStatus::Aborted
135        )
136    }
137
138    /// Check if the goal is still active (accepted, executing, or canceling)
139    pub fn is_active(&self) -> bool {
140        matches!(
141            self,
142            GoalStatus::Accepted | GoalStatus::Executing | GoalStatus::Canceling
143        )
144    }
145
146    /// Convert from i8 value
147    pub fn from_i8(value: i8) -> Option<Self> {
148        match value {
149            0 => Some(GoalStatus::Unknown),
150            1 => Some(GoalStatus::Accepted),
151            2 => Some(GoalStatus::Executing),
152            3 => Some(GoalStatus::Canceling),
153            4 => Some(GoalStatus::Succeeded),
154            5 => Some(GoalStatus::Canceled),
155            6 => Some(GoalStatus::Aborted),
156            _ => None,
157        }
158    }
159}
160
161impl fmt::Display for GoalStatus {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            GoalStatus::Unknown => write!(f, "UNKNOWN"),
165            GoalStatus::Accepted => write!(f, "ACCEPTED"),
166            GoalStatus::Executing => write!(f, "EXECUTING"),
167            GoalStatus::Canceling => write!(f, "CANCELING"),
168            GoalStatus::Succeeded => write!(f, "SUCCEEDED"),
169            GoalStatus::Canceled => write!(f, "CANCELED"),
170            GoalStatus::Aborted => write!(f, "ABORTED"),
171        }
172    }
173}
174
175impl Serialize for GoalStatus {
176    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
177        writer.write_i8(*self as i8)
178    }
179}
180
181impl Deserialize for GoalStatus {
182    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
183        let value = reader.read_i8()?;
184        GoalStatus::from_i8(value).ok_or(DeserError::InvalidData)
185    }
186}
187
188/// Unique identifier for a goal
189///
190/// This is a 128-bit UUID matching `unique_identifier_msgs/msg/UUID`.
191#[derive(Clone, Copy, PartialEq, Eq, Hash)]
192pub struct GoalId {
193    /// UUID bytes in standard format
194    pub uuid: [u8; 16],
195}
196
197impl GoalId {
198    /// Length of the goal UUID in bytes (ROS 2 `unique_identifier_msgs/UUID`).
199    pub const UUID_LEN: usize = 16;
200
201    /// Length of any CDR length-prefix preceding the goal UUID bytes — **zero**.
202    ///
203    /// ROS 2 carries the goal id as `unique_identifier_msgs/UUID`, a fixed
204    /// `uint8[16]` array; CDR fixed arrays have **no** length prefix, so the goal
205    /// UUID sits directly after the CDR header. Pre-233.6 the action framing
206    /// wrote a `u32(16)` sequence prefix here (this const was `4`), which
207    /// self-matched nano-ros peers but added 4 bytes a real `rcl_action` peer
208    /// rejects. Kept as a named `0` (rather than deleted) so the C/C++/Cyclone
209    /// framing calcs that read `CDR_HEADER_LEN + SEQ_PREFIX_LEN + UUID_LEN` stay
210    /// correct after the migration.
211    pub const SEQ_PREFIX_LEN: usize = 0;
212
213    /// Create a new GoalId from UUID bytes
214    pub const fn new(uuid: [u8; 16]) -> Self {
215        Self { uuid }
216    }
217
218    /// Create a zero/null GoalId
219    pub const fn zero() -> Self {
220        Self { uuid: [0; 16] }
221    }
222
223    /// Check if this is a zero/null GoalId
224    pub fn is_zero(&self) -> bool {
225        self.uuid == [0; 16]
226    }
227
228    /// Create a GoalId from a simple counter (for testing/embedded use)
229    ///
230    /// This creates a deterministic UUID-like identifier from a counter value.
231    /// Not a true UUID, but useful for embedded systems without random number generators.
232    pub fn from_counter(counter: u64) -> Self {
233        let mut uuid = [0u8; 16];
234        // Put counter in last 8 bytes (big-endian)
235        uuid[8..16].copy_from_slice(&counter.to_be_bytes());
236        // Set version 4 (random) variant bits for compatibility
237        uuid[6] = (uuid[6] & 0x0f) | 0x40; // Version 4
238        uuid[8] = (uuid[8] & 0x3f) | 0x80; // Variant 1
239        Self { uuid }
240    }
241}
242
243impl Default for GoalId {
244    fn default() -> Self {
245        Self::zero()
246    }
247}
248
249impl Serialize for GoalId {
250    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
251        for byte in &self.uuid {
252            writer.write_u8(*byte)?;
253        }
254        Ok(())
255    }
256}
257
258impl Deserialize for GoalId {
259    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
260        let mut uuid = [0u8; 16];
261        for byte in &mut uuid {
262            *byte = reader.read_u8()?;
263        }
264        Ok(Self { uuid })
265    }
266}
267
268impl fmt::Debug for GoalId {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        // Format as UUID string: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
271        write!(
272            f,
273            "GoalId({:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x})",
274            self.uuid[0],
275            self.uuid[1],
276            self.uuid[2],
277            self.uuid[3],
278            self.uuid[4],
279            self.uuid[5],
280            self.uuid[6],
281            self.uuid[7],
282            self.uuid[8],
283            self.uuid[9],
284            self.uuid[10],
285            self.uuid[11],
286            self.uuid[12],
287            self.uuid[13],
288            self.uuid[14],
289            self.uuid[15]
290        )
291    }
292}
293
294impl fmt::Display for GoalId {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        write!(
297            f,
298            "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
299            self.uuid[0],
300            self.uuid[1],
301            self.uuid[2],
302            self.uuid[3],
303            self.uuid[4],
304            self.uuid[5],
305            self.uuid[6],
306            self.uuid[7],
307            self.uuid[8],
308            self.uuid[9],
309            self.uuid[10],
310            self.uuid[11],
311            self.uuid[12],
312            self.uuid[13],
313            self.uuid[14],
314            self.uuid[15]
315        )
316    }
317}
318
319/// Information about a goal
320///
321/// This matches `action_msgs/msg/GoalInfo` from ROS 2.
322/// Contains the goal ID and timestamp when the goal was accepted.
323#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
324pub struct GoalInfo {
325    /// Unique identifier for the goal
326    pub goal_id: GoalId,
327    /// Timestamp when the goal was accepted (nanoseconds since epoch)
328    pub stamp_sec: i32,
329    /// Nanosecond part of timestamp
330    pub stamp_nanosec: u32,
331}
332
333impl GoalInfo {
334    /// Create a new GoalInfo with the given ID and timestamp
335    pub const fn new(goal_id: GoalId, stamp_sec: i32, stamp_nanosec: u32) -> Self {
336        Self {
337            goal_id,
338            stamp_sec,
339            stamp_nanosec,
340        }
341    }
342
343    /// Create a GoalInfo with zero timestamp
344    pub const fn with_id(goal_id: GoalId) -> Self {
345        Self {
346            goal_id,
347            stamp_sec: 0,
348            stamp_nanosec: 0,
349        }
350    }
351}
352
353impl Serialize for GoalInfo {
354    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
355        self.goal_id.serialize(writer)?;
356        writer.write_i32(self.stamp_sec)?;
357        writer.write_u32(self.stamp_nanosec)?;
358        Ok(())
359    }
360}
361
362impl Deserialize for GoalInfo {
363    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
364        Ok(Self {
365            goal_id: GoalId::deserialize(reader)?,
366            stamp_sec: reader.read_i32()?,
367            stamp_nanosec: reader.read_u32()?,
368        })
369    }
370}
371
372/// Goal status with associated goal info
373///
374/// This matches `action_msgs/msg/GoalStatus` from ROS 2.
375#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
376pub struct GoalStatusStamped {
377    /// Goal information (ID and timestamp)
378    pub goal_info: GoalInfo,
379    /// Current status of the goal
380    pub status: GoalStatus,
381}
382
383impl GoalStatusStamped {
384    /// Create a new GoalStatusStamped
385    pub const fn new(goal_info: GoalInfo, status: GoalStatus) -> Self {
386        Self { goal_info, status }
387    }
388}
389
390impl Serialize for GoalStatusStamped {
391    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
392        self.goal_info.serialize(writer)?;
393        writer.write_i8(self.status as i8)?;
394        Ok(())
395    }
396}
397
398impl Deserialize for GoalStatusStamped {
399    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
400        let goal_info = GoalInfo::deserialize(reader)?;
401        let status_val = reader.read_i8()?;
402        let status = GoalStatus::from_i8(status_val).unwrap_or(GoalStatus::Unknown);
403        Ok(Self { goal_info, status })
404    }
405}
406
407/// Cancel goal response codes
408///
409/// These codes match `action_msgs/srv/CancelGoal` response codes.
410#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
411#[repr(i8)]
412pub enum CancelResponse {
413    /// No error, goal(s) will be canceled
414    #[default]
415    Ok = 0,
416    /// Goal was rejected (not cancelable or doesn't exist)
417    Rejected = 1,
418    /// Unknown goal ID
419    UnknownGoal = 2,
420    /// Goal is already in a terminal state
421    GoalTerminated = 3,
422}
423
424impl CancelResponse {
425    /// Convert from i8 value
426    pub fn from_i8(value: i8) -> Option<Self> {
427        match value {
428            0 => Some(CancelResponse::Ok),
429            1 => Some(CancelResponse::Rejected),
430            2 => Some(CancelResponse::UnknownGoal),
431            3 => Some(CancelResponse::GoalTerminated),
432            _ => None,
433        }
434    }
435}
436
437impl Serialize for CancelResponse {
438    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
439        writer.write_i8(*self as i8)
440    }
441}
442
443impl Deserialize for CancelResponse {
444    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
445        let value = reader.read_i8()?;
446        CancelResponse::from_i8(value).ok_or(DeserError::InvalidData)
447    }
448}
449
450/// Goal accept/reject response codes
451#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
452#[repr(i8)]
453pub enum GoalResponse {
454    /// Goal was rejected
455    #[default]
456    Reject = 0,
457    /// Goal was accepted and will be executed
458    AcceptAndExecute = 1,
459    /// Goal was accepted and is deferred
460    AcceptAndDefer = 2,
461}
462
463impl GoalResponse {
464    /// Convert from i8 value
465    pub fn from_i8(value: i8) -> Option<Self> {
466        match value {
467            0 => Some(GoalResponse::Reject),
468            1 => Some(GoalResponse::AcceptAndExecute),
469            2 => Some(GoalResponse::AcceptAndDefer),
470            _ => None,
471        }
472    }
473
474    /// Check if the goal was accepted
475    pub fn is_accepted(&self) -> bool {
476        matches!(
477            self,
478            GoalResponse::AcceptAndExecute | GoalResponse::AcceptAndDefer
479        )
480    }
481}
482
483/// Action server handle (type-level marker)
484///
485/// This is a lightweight handle for tracking an action server.
486/// The actual implementation is in `nros-node`.
487pub struct ActionServer<A: RosAction> {
488    /// Action name (e.g., "/fibonacci")
489    pub name: &'static str,
490    /// Marker for action type
491    _marker: core::marker::PhantomData<A>,
492}
493
494impl<A: RosAction> ActionServer<A> {
495    /// Create a new action server handle
496    pub fn new(name: &'static str) -> Self {
497        Self {
498            name,
499            _marker: core::marker::PhantomData,
500        }
501    }
502
503    /// Get the action name
504    pub fn name(&self) -> &str {
505        self.name
506    }
507
508    /// Get the action type name
509    pub fn action_type(&self) -> &'static str {
510        A::ACTION_NAME
511    }
512
513    /// Get the action type hash
514    pub fn action_hash(&self) -> &'static str {
515        A::ACTION_HASH
516    }
517}
518
519/// Action client handle (type-level marker)
520///
521/// This is a lightweight handle for tracking an action client.
522/// The actual implementation is in `nros-node`.
523pub struct ActionClient<A: RosAction> {
524    /// Action name (e.g., "/fibonacci")
525    pub name: &'static str,
526    /// Marker for action type
527    _marker: core::marker::PhantomData<A>,
528}
529
530impl<A: RosAction> ActionClient<A> {
531    /// Create a new action client handle
532    pub fn new(name: &'static str) -> Self {
533        Self {
534            name,
535            _marker: core::marker::PhantomData,
536        }
537    }
538
539    /// Get the action name
540    pub fn name(&self) -> &str {
541        self.name
542    }
543
544    /// Get the action type name
545    pub fn action_type(&self) -> &'static str {
546        A::ACTION_NAME
547    }
548
549    /// Get the action type hash
550    pub fn action_hash(&self) -> &'static str {
551        A::ACTION_HASH
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    extern crate std;
558
559    use super::*;
560    use std::format;
561
562    #[test]
563    fn test_goal_status_is_terminal() {
564        assert!(!GoalStatus::Unknown.is_terminal());
565        assert!(!GoalStatus::Accepted.is_terminal());
566        assert!(!GoalStatus::Executing.is_terminal());
567        assert!(!GoalStatus::Canceling.is_terminal());
568        assert!(GoalStatus::Succeeded.is_terminal());
569        assert!(GoalStatus::Canceled.is_terminal());
570        assert!(GoalStatus::Aborted.is_terminal());
571    }
572
573    #[test]
574    fn test_goal_status_is_active() {
575        assert!(!GoalStatus::Unknown.is_active());
576        assert!(GoalStatus::Accepted.is_active());
577        assert!(GoalStatus::Executing.is_active());
578        assert!(GoalStatus::Canceling.is_active());
579        assert!(!GoalStatus::Succeeded.is_active());
580        assert!(!GoalStatus::Canceled.is_active());
581        assert!(!GoalStatus::Aborted.is_active());
582    }
583
584    #[test]
585    fn test_goal_status_from_i8() {
586        assert_eq!(GoalStatus::from_i8(0), Some(GoalStatus::Unknown));
587        assert_eq!(GoalStatus::from_i8(1), Some(GoalStatus::Accepted));
588        assert_eq!(GoalStatus::from_i8(2), Some(GoalStatus::Executing));
589        assert_eq!(GoalStatus::from_i8(3), Some(GoalStatus::Canceling));
590        assert_eq!(GoalStatus::from_i8(4), Some(GoalStatus::Succeeded));
591        assert_eq!(GoalStatus::from_i8(5), Some(GoalStatus::Canceled));
592        assert_eq!(GoalStatus::from_i8(6), Some(GoalStatus::Aborted));
593        assert_eq!(GoalStatus::from_i8(7), None);
594        assert_eq!(GoalStatus::from_i8(-1), None);
595    }
596
597    #[test]
598    fn test_goal_id_zero() {
599        let id = GoalId::zero();
600        assert!(id.is_zero());
601        assert_eq!(id.uuid, [0; 16]);
602    }
603
604    #[test]
605    fn test_goal_id_from_counter() {
606        let id1 = GoalId::from_counter(1);
607        let id2 = GoalId::from_counter(2);
608
609        assert!(!id1.is_zero());
610        assert!(!id2.is_zero());
611        assert_ne!(id1, id2);
612
613        // Same counter should produce same ID
614        let id1_again = GoalId::from_counter(1);
615        assert_eq!(id1, id1_again);
616    }
617
618    #[test]
619    fn test_goal_id_display() {
620        let id = GoalId::new([
621            0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
622            0x77, 0x88,
623        ]);
624        let s = format!("{}", id);
625        assert_eq!(s, "12345678-9abc-def0-1122-334455667788");
626    }
627
628    #[test]
629    fn test_cancel_response_from_i8() {
630        assert_eq!(CancelResponse::from_i8(0), Some(CancelResponse::Ok));
631        assert_eq!(CancelResponse::from_i8(1), Some(CancelResponse::Rejected));
632        assert_eq!(
633            CancelResponse::from_i8(2),
634            Some(CancelResponse::UnknownGoal)
635        );
636        assert_eq!(
637            CancelResponse::from_i8(3),
638            Some(CancelResponse::GoalTerminated)
639        );
640        assert_eq!(CancelResponse::from_i8(4), None);
641    }
642
643    #[test]
644    fn test_goal_response_is_accepted() {
645        assert!(!GoalResponse::Reject.is_accepted());
646        assert!(GoalResponse::AcceptAndExecute.is_accepted());
647        assert!(GoalResponse::AcceptAndDefer.is_accepted());
648    }
649
650    #[test]
651    fn test_goal_info_new() {
652        let goal_id = GoalId::from_counter(42);
653        let info = GoalInfo::new(goal_id, 123, 456);
654        assert_eq!(info.goal_id, goal_id);
655        assert_eq!(info.stamp_sec, 123);
656        assert_eq!(info.stamp_nanosec, 456);
657    }
658
659    #[test]
660    fn test_goal_info_with_id() {
661        let goal_id = GoalId::from_counter(42);
662        let info = GoalInfo::with_id(goal_id);
663        assert_eq!(info.goal_id, goal_id);
664        assert_eq!(info.stamp_sec, 0);
665        assert_eq!(info.stamp_nanosec, 0);
666    }
667
668    #[test]
669    fn test_goal_status_stamped() {
670        let goal_id = GoalId::from_counter(1);
671        let info = GoalInfo::with_id(goal_id);
672        let stamped = GoalStatusStamped::new(info, GoalStatus::Executing);
673        assert_eq!(stamped.goal_info.goal_id, goal_id);
674        assert_eq!(stamped.status, GoalStatus::Executing);
675    }
676
677    #[test]
678    fn test_goal_id_serialization() {
679        use nros_serdes::{CdrReader, CdrWriter};
680
681        let original = GoalId::new([
682            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
683            0x0f, 0x10,
684        ]);
685
686        // Serialize
687        let mut buf = [0u8; 32];
688        let mut writer = CdrWriter::new(&mut buf);
689        original.serialize(&mut writer).unwrap();
690        let len = writer.position();
691        assert_eq!(len, 16); // UUID is 16 bytes
692
693        // Deserialize
694        let mut reader = CdrReader::new(&buf[..len]);
695        let deserialized = GoalId::deserialize(&mut reader).unwrap();
696
697        assert_eq!(original, deserialized);
698    }
699
700    #[test]
701    fn test_goal_status_serialization() {
702        use nros_serdes::{CdrReader, CdrWriter};
703
704        for status in [
705            GoalStatus::Unknown,
706            GoalStatus::Accepted,
707            GoalStatus::Executing,
708            GoalStatus::Canceling,
709            GoalStatus::Succeeded,
710            GoalStatus::Canceled,
711            GoalStatus::Aborted,
712        ] {
713            let mut buf = [0u8; 8];
714            let len = {
715                let mut writer = CdrWriter::new(&mut buf);
716                status.serialize(&mut writer).unwrap();
717                writer.position()
718            };
719
720            let mut reader = CdrReader::new(&buf[..len]);
721            let deserialized = GoalStatus::deserialize(&mut reader).unwrap();
722
723            assert_eq!(status, deserialized);
724        }
725    }
726
727    #[test]
728    fn test_goal_info_serialization() {
729        use nros_serdes::{CdrReader, CdrWriter};
730
731        let goal_id = GoalId::from_counter(42);
732        let original = GoalInfo::new(goal_id, 1234567890, 123456789);
733
734        // Serialize
735        let mut buf = [0u8; 64];
736        let mut writer = CdrWriter::new(&mut buf);
737        original.serialize(&mut writer).unwrap();
738        let len = writer.position();
739        // UUID (16) + i32 (4) + u32 (4) = 24 bytes
740        assert_eq!(len, 24);
741
742        // Deserialize
743        let mut reader = CdrReader::new(&buf[..len]);
744        let deserialized = GoalInfo::deserialize(&mut reader).unwrap();
745
746        assert_eq!(original.goal_id, deserialized.goal_id);
747        assert_eq!(original.stamp_sec, deserialized.stamp_sec);
748        assert_eq!(original.stamp_nanosec, deserialized.stamp_nanosec);
749    }
750
751    #[test]
752    fn test_goal_status_stamped_serialization() {
753        use nros_serdes::{CdrReader, CdrWriter};
754
755        let goal_id = GoalId::from_counter(99);
756        let goal_info = GoalInfo::new(goal_id, 987654321, 111222333);
757        let original = GoalStatusStamped::new(goal_info, GoalStatus::Succeeded);
758
759        // Serialize
760        let mut buf = [0u8; 64];
761        let mut writer = CdrWriter::new(&mut buf);
762        original.serialize(&mut writer).unwrap();
763        let len = writer.position();
764        // GoalInfo (24) + i8 (1) = 25 bytes
765        assert_eq!(len, 25);
766
767        // Deserialize
768        let mut reader = CdrReader::new(&buf[..len]);
769        let deserialized = GoalStatusStamped::deserialize(&mut reader).unwrap();
770
771        assert_eq!(original.goal_info.goal_id, deserialized.goal_info.goal_id);
772        assert_eq!(
773            original.goal_info.stamp_sec,
774            deserialized.goal_info.stamp_sec
775        );
776        assert_eq!(
777            original.goal_info.stamp_nanosec,
778            deserialized.goal_info.stamp_nanosec
779        );
780        assert_eq!(original.status, deserialized.status);
781    }
782}
783
784// =============================================================================
785// Kani bounded model checking proofs
786// =============================================================================
787
788#[cfg(kani)]
789mod verification {
790    use super::*;
791
792    // ---- GoalStatus ----
793
794    #[kani::proof]
795    fn goal_status_from_i8_valid_range() {
796        let val: i8 = kani::any();
797        let status = GoalStatus::from_i8(val);
798        if (0..=6).contains(&val) {
799            assert!(status.is_some());
800        } else {
801            assert!(status.is_none());
802        }
803    }
804
805    #[kani::proof]
806    fn goal_status_terminal_active_exclusive() {
807        let val: i8 = kani::any();
808        kani::assume((0..=6).contains(&val));
809        let status = GoalStatus::from_i8(val).unwrap();
810        // Terminal and active must be mutually exclusive
811        assert!(!(status.is_terminal() && status.is_active()));
812        // Unknown(0) is neither terminal nor active
813        if val == 0 {
814            assert!(!status.is_terminal());
815            assert!(!status.is_active());
816        }
817        // Active: 1, 2, 3
818        if (1..=3).contains(&val) {
819            assert!(status.is_active());
820            assert!(!status.is_terminal());
821        }
822        // Terminal: 4, 5, 6
823        if (4..=6).contains(&val) {
824            assert!(status.is_terminal());
825            assert!(!status.is_active());
826        }
827    }
828
829    #[kani::proof]
830    #[kani::unwind(5)]
831    fn goal_status_serialize_roundtrip() {
832        let val: i8 = kani::any();
833        kani::assume((0..=6).contains(&val));
834        let status = GoalStatus::from_i8(val).unwrap();
835
836        let mut buf = [0u8; 8];
837        let len = {
838            let mut writer = CdrWriter::new(&mut buf);
839            status.serialize(&mut writer).unwrap();
840            writer.position()
841        };
842
843        let mut reader = CdrReader::new(&buf[..len]);
844        let deserialized = GoalStatus::deserialize(&mut reader).unwrap();
845        assert_eq!(status, deserialized);
846    }
847
848    // ---- GoalResponse ----
849
850    #[kani::proof]
851    fn goal_response_from_i8_valid_range() {
852        let val: i8 = kani::any();
853        let resp = GoalResponse::from_i8(val);
854        if (0..=2).contains(&val) {
855            assert!(resp.is_some());
856        } else {
857            assert!(resp.is_none());
858        }
859    }
860
861    #[kani::proof]
862    fn goal_response_is_accepted_consistent() {
863        let val: i8 = kani::any();
864        kani::assume((0..=2).contains(&val));
865        let resp = GoalResponse::from_i8(val).unwrap();
866        // Reject(0) → not accepted; AcceptAndExecute(1), AcceptAndDefer(2) → accepted
867        assert_eq!(resp.is_accepted(), val >= 1);
868    }
869
870    // ---- CancelResponse ----
871
872    #[kani::proof]
873    fn cancel_response_from_i8_valid_range() {
874        let val: i8 = kani::any();
875        let resp = CancelResponse::from_i8(val);
876        if (0..=3).contains(&val) {
877            assert!(resp.is_some());
878        } else {
879            assert!(resp.is_none());
880        }
881    }
882
883    // ---- GoalId ----
884
885    #[kani::proof]
886    fn goal_id_zero_is_zero() {
887        let id = GoalId::zero();
888        assert!(id.is_zero());
889    }
890
891    #[kani::proof]
892    fn goal_id_from_counter_deterministic() {
893        let counter: u64 = kani::any();
894        // Constrain for CBMC tractability (exercises same byte/bit logic)
895        kani::assume(counter <= 1_000_000);
896        let id1 = GoalId::from_counter(counter);
897        let id2 = GoalId::from_counter(counter);
898        assert_eq!(id1, id2);
899    }
900
901    #[kani::proof]
902    fn goal_id_from_counter_not_zero() {
903        let counter: u64 = kani::any();
904        kani::assume(counter > 0 && counter <= 1_000_000);
905        let id = GoalId::from_counter(counter);
906        assert!(!id.is_zero());
907    }
908
909    #[kani::proof]
910    #[kani::unwind(20)]
911    fn goal_id_serialize_roundtrip() {
912        let mut uuid = [0u8; 16];
913        // Use a few arbitrary bytes (bounded for tractability)
914        uuid[0] = kani::any();
915        uuid[7] = kani::any();
916        uuid[15] = kani::any();
917        let id = GoalId::new(uuid);
918
919        let mut buf = [0u8; 32];
920        let len = {
921            let mut writer = CdrWriter::new(&mut buf);
922            id.serialize(&mut writer).unwrap();
923            writer.position()
924        };
925
926        let mut reader = CdrReader::new(&buf[..len]);
927        let deserialized = GoalId::deserialize(&mut reader).unwrap();
928        assert_eq!(id, deserialized);
929    }
930}