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