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    /// RIHS hash of the `<Action>_SendGoal` SERVICE — the value a stock
85    /// `rmw_zenoh_cpp` client puts in the send_goal service keyexpr, distinct
86    /// from [`ACTION_HASH`](Self::ACTION_HASH). A nano-ros action server must
87    /// advertise it (not the action hash) or a ROS 2 client's `send_goal` query
88    /// keyexpr won't match the server's queryable (issue #0292). Generated
89    /// `impl RosAction` sets the real per-action value on Iron+; the default
90    /// keeps the historical (action-hash) behavior for hand-written impls and
91    /// Humble (where the placeholder hash makes all channels equal anyway).
92    const SEND_GOAL_SERVICE_HASH: &'static str = Self::ACTION_HASH;
93
94    /// RIHS hash of the `<Action>_GetResult` SERVICE (issue #0292; see
95    /// [`SEND_GOAL_SERVICE_HASH`](Self::SEND_GOAL_SERVICE_HASH)).
96    const GET_RESULT_SERVICE_HASH: &'static str = Self::ACTION_HASH;
97
98    /// RIHS hash of the standard `action_msgs/srv/CancelGoal` SERVICE, which
99    /// every action's `cancel_goal` endpoint is keyed on (issue 1913). The same
100    /// value for every action, but `nros-core` cannot name `action_msgs`, so the
101    /// generated impl carries it. Default as for
102    /// [`SEND_GOAL_SERVICE_HASH`](Self::SEND_GOAL_SERVICE_HASH).
103    const CANCEL_GOAL_SERVICE_HASH: &'static str = Self::ACTION_HASH;
104
105    /// RIHS hash of the standard `action_msgs/msg/GoalStatusArray`, which every
106    /// action's `status` topic is keyed on (issue 1913).
107    const STATUS_MESSAGE_HASH: &'static str = Self::ACTION_HASH;
108
109    /// Register the fixed ROS 2 action-protocol message types this action needs
110    /// at runtime beyond its own 8 envelopes — the `action_msgs` types the
111    /// cancel/status plumbing serializes (`CancelGoal_{Request,Response}`,
112    /// `GoalStatusArray`). The 8 `RosAction`-associated envelopes are registered
113    /// generically by the executor (`register_type::<A::Goal>()` …); these three
114    /// are NOT associated types (they live in `action_msgs`, which `nros-core`
115    /// cannot name), so the generated `impl RosAction` overrides this to register
116    /// them with the active RMW backend.
117    ///
118    /// Default: no-op (`Ok(())`) — keeps every existing `impl RosAction` valid
119    /// (RFC-0044 / phase-244 E3, non-breaking). Returns `Err(())` on a backend
120    /// registration failure; the caller maps it onto its own error type
121    /// (`nros-core` cannot name `nros-node::NodeError`).
122    // Unit error is deliberate — `nros-core` sits below `nros-node` and cannot
123    // name `NodeError`; the caller re-maps `Err(())` onto its own type.
124    #[allow(clippy::result_unit_err)]
125    fn register_protocol_types() -> Result<(), ()> {
126        Ok(())
127    }
128}
129
130/// Goal status states
131///
132/// These states match `action_msgs/msg/GoalStatus` from ROS 2.
133/// A goal progresses through these states during its lifecycle.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
135#[repr(i8)]
136pub enum GoalStatus {
137    /// Status has not been set
138    #[default]
139    Unknown = 0,
140    /// Goal has been accepted and is awaiting execution
141    Accepted = 1,
142    /// Goal is currently being executed
143    Executing = 2,
144    /// Goal is in the process of being canceled
145    Canceling = 3,
146    /// Goal completed successfully
147    Succeeded = 4,
148    /// Goal was canceled before completion
149    Canceled = 5,
150    /// Goal was aborted due to an error
151    Aborted = 6,
152}
153
154impl GoalStatus {
155    /// Check if the goal is in a terminal state (completed, canceled, or aborted)
156    pub fn is_terminal(&self) -> bool {
157        matches!(
158            self,
159            GoalStatus::Succeeded | GoalStatus::Canceled | GoalStatus::Aborted
160        )
161    }
162
163    /// Check if the goal is still active (accepted, executing, or canceling)
164    pub fn is_active(&self) -> bool {
165        matches!(
166            self,
167            GoalStatus::Accepted | GoalStatus::Executing | GoalStatus::Canceling
168        )
169    }
170
171    /// Convert from i8 value
172    pub fn from_i8(value: i8) -> Option<Self> {
173        match value {
174            0 => Some(GoalStatus::Unknown),
175            1 => Some(GoalStatus::Accepted),
176            2 => Some(GoalStatus::Executing),
177            3 => Some(GoalStatus::Canceling),
178            4 => Some(GoalStatus::Succeeded),
179            5 => Some(GoalStatus::Canceled),
180            6 => Some(GoalStatus::Aborted),
181            _ => None,
182        }
183    }
184}
185
186impl fmt::Display for GoalStatus {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        match self {
189            GoalStatus::Unknown => write!(f, "UNKNOWN"),
190            GoalStatus::Accepted => write!(f, "ACCEPTED"),
191            GoalStatus::Executing => write!(f, "EXECUTING"),
192            GoalStatus::Canceling => write!(f, "CANCELING"),
193            GoalStatus::Succeeded => write!(f, "SUCCEEDED"),
194            GoalStatus::Canceled => write!(f, "CANCELED"),
195            GoalStatus::Aborted => write!(f, "ABORTED"),
196        }
197    }
198}
199
200impl Serialize for GoalStatus {
201    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
202        writer.write_i8(*self as i8)
203    }
204}
205
206impl Deserialize for GoalStatus {
207    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
208        let value = reader.read_i8()?;
209        GoalStatus::from_i8(value).ok_or(DeserError::InvalidData)
210    }
211}
212
213/// Unique identifier for a goal
214///
215/// This is a 128-bit UUID matching `unique_identifier_msgs/msg/UUID`.
216#[derive(Clone, Copy, PartialEq, Eq, Hash)]
217pub struct GoalId {
218    /// UUID bytes in standard format
219    pub uuid: [u8; 16],
220}
221
222impl GoalId {
223    /// Length of the goal UUID in bytes (ROS 2 `unique_identifier_msgs/UUID`).
224    pub const UUID_LEN: usize = 16;
225
226    /// Length of any CDR length-prefix preceding the goal UUID bytes — **zero**.
227    ///
228    /// ROS 2 carries the goal id as `unique_identifier_msgs/UUID`, a fixed
229    /// `uint8[16]` array; CDR fixed arrays have **no** length prefix, so the goal
230    /// UUID sits directly after the CDR header. Pre-233.6 the action framing
231    /// wrote a `u32(16)` sequence prefix here (this const was `4`), which
232    /// self-matched nano-ros peers but added 4 bytes a real `rcl_action` peer
233    /// rejects. Kept as a named `0` (rather than deleted) so the C/C++/Cyclone
234    /// framing calcs that read `CDR_HEADER_LEN + SEQ_PREFIX_LEN + UUID_LEN` stay
235    /// correct after the migration.
236    pub const SEQ_PREFIX_LEN: usize = 0;
237
238    /// Create a new GoalId from UUID bytes
239    pub const fn new(uuid: [u8; 16]) -> Self {
240        Self { uuid }
241    }
242
243    /// Create a zero/null GoalId
244    pub const fn zero() -> Self {
245        Self { uuid: [0; 16] }
246    }
247
248    /// Check if this is a zero/null GoalId
249    pub fn is_zero(&self) -> bool {
250        self.uuid == [0; 16]
251    }
252
253    /// Create a GoalId from a simple counter (for testing/embedded use)
254    ///
255    /// This creates a deterministic UUID-like identifier from a counter value.
256    /// Not a true UUID, but useful for embedded systems without random number generators.
257    pub fn from_counter(counter: u64) -> Self {
258        let mut uuid = [0u8; 16];
259        // Put counter in last 8 bytes (big-endian)
260        uuid[8..16].copy_from_slice(&counter.to_be_bytes());
261        // Set version 4 (random) variant bits for compatibility
262        uuid[6] = (uuid[6] & 0x0f) | 0x40; // Version 4
263        uuid[8] = (uuid[8] & 0x3f) | 0x80; // Variant 1
264        Self { uuid }
265    }
266}
267
268impl Default for GoalId {
269    fn default() -> Self {
270        Self::zero()
271    }
272}
273
274impl Serialize for GoalId {
275    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
276        for byte in &self.uuid {
277            writer.write_u8(*byte)?;
278        }
279        Ok(())
280    }
281}
282
283impl Deserialize for GoalId {
284    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
285        let mut uuid = [0u8; 16];
286        for byte in &mut uuid {
287            *byte = reader.read_u8()?;
288        }
289        Ok(Self { uuid })
290    }
291}
292
293impl fmt::Debug for GoalId {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        // Format as UUID string: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
296        write!(
297            f,
298            "GoalId({: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
319impl fmt::Display for GoalId {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        write!(
322            f,
323            "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
324            self.uuid[0],
325            self.uuid[1],
326            self.uuid[2],
327            self.uuid[3],
328            self.uuid[4],
329            self.uuid[5],
330            self.uuid[6],
331            self.uuid[7],
332            self.uuid[8],
333            self.uuid[9],
334            self.uuid[10],
335            self.uuid[11],
336            self.uuid[12],
337            self.uuid[13],
338            self.uuid[14],
339            self.uuid[15]
340        )
341    }
342}
343
344/// Information about a goal
345///
346/// This matches `action_msgs/msg/GoalInfo` from ROS 2.
347/// Contains the goal ID and timestamp when the goal was accepted.
348#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
349pub struct GoalInfo {
350    /// Unique identifier for the goal
351    pub goal_id: GoalId,
352    /// Timestamp when the goal was accepted (nanoseconds since epoch)
353    pub stamp_sec: i32,
354    /// Nanosecond part of timestamp
355    pub stamp_nanosec: u32,
356}
357
358impl GoalInfo {
359    /// Create a new GoalInfo with the given ID and timestamp
360    pub const fn new(goal_id: GoalId, stamp_sec: i32, stamp_nanosec: u32) -> Self {
361        Self {
362            goal_id,
363            stamp_sec,
364            stamp_nanosec,
365        }
366    }
367
368    /// Create a GoalInfo with zero timestamp
369    pub const fn with_id(goal_id: GoalId) -> Self {
370        Self {
371            goal_id,
372            stamp_sec: 0,
373            stamp_nanosec: 0,
374        }
375    }
376}
377
378impl Serialize for GoalInfo {
379    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
380        self.goal_id.serialize(writer)?;
381        writer.write_i32(self.stamp_sec)?;
382        writer.write_u32(self.stamp_nanosec)?;
383        Ok(())
384    }
385}
386
387impl Deserialize for GoalInfo {
388    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
389        Ok(Self {
390            goal_id: GoalId::deserialize(reader)?,
391            stamp_sec: reader.read_i32()?,
392            stamp_nanosec: reader.read_u32()?,
393        })
394    }
395}
396
397/// Goal status with associated goal info
398///
399/// This matches `action_msgs/msg/GoalStatus` from ROS 2.
400#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
401pub struct GoalStatusStamped {
402    /// Goal information (ID and timestamp)
403    pub goal_info: GoalInfo,
404    /// Current status of the goal
405    pub status: GoalStatus,
406}
407
408impl GoalStatusStamped {
409    /// Create a new GoalStatusStamped
410    pub const fn new(goal_info: GoalInfo, status: GoalStatus) -> Self {
411        Self { goal_info, status }
412    }
413}
414
415impl Serialize for GoalStatusStamped {
416    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
417        self.goal_info.serialize(writer)?;
418        writer.write_i8(self.status as i8)?;
419        Ok(())
420    }
421}
422
423impl Deserialize for GoalStatusStamped {
424    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
425        let goal_info = GoalInfo::deserialize(reader)?;
426        let status_val = reader.read_i8()?;
427        let status = GoalStatus::from_i8(status_val).unwrap_or(GoalStatus::Unknown);
428        Ok(Self { goal_info, status })
429    }
430}
431
432/// `action_msgs/srv/CancelGoal` RPC return codes.
433///
434/// This is the *whole-request* outcome the server writes into the
435/// `CancelGoal` reply — "did the cancel RPC succeed", not "is this one goal
436/// being cancelled". These codes are the wire contract, so the discriminants
437/// are fixed by `action_msgs`.
438///
439/// Issue 0796 — this type used to be called `CancelResponse`, which is the
440/// name C ([`rclc_cancel_response_t`]) and C++ ([`nros::CancelResponse`]) both
441/// use for the PER-GOAL accept/reject decision. Two concepts under one name is
442/// how [`CancelResponse`] ended up being the type a per-goal cancel callback
443/// returned: `CancelResponse::Ok` meant "cancel this goal", which reads as an
444/// RPC status and is not one. C already named them apart
445/// (`rclc_cancel_response_t` vs `rclc_cancel_return_code_t`); the Rust names
446/// now follow it.
447///
448/// [`rclc_cancel_response_t`]: https://docs.rs/nros-c
449/// [`nros::CancelResponse`]: CancelResponse
450#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
451#[repr(i8)]
452pub enum CancelReturnCode {
453    /// No error, goal(s) will be canceled
454    #[default]
455    Ok = 0,
456    /// Goal was rejected (not cancelable or doesn't exist)
457    Rejected = 1,
458    /// Unknown goal ID
459    UnknownGoal = 2,
460    /// Goal is already in a terminal state
461    GoalTerminated = 3,
462}
463
464impl CancelReturnCode {
465    /// Convert from i8 value
466    pub fn from_i8(value: i8) -> Option<Self> {
467        match value {
468            0 => Some(CancelReturnCode::Ok),
469            1 => Some(CancelReturnCode::Rejected),
470            2 => Some(CancelReturnCode::UnknownGoal),
471            3 => Some(CancelReturnCode::GoalTerminated),
472            _ => None,
473        }
474    }
475}
476
477impl Serialize for CancelReturnCode {
478    fn serialize(&self, writer: &mut CdrWriter) -> Result<(), SerError> {
479        writer.write_i8(*self as i8)
480    }
481}
482
483impl Deserialize for CancelReturnCode {
484    fn deserialize(reader: &mut CdrReader) -> Result<Self, DeserError> {
485        let value = reader.read_i8()?;
486        CancelReturnCode::from_i8(value).ok_or(DeserError::InvalidData)
487    }
488}
489
490/// Per-goal cancel decision returned from a server's cancel callback.
491///
492/// The twin of [`GoalResponse`] for the cancel path: the user is asked about
493/// ONE goal and answers accept or reject. It never travels on the wire — the
494/// server turns the answer into a [`CancelReturnCode`] plus a `goals_canceling`
495/// entry when it writes the `CancelGoal` reply.
496///
497/// Discriminants match C's `rclc_cancel_response_t` (`RCLC_CANCEL_REJECT` = 0,
498/// `RCLC_CANCEL_ACCEPT` = 1) and C++'s `nros::CancelResponse`, so the three
499/// surfaces agree. `Reject` is the default because a zeroed or unanswered
500/// decision must not cancel a goal nobody asked to cancel.
501#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
502#[repr(i8)]
503pub enum CancelResponse {
504    /// Do not cancel this goal.
505    #[default]
506    Reject = 0,
507    /// Cancel this goal — it transitions to [`GoalStatus::Canceling`].
508    Accept = 1,
509}
510
511impl CancelResponse {
512    /// Convert from i8 value
513    pub fn from_i8(value: i8) -> Option<Self> {
514        match value {
515            0 => Some(CancelResponse::Reject),
516            1 => Some(CancelResponse::Accept),
517            _ => None,
518        }
519    }
520}
521
522/// Goal accept/reject response codes
523#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
524#[repr(i8)]
525pub enum GoalResponse {
526    /// Goal was rejected
527    #[default]
528    Reject = 0,
529    /// Goal was accepted and will be executed
530    AcceptAndExecute = 1,
531    /// Goal was accepted and is deferred
532    AcceptAndDefer = 2,
533}
534
535impl GoalResponse {
536    /// Convert from i8 value
537    pub fn from_i8(value: i8) -> Option<Self> {
538        match value {
539            0 => Some(GoalResponse::Reject),
540            1 => Some(GoalResponse::AcceptAndExecute),
541            2 => Some(GoalResponse::AcceptAndDefer),
542            _ => None,
543        }
544    }
545
546    /// Check if the goal was accepted
547    pub fn is_accepted(&self) -> bool {
548        matches!(
549            self,
550            GoalResponse::AcceptAndExecute | GoalResponse::AcceptAndDefer
551        )
552    }
553}
554
555// phase-417 W4.b — `ActionServer<A>` and `ActionClient<A>`, two "type-level
556// marker" structs each carrying `name()` / `action_type()` / `action_hash()`,
557// were DELETED here. Measured reach before the deletion: ZERO references
558// anywhere in the tree, and the names were UNREACHABLE through the `nros`
559// facade, which re-exports `nros_node`'s live `ActionServer` / `ActionClient`
560// under exactly those spellings. The ledger row
561// `c:action_client_get_action_name` cited `nros_core::ActionServer::name` as
562// Rust's half of the action-name accessor; it could not be called, so the
563// claim was false (issue 1022's class). PR #806's precedent: in-tree-only
564// reach, measured, so a hard delete rather than a deprecated forwarder.
565
566#[cfg(test)]
567mod tests {
568    extern crate std;
569
570    use super::*;
571    use std::format;
572
573    #[test]
574    fn test_goal_status_is_terminal() {
575        assert!(!GoalStatus::Unknown.is_terminal());
576        assert!(!GoalStatus::Accepted.is_terminal());
577        assert!(!GoalStatus::Executing.is_terminal());
578        assert!(!GoalStatus::Canceling.is_terminal());
579        assert!(GoalStatus::Succeeded.is_terminal());
580        assert!(GoalStatus::Canceled.is_terminal());
581        assert!(GoalStatus::Aborted.is_terminal());
582    }
583
584    #[test]
585    fn test_goal_status_is_active() {
586        assert!(!GoalStatus::Unknown.is_active());
587        assert!(GoalStatus::Accepted.is_active());
588        assert!(GoalStatus::Executing.is_active());
589        assert!(GoalStatus::Canceling.is_active());
590        assert!(!GoalStatus::Succeeded.is_active());
591        assert!(!GoalStatus::Canceled.is_active());
592        assert!(!GoalStatus::Aborted.is_active());
593    }
594
595    #[test]
596    fn test_goal_status_from_i8() {
597        assert_eq!(GoalStatus::from_i8(0), Some(GoalStatus::Unknown));
598        assert_eq!(GoalStatus::from_i8(1), Some(GoalStatus::Accepted));
599        assert_eq!(GoalStatus::from_i8(2), Some(GoalStatus::Executing));
600        assert_eq!(GoalStatus::from_i8(3), Some(GoalStatus::Canceling));
601        assert_eq!(GoalStatus::from_i8(4), Some(GoalStatus::Succeeded));
602        assert_eq!(GoalStatus::from_i8(5), Some(GoalStatus::Canceled));
603        assert_eq!(GoalStatus::from_i8(6), Some(GoalStatus::Aborted));
604        assert_eq!(GoalStatus::from_i8(7), None);
605        assert_eq!(GoalStatus::from_i8(-1), None);
606    }
607
608    #[test]
609    fn test_goal_id_zero() {
610        let id = GoalId::zero();
611        assert!(id.is_zero());
612        assert_eq!(id.uuid, [0; 16]);
613    }
614
615    #[test]
616    fn test_goal_id_from_counter() {
617        let id1 = GoalId::from_counter(1);
618        let id2 = GoalId::from_counter(2);
619
620        assert!(!id1.is_zero());
621        assert!(!id2.is_zero());
622        assert_ne!(id1, id2);
623
624        // Same counter should produce same ID
625        let id1_again = GoalId::from_counter(1);
626        assert_eq!(id1, id1_again);
627    }
628
629    #[test]
630    fn test_goal_id_display() {
631        let id = GoalId::new([
632            0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66,
633            0x77, 0x88,
634        ]);
635        let s = format!("{}", id);
636        assert_eq!(s, "12345678-9abc-def0-1122-334455667788");
637    }
638
639    #[test]
640    fn test_cancel_return_code_from_i8() {
641        assert_eq!(CancelReturnCode::from_i8(0), Some(CancelReturnCode::Ok));
642        assert_eq!(
643            CancelReturnCode::from_i8(1),
644            Some(CancelReturnCode::Rejected)
645        );
646        assert_eq!(
647            CancelReturnCode::from_i8(2),
648            Some(CancelReturnCode::UnknownGoal)
649        );
650        assert_eq!(
651            CancelReturnCode::from_i8(3),
652            Some(CancelReturnCode::GoalTerminated)
653        );
654        assert_eq!(CancelReturnCode::from_i8(4), None);
655    }
656
657    /// Issue 0796 — the per-goal decision and the RPC return code are two
658    /// types now, and the thing that made the collision dangerous is that
659    /// their discriminants OVERLAP with opposite meanings: 0 is "reject this
660    /// goal" on one and "the RPC succeeded" on the other. A cast between them
661    /// is therefore never a no-op, which is what this pins down.
662    #[test]
663    fn test_cancel_response_is_not_a_return_code() {
664        assert_eq!(CancelResponse::from_i8(0), Some(CancelResponse::Reject));
665        assert_eq!(CancelResponse::from_i8(1), Some(CancelResponse::Accept));
666        assert_eq!(CancelResponse::from_i8(2), None);
667        assert_eq!(CancelResponse::default(), CancelResponse::Reject);
668
669        // Same byte, opposite verdicts.
670        assert_eq!(CancelResponse::Reject as i8, CancelReturnCode::Ok as i8);
671        assert_eq!(
672            CancelResponse::Accept as i8,
673            CancelReturnCode::Rejected as i8
674        );
675    }
676
677    #[test]
678    fn test_goal_response_is_accepted() {
679        assert!(!GoalResponse::Reject.is_accepted());
680        assert!(GoalResponse::AcceptAndExecute.is_accepted());
681        assert!(GoalResponse::AcceptAndDefer.is_accepted());
682    }
683
684    #[test]
685    fn test_goal_info_new() {
686        let goal_id = GoalId::from_counter(42);
687        let info = GoalInfo::new(goal_id, 123, 456);
688        assert_eq!(info.goal_id, goal_id);
689        assert_eq!(info.stamp_sec, 123);
690        assert_eq!(info.stamp_nanosec, 456);
691    }
692
693    #[test]
694    fn test_goal_info_with_id() {
695        let goal_id = GoalId::from_counter(42);
696        let info = GoalInfo::with_id(goal_id);
697        assert_eq!(info.goal_id, goal_id);
698        assert_eq!(info.stamp_sec, 0);
699        assert_eq!(info.stamp_nanosec, 0);
700    }
701
702    #[test]
703    fn test_goal_status_stamped() {
704        let goal_id = GoalId::from_counter(1);
705        let info = GoalInfo::with_id(goal_id);
706        let stamped = GoalStatusStamped::new(info, GoalStatus::Executing);
707        assert_eq!(stamped.goal_info.goal_id, goal_id);
708        assert_eq!(stamped.status, GoalStatus::Executing);
709    }
710
711    #[test]
712    fn test_goal_id_serialization() {
713        use nros_serdes::{CdrReader, CdrWriter};
714
715        let original = GoalId::new([
716            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
717            0x0f, 0x10,
718        ]);
719
720        // Serialize
721        let mut buf = [0u8; 32];
722        let mut writer = CdrWriter::new(&mut buf);
723        original.serialize(&mut writer).unwrap();
724        let len = writer.position();
725        assert_eq!(len, 16); // UUID is 16 bytes
726
727        // Deserialize
728        let mut reader = CdrReader::new(&buf[..len]);
729        let deserialized = GoalId::deserialize(&mut reader).unwrap();
730
731        assert_eq!(original, deserialized);
732    }
733
734    #[test]
735    fn test_goal_status_serialization() {
736        use nros_serdes::{CdrReader, CdrWriter};
737
738        for status in [
739            GoalStatus::Unknown,
740            GoalStatus::Accepted,
741            GoalStatus::Executing,
742            GoalStatus::Canceling,
743            GoalStatus::Succeeded,
744            GoalStatus::Canceled,
745            GoalStatus::Aborted,
746        ] {
747            let mut buf = [0u8; 8];
748            let len = {
749                let mut writer = CdrWriter::new(&mut buf);
750                status.serialize(&mut writer).unwrap();
751                writer.position()
752            };
753
754            let mut reader = CdrReader::new(&buf[..len]);
755            let deserialized = GoalStatus::deserialize(&mut reader).unwrap();
756
757            assert_eq!(status, deserialized);
758        }
759    }
760
761    #[test]
762    fn test_goal_info_serialization() {
763        use nros_serdes::{CdrReader, CdrWriter};
764
765        let goal_id = GoalId::from_counter(42);
766        let original = GoalInfo::new(goal_id, 1234567890, 123456789);
767
768        // Serialize
769        let mut buf = [0u8; 64];
770        let mut writer = CdrWriter::new(&mut buf);
771        original.serialize(&mut writer).unwrap();
772        let len = writer.position();
773        // UUID (16) + i32 (4) + u32 (4) = 24 bytes
774        assert_eq!(len, 24);
775
776        // Deserialize
777        let mut reader = CdrReader::new(&buf[..len]);
778        let deserialized = GoalInfo::deserialize(&mut reader).unwrap();
779
780        assert_eq!(original.goal_id, deserialized.goal_id);
781        assert_eq!(original.stamp_sec, deserialized.stamp_sec);
782        assert_eq!(original.stamp_nanosec, deserialized.stamp_nanosec);
783    }
784
785    #[test]
786    fn test_goal_status_stamped_serialization() {
787        use nros_serdes::{CdrReader, CdrWriter};
788
789        let goal_id = GoalId::from_counter(99);
790        let goal_info = GoalInfo::new(goal_id, 987654321, 111222333);
791        let original = GoalStatusStamped::new(goal_info, GoalStatus::Succeeded);
792
793        // Serialize
794        let mut buf = [0u8; 64];
795        let mut writer = CdrWriter::new(&mut buf);
796        original.serialize(&mut writer).unwrap();
797        let len = writer.position();
798        // GoalInfo (24) + i8 (1) = 25 bytes
799        assert_eq!(len, 25);
800
801        // Deserialize
802        let mut reader = CdrReader::new(&buf[..len]);
803        let deserialized = GoalStatusStamped::deserialize(&mut reader).unwrap();
804
805        assert_eq!(original.goal_info.goal_id, deserialized.goal_info.goal_id);
806        assert_eq!(
807            original.goal_info.stamp_sec,
808            deserialized.goal_info.stamp_sec
809        );
810        assert_eq!(
811            original.goal_info.stamp_nanosec,
812            deserialized.goal_info.stamp_nanosec
813        );
814        assert_eq!(original.status, deserialized.status);
815    }
816}
817
818// =============================================================================
819// Kani bounded model checking proofs
820// =============================================================================
821
822#[cfg(kani)]
823mod verification {
824    use super::*;
825
826    // ---- GoalStatus ----
827
828    #[kani::proof]
829    fn goal_status_from_i8_valid_range() {
830        let val: i8 = kani::any();
831        let status = GoalStatus::from_i8(val);
832        if (0..=6).contains(&val) {
833            assert!(status.is_some());
834        } else {
835            assert!(status.is_none());
836        }
837    }
838
839    #[kani::proof]
840    fn goal_status_terminal_active_exclusive() {
841        let val: i8 = kani::any();
842        kani::assume((0..=6).contains(&val));
843        let status = GoalStatus::from_i8(val).unwrap();
844        // Terminal and active must be mutually exclusive
845        assert!(!(status.is_terminal() && status.is_active()));
846        // Unknown(0) is neither terminal nor active
847        if val == 0 {
848            assert!(!status.is_terminal());
849            assert!(!status.is_active());
850        }
851        // Active: 1, 2, 3
852        if (1..=3).contains(&val) {
853            assert!(status.is_active());
854            assert!(!status.is_terminal());
855        }
856        // Terminal: 4, 5, 6
857        if (4..=6).contains(&val) {
858            assert!(status.is_terminal());
859            assert!(!status.is_active());
860        }
861    }
862
863    #[kani::proof]
864    #[kani::unwind(5)]
865    fn goal_status_serialize_roundtrip() {
866        let val: i8 = kani::any();
867        kani::assume((0..=6).contains(&val));
868        let status = GoalStatus::from_i8(val).unwrap();
869
870        let mut buf = [0u8; 8];
871        let len = {
872            let mut writer = CdrWriter::new(&mut buf);
873            status.serialize(&mut writer).unwrap();
874            writer.position()
875        };
876
877        let mut reader = CdrReader::new(&buf[..len]);
878        let deserialized = GoalStatus::deserialize(&mut reader).unwrap();
879        assert_eq!(status, deserialized);
880    }
881
882    // ---- GoalResponse ----
883
884    #[kani::proof]
885    fn goal_response_from_i8_valid_range() {
886        let val: i8 = kani::any();
887        let resp = GoalResponse::from_i8(val);
888        if (0..=2).contains(&val) {
889            assert!(resp.is_some());
890        } else {
891            assert!(resp.is_none());
892        }
893    }
894
895    #[kani::proof]
896    fn goal_response_is_accepted_consistent() {
897        let val: i8 = kani::any();
898        kani::assume((0..=2).contains(&val));
899        let resp = GoalResponse::from_i8(val).unwrap();
900        // Reject(0) → not accepted; AcceptAndExecute(1), AcceptAndDefer(2) → accepted
901        assert_eq!(resp.is_accepted(), val >= 1);
902    }
903
904    // ---- CancelReturnCode ----
905
906    #[kani::proof]
907    fn cancel_return_code_from_i8_valid_range() {
908        let val: i8 = kani::any();
909        let resp = CancelReturnCode::from_i8(val);
910        if (0..=3).contains(&val) {
911            assert!(resp.is_some());
912        } else {
913            assert!(resp.is_none());
914        }
915    }
916
917    // ---- CancelResponse ----
918
919    #[kani::proof]
920    fn cancel_response_from_i8_valid_range() {
921        let val: i8 = kani::any();
922        let resp = CancelResponse::from_i8(val);
923        if (0..=1).contains(&val) {
924            assert!(resp.is_some());
925        } else {
926            assert!(resp.is_none());
927        }
928    }
929
930    // ---- GoalId ----
931
932    #[kani::proof]
933    fn goal_id_zero_is_zero() {
934        let id = GoalId::zero();
935        assert!(id.is_zero());
936    }
937
938    #[kani::proof]
939    fn goal_id_from_counter_deterministic() {
940        let counter: u64 = kani::any();
941        // Constrain for CBMC tractability (exercises same byte/bit logic)
942        kani::assume(counter <= 1_000_000);
943        let id1 = GoalId::from_counter(counter);
944        let id2 = GoalId::from_counter(counter);
945        assert_eq!(id1, id2);
946    }
947
948    #[kani::proof]
949    fn goal_id_from_counter_not_zero() {
950        let counter: u64 = kani::any();
951        kani::assume(counter > 0 && counter <= 1_000_000);
952        let id = GoalId::from_counter(counter);
953        assert!(!id.is_zero());
954    }
955
956    #[kani::proof]
957    #[kani::unwind(20)]
958    fn goal_id_serialize_roundtrip() {
959        let mut uuid = [0u8; 16];
960        // Use a few arbitrary bytes (bounded for tractability)
961        uuid[0] = kani::any();
962        uuid[7] = kani::any();
963        uuid[15] = kani::any();
964        let id = GoalId::new(uuid);
965
966        let mut buf = [0u8; 32];
967        let len = {
968            let mut writer = CdrWriter::new(&mut buf);
969            id.serialize(&mut writer).unwrap();
970            writer.position()
971        };
972
973        let mut reader = CdrReader::new(&buf[..len]);
974        let deserialized = GoalId::deserialize(&mut reader).unwrap();
975        assert_eq!(id, deserialized);
976    }
977}