Skip to main content

pamoja_ffi/
ros2.rs

1//! The C ABI for the ROS 2 naming and encoding rules.
2//!
3//! These wrap [`pamoja_ros2`] for callers that reach the SDK through the flat C
4//! boundary: what makes a topic name legal, what it becomes on the DDS wire, the
5//! RIHS type hash that identifies a message definition, and the CDR encoding the
6//! payload itself is written in.
7//!
8//! None of it needs a ROS 2 installation, which is the point. A gateway can
9//! validate a name, derive the DDS topic and the Zenoh key an `rmw_zenoh` peer
10//! subscribes on, and encode a `geometry_msgs/msg/Twist` without linking against
11//! anything from a ROS distribution. Driving a live graph does need one, so the
12//! `bridge` feature stays Rust-only.
13
14use std::ffi::c_char;
15use std::fmt::Write as _;
16use std::ptr;
17
18use pamoja_ros2::key::entity_key;
19use pamoja_ros2::msg::{CdrReader, CdrWriter, Twist, Vector3};
20use pamoja_ros2::name::{dds_topic, is_fully_qualified, is_valid_name, percent_mangle, EntityKind};
21use pamoja_ros2::typehash::{dds_type_name, TypeHash};
22
23use crate::{read_bytes, read_str, set_last_error, PamojaBuffer, PamojaStatus, PamojaString};
24
25/// The number of bytes in a RIHS01 type hash digest.
26pub const PAMOJA_TYPE_HASH_LEN: usize = 32;
27
28/// The ROS 2 subsystem a name belongs to, which fixes its DDS prefix.
29#[repr(C)]
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum PamojaEntityKind {
32    /// A topic, which takes the `rt` prefix.
33    Topic = 0,
34    /// The request side of a service, which takes the `rq` prefix.
35    ServiceRequest = 1,
36    /// The reply side of a service, which takes the `rr` prefix.
37    ServiceResponse = 2,
38}
39
40impl From<PamojaEntityKind> for EntityKind {
41    fn from(kind: PamojaEntityKind) -> Self {
42        match kind {
43            PamojaEntityKind::Topic => EntityKind::Topic,
44            PamojaEntityKind::ServiceRequest => EntityKind::ServiceRequest,
45            PamojaEntityKind::ServiceResponse => EntityKind::ServiceResponse,
46        }
47    }
48}
49
50/// A RIHS01 type hash: the 32-byte digest that identifies a message definition.
51#[repr(C)]
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub struct PamojaTypeHash {
54    /// The SHA-256 digest the hash carries.
55    pub digest: [u8; PAMOJA_TYPE_HASH_LEN],
56}
57
58/// A three-dimensional vector, matching `geometry_msgs/msg/Vector3`.
59#[repr(C)]
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct PamojaVector3 {
62    /// The x component.
63    pub x: f64,
64    /// The y component.
65    pub y: f64,
66    /// The z component.
67    pub z: f64,
68}
69
70/// A body velocity command, matching `geometry_msgs/msg/Twist`.
71///
72/// This is what a ROS 2 robot is driven by on `cmd_vel`, so it is the shape a
73/// chassis or navigation helper from `pamoja-kit` publishes into a ROS graph.
74#[repr(C)]
75#[derive(Clone, Copy, Debug, PartialEq)]
76pub struct PamojaRos2Twist {
77    /// The linear velocity in metres per second.
78    pub linear: PamojaVector3,
79    /// The angular velocity in radians per second.
80    pub angular: PamojaVector3,
81}
82
83impl From<Vector3> for PamojaVector3 {
84    fn from(vector: Vector3) -> Self {
85        Self {
86            x: vector.x,
87            y: vector.y,
88            z: vector.z,
89        }
90    }
91}
92
93impl From<PamojaVector3> for Vector3 {
94    fn from(vector: PamojaVector3) -> Self {
95        Vector3::new(vector.x, vector.y, vector.z)
96    }
97}
98
99impl PamojaTypeHash {
100    /// Renders the digest as its RIHS01 string.
101    fn text(&self) -> String {
102        let mut text = String::from("RIHS01_");
103        for byte in self.digest {
104            let _ = write!(text, "{byte:02x}");
105        }
106        text
107    }
108}
109
110/// Reports whether a string is a valid ROS 2 topic or service name.
111///
112/// # Arguments
113///
114/// * `name` - the candidate name, as null-terminated UTF-8.
115///
116/// # Returns
117///
118/// `true` when the name obeys the ROS 2 rules, or `false` if it does not or
119/// `name` is null.
120///
121/// # Safety
122///
123/// `name` must be a valid null-terminated UTF-8 string for the duration of the
124/// call, or null.
125#[no_mangle]
126pub unsafe extern "C" fn pamoja_ros2_name_is_valid(name: *const c_char) -> bool {
127    match read_str(name, "name") {
128        Some(name) => is_valid_name(name),
129        None => false,
130    }
131}
132
133/// Reports whether a name is fully qualified, so it resolves with no namespace.
134///
135/// # Arguments
136///
137/// * `name` - the candidate name, as null-terminated UTF-8.
138///
139/// # Returns
140///
141/// `true` when the name is fully qualified, or `false` if it is not or `name` is
142/// null.
143///
144/// # Safety
145///
146/// `name` must be a valid null-terminated UTF-8 string for the duration of the
147/// call, or null.
148#[no_mangle]
149pub unsafe extern "C" fn pamoja_ros2_name_is_fully_qualified(name: *const c_char) -> bool {
150    match read_str(name, "name") {
151        Some(name) => is_fully_qualified(name),
152        None => false,
153    }
154}
155
156/// Returns the DDS topic prefix a subsystem uses.
157///
158/// # Arguments
159///
160/// * `kind` - the subsystem.
161///
162/// # Returns
163///
164/// A null-terminated string with static lifetime, which the caller does not free.
165#[no_mangle]
166pub extern "C" fn pamoja_ros2_entity_kind_prefix(kind: PamojaEntityKind) -> *const c_char {
167    match kind {
168        PamojaEntityKind::Topic => c"rt".as_ptr(),
169        PamojaEntityKind::ServiceRequest => c"rq".as_ptr(),
170        PamojaEntityKind::ServiceResponse => c"rr".as_ptr(),
171    }
172}
173
174/// Returns the DDS topic a fully qualified ROS 2 name maps onto.
175///
176/// # Arguments
177///
178/// * `fqn` - the fully qualified name, as null-terminated UTF-8.
179/// * `kind` - which subsystem the name belongs to, which fixes the prefix.
180///
181/// # Returns
182///
183/// A string the caller must release with
184/// [`pamoja_string_free`](crate::pamoja_string_free), or null if the name is not
185/// fully qualified or `fqn` is null.
186///
187/// # Safety
188///
189/// `fqn` must be a valid null-terminated UTF-8 string for the duration of the
190/// call, or null.
191#[no_mangle]
192pub unsafe extern "C" fn pamoja_ros2_dds_topic(
193    fqn: *const c_char,
194    kind: PamojaEntityKind,
195) -> *mut PamojaString {
196    let Some(fqn) = read_str(fqn, "fqn") else {
197        return ptr::null_mut();
198    };
199    match dds_topic(fqn, kind.into()) {
200        Some(topic) => PamojaString::into_raw(topic),
201        None => {
202            set_last_error(format!("`{fqn}` is not a fully qualified ROS 2 name"));
203            ptr::null_mut()
204        }
205    }
206}
207
208/// Percent-mangles a name the way a DDS partition requires.
209///
210/// # Arguments
211///
212/// * `name` - the name to mangle, as null-terminated UTF-8.
213///
214/// # Returns
215///
216/// A string the caller must release with
217/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `name` is null.
218///
219/// # Safety
220///
221/// `name` must be a valid null-terminated UTF-8 string for the duration of the
222/// call, or null.
223#[no_mangle]
224pub unsafe extern "C" fn pamoja_ros2_percent_mangle(name: *const c_char) -> *mut PamojaString {
225    let Some(name) = read_str(name, "name") else {
226        return ptr::null_mut();
227    };
228    PamojaString::into_raw(percent_mangle(name))
229}
230
231/// Returns the DDS type name a ROS 2 interface type maps onto.
232///
233/// # Arguments
234///
235/// * `ros_type` - the interface type as `package/namespace/Type`, such as
236///   `std_msgs/msg/String`, as null-terminated UTF-8.
237///
238/// # Returns
239///
240/// A string such as `std_msgs::msg::dds_::String_`, which the caller must
241/// release with [`pamoja_string_free`](crate::pamoja_string_free), or null if
242/// the type is not a valid three-part interface type or `ros_type` is null.
243///
244/// # Safety
245///
246/// `ros_type` must be a valid null-terminated UTF-8 string for the duration of
247/// the call, or null.
248#[no_mangle]
249pub unsafe extern "C" fn pamoja_ros2_dds_type_name(ros_type: *const c_char) -> *mut PamojaString {
250    let Some(ros_type) = read_str(ros_type, "ros_type") else {
251        return ptr::null_mut();
252    };
253    match dds_type_name(ros_type) {
254        Some(name) => PamojaString::into_raw(name),
255        None => {
256            set_last_error(format!(
257                "`{ros_type}` is not a `package/namespace/Type` interface type"
258            ));
259            ptr::null_mut()
260        }
261    }
262}
263
264/// Parses a RIHS01 type hash string.
265///
266/// # Arguments
267///
268/// * `text` - the candidate hash, expected as `RIHS01_` plus 64 lowercase hex
269///   digits, as null-terminated UTF-8.
270/// * `out_hash` - receives the parsed hash.
271///
272/// # Returns
273///
274/// [`PamojaStatus::Ok`] once parsed, or [`PamojaStatus::InvalidArgument`] if the
275/// text is malformed or either pointer is null.
276///
277/// # Safety
278///
279/// `text` must be a valid null-terminated UTF-8 string for the duration of the
280/// call, and `out_hash` must be writable.
281#[no_mangle]
282pub unsafe extern "C" fn pamoja_ros2_type_hash_parse(
283    text: *const c_char,
284    out_hash: *mut PamojaTypeHash,
285) -> PamojaStatus {
286    if out_hash.is_null() {
287        set_last_error("out_hash must not be null".to_owned());
288        return PamojaStatus::InvalidArgument;
289    }
290    let Some(text) = read_str(text, "text") else {
291        return PamojaStatus::InvalidArgument;
292    };
293    match TypeHash::parse(text) {
294        Some(hash) => {
295            *out_hash = PamojaTypeHash {
296                digest: hash.digest(),
297            };
298            PamojaStatus::Ok
299        }
300        None => {
301            set_last_error(format!("`{text}` is not a well-formed RIHS01 hash"));
302            PamojaStatus::InvalidArgument
303        }
304    }
305}
306
307/// Renders a type hash back to its RIHS01 string.
308///
309/// # Arguments
310///
311/// * `hash` - the hash to render.
312///
313/// # Returns
314///
315/// A string the caller must release with
316/// [`pamoja_string_free`](crate::pamoja_string_free).
317#[no_mangle]
318pub extern "C" fn pamoja_ros2_type_hash_to_string(hash: PamojaTypeHash) -> *mut PamojaString {
319    PamojaString::into_raw(hash.text())
320}
321
322/// Builds the Zenoh key an `rmw_zenoh` peer publishes an entity on.
323///
324/// # Arguments
325///
326/// * `domain_id` - the ROS 2 domain.
327/// * `fqn` - the fully qualified entity name, as null-terminated UTF-8.
328/// * `ros_type` - the interface type as `package/namespace/Type`, as
329///   null-terminated UTF-8.
330/// * `hash` - the message type hash.
331///
332/// # Returns
333///
334/// A key such as `0/chatter/std_msgs::msg::dds_::String_/RIHS01_...`, which the
335/// caller must release with [`pamoja_string_free`](crate::pamoja_string_free),
336/// or null if the name is not fully qualified, the type is not a valid interface
337/// type, or either string is null.
338///
339/// # Safety
340///
341/// `fqn` and `ros_type` must be valid null-terminated UTF-8 strings for the
342/// duration of the call, or null.
343#[no_mangle]
344pub unsafe extern "C" fn pamoja_ros2_entity_key(
345    domain_id: u32,
346    fqn: *const c_char,
347    ros_type: *const c_char,
348    hash: PamojaTypeHash,
349) -> *mut PamojaString {
350    let (Some(fqn), Some(ros_type)) = (read_str(fqn, "fqn"), read_str(ros_type, "ros_type")) else {
351        return ptr::null_mut();
352    };
353    let Some(parsed) = TypeHash::parse(&hash.text()) else {
354        set_last_error("the type hash is malformed".to_owned());
355        return ptr::null_mut();
356    };
357    match entity_key(domain_id, fqn, ros_type, &parsed) {
358        Some(key) => PamojaString::into_raw(key),
359        None => {
360            set_last_error(format!(
361                "no entity key for `{fqn}` of type `{ros_type}` in domain {domain_id}"
362            ));
363            ptr::null_mut()
364        }
365    }
366}
367
368/// Encodes a twist into its CDR representation.
369///
370/// # Arguments
371///
372/// * `twist` - the command to encode.
373///
374/// # Returns
375///
376/// A buffer the caller must release with
377/// [`pamoja_buffer_free`](crate::pamoja_buffer_free).
378#[no_mangle]
379pub extern "C" fn pamoja_ros2_twist_to_cdr(twist: PamojaRos2Twist) -> *mut PamojaBuffer {
380    let twist = Twist {
381        linear: twist.linear.into(),
382        angular: twist.angular.into(),
383    };
384    PamojaBuffer::into_raw(twist.to_cdr())
385}
386
387/// Decodes a twist from its CDR representation.
388///
389/// # Arguments
390///
391/// * `data` - the encoded bytes.
392/// * `data_len` - the length of `data`.
393/// * `out_twist` - receives the decoded command.
394///
395/// # Returns
396///
397/// [`PamojaStatus::Ok`] once decoded, or [`PamojaStatus::InvalidArgument`] if the
398/// bytes are not a well-formed twist or `out_twist` is null.
399///
400/// # Safety
401///
402/// `data` must point to at least `data_len` readable bytes or be null when that
403/// length is 0, and `out_twist` must be writable.
404#[no_mangle]
405pub unsafe extern "C" fn pamoja_ros2_twist_from_cdr(
406    data: *const u8,
407    data_len: usize,
408    out_twist: *mut PamojaRos2Twist,
409) -> PamojaStatus {
410    if out_twist.is_null() {
411        set_last_error("out_twist must not be null".to_owned());
412        return PamojaStatus::InvalidArgument;
413    }
414    let data = match read_bytes(data, data_len) {
415        Ok(data) => data,
416        Err(status) => return status,
417    };
418    match Twist::from_cdr(&data) {
419        Some(twist) => {
420            *out_twist = PamojaRos2Twist {
421                linear: twist.linear.into(),
422                angular: twist.angular.into(),
423            };
424            PamojaStatus::Ok
425        }
426        None => {
427            set_last_error("the bytes are not a well-formed CDR twist".to_owned());
428            PamojaStatus::InvalidArgument
429        }
430    }
431}
432
433/// An opaque handle to a CDR encoder.
434pub struct PamojaCdrWriter {
435    inner: CdrWriter,
436}
437
438/// Creates a CDR encoder with the encapsulation header already written.
439///
440/// # Returns
441///
442/// A handle the caller must release with [`pamoja_cdr_writer_free`] or consume
443/// with [`pamoja_cdr_writer_into_bytes`].
444#[no_mangle]
445pub extern "C" fn pamoja_cdr_writer_new() -> *mut PamojaCdrWriter {
446    Box::into_raw(Box::new(PamojaCdrWriter {
447        inner: CdrWriter::new(),
448    }))
449}
450
451/// Appends a 32-bit signed integer.
452///
453/// # Arguments
454///
455/// * `writer` - the encoder.
456/// * `value` - the value to append.
457///
458/// # Returns
459///
460/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
461/// `writer` is null.
462///
463/// # Safety
464///
465/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
466#[no_mangle]
467pub unsafe extern "C" fn pamoja_cdr_writer_write_i32(
468    writer: *mut PamojaCdrWriter,
469    value: i32,
470) -> PamojaStatus {
471    let Some(writer) = writer_handle(writer) else {
472        return PamojaStatus::InvalidArgument;
473    };
474    writer.inner.write_i32(value);
475    PamojaStatus::Ok
476}
477
478/// Appends a 32-bit unsigned integer.
479///
480/// # Arguments
481///
482/// * `writer` - the encoder.
483/// * `value` - the value to append.
484///
485/// # Returns
486///
487/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
488/// `writer` is null.
489///
490/// # Safety
491///
492/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
493#[no_mangle]
494pub unsafe extern "C" fn pamoja_cdr_writer_write_u32(
495    writer: *mut PamojaCdrWriter,
496    value: u32,
497) -> PamojaStatus {
498    let Some(writer) = writer_handle(writer) else {
499        return PamojaStatus::InvalidArgument;
500    };
501    writer.inner.write_u32(value);
502    PamojaStatus::Ok
503}
504
505/// Appends a 32-bit float.
506///
507/// # Arguments
508///
509/// * `writer` - the encoder.
510/// * `value` - the value to append.
511///
512/// # Returns
513///
514/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
515/// `writer` is null.
516///
517/// # Safety
518///
519/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
520#[no_mangle]
521pub unsafe extern "C" fn pamoja_cdr_writer_write_f32(
522    writer: *mut PamojaCdrWriter,
523    value: f32,
524) -> PamojaStatus {
525    let Some(writer) = writer_handle(writer) else {
526        return PamojaStatus::InvalidArgument;
527    };
528    writer.inner.write_f32(value);
529    PamojaStatus::Ok
530}
531
532/// Appends a 64-bit float.
533///
534/// # Arguments
535///
536/// * `writer` - the encoder.
537/// * `value` - the value to append.
538///
539/// # Returns
540///
541/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
542/// `writer` is null.
543///
544/// # Safety
545///
546/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
547#[no_mangle]
548pub unsafe extern "C" fn pamoja_cdr_writer_write_f64(
549    writer: *mut PamojaCdrWriter,
550    value: f64,
551) -> PamojaStatus {
552    let Some(writer) = writer_handle(writer) else {
553        return PamojaStatus::InvalidArgument;
554    };
555    writer.inner.write_f64(value);
556    PamojaStatus::Ok
557}
558
559/// Takes the encoded bytes, consuming the encoder.
560///
561/// # Arguments
562///
563/// * `writer` - the encoder, which this call frees.
564///
565/// # Returns
566///
567/// A buffer the caller must release with
568/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or null if `writer` is
569/// null.
570///
571/// # Safety
572///
573/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null. After
574/// this call it must not be used again.
575#[no_mangle]
576pub unsafe extern "C" fn pamoja_cdr_writer_into_bytes(
577    writer: *mut PamojaCdrWriter,
578) -> *mut PamojaBuffer {
579    if writer.is_null() {
580        set_last_error("writer must not be null".to_owned());
581        return ptr::null_mut();
582    }
583    let writer = Box::from_raw(writer);
584    PamojaBuffer::into_raw(writer.inner.into_bytes())
585}
586
587/// Releases a CDR encoder handle.
588///
589/// Passing null is a no-op.
590///
591/// # Safety
592///
593/// `writer` must be a handle from [`pamoja_cdr_writer_new`] that has not already
594/// been freed or consumed, or null. After this call it must not be used again.
595#[no_mangle]
596pub unsafe extern "C" fn pamoja_cdr_writer_free(writer: *mut PamojaCdrWriter) {
597    if !writer.is_null() {
598        drop(Box::from_raw(writer));
599    }
600}
601
602/// The width of a field already taken from a decoder.
603///
604/// The core reader borrows the buffer it walks and keeps its cursor private, so
605/// this handle owns the bytes and replays the reads made so far to reach the
606/// cursor again. Alignment follows the width, so replaying by width lands in
607/// exactly the position the original sequence did.
608#[derive(Clone, Copy)]
609enum Field {
610    /// A four-byte field, aligned to four.
611    Word,
612    /// An eight-byte field, aligned to eight.
613    Double,
614}
615
616/// An opaque handle to a CDR decoder.
617pub struct PamojaCdrReader {
618    data: Vec<u8>,
619    taken: Vec<Field>,
620}
621
622/// Creates a CDR decoder over encoded bytes.
623///
624/// # Arguments
625///
626/// * `data` - the encoded bytes, which are copied.
627/// * `data_len` - the length of `data`.
628///
629/// # Returns
630///
631/// A handle the caller must release with [`pamoja_cdr_reader_free`], or null if
632/// the bytes carry no valid encapsulation header.
633///
634/// # Safety
635///
636/// `data` must point to at least `data_len` readable bytes, or be null when that
637/// length is 0.
638#[no_mangle]
639pub unsafe extern "C" fn pamoja_cdr_reader_new(
640    data: *const u8,
641    data_len: usize,
642) -> *mut PamojaCdrReader {
643    let Ok(data) = read_bytes(data, data_len) else {
644        return ptr::null_mut();
645    };
646    if CdrReader::new(&data).is_none() {
647        set_last_error("the bytes carry no valid CDR encapsulation header".to_owned());
648        return ptr::null_mut();
649    }
650    Box::into_raw(Box::new(PamojaCdrReader {
651        data,
652        taken: Vec::new(),
653    }))
654}
655
656/// Reads the next 32-bit signed integer.
657///
658/// # Arguments
659///
660/// * `reader` - the decoder.
661/// * `out_value` - receives the value.
662///
663/// # Returns
664///
665/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
666/// buffer is exhausted or either pointer is null.
667///
668/// # Safety
669///
670/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
671/// must be writable.
672#[no_mangle]
673pub unsafe extern "C" fn pamoja_cdr_reader_read_i32(
674    reader: *mut PamojaCdrReader,
675    out_value: *mut i32,
676) -> PamojaStatus {
677    read_field(reader, out_value, Field::Word, |cursor| cursor.read_i32())
678}
679
680/// Reads the next 32-bit unsigned integer.
681///
682/// # Arguments
683///
684/// * `reader` - the decoder.
685/// * `out_value` - receives the value.
686///
687/// # Returns
688///
689/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
690/// buffer is exhausted or either pointer is null.
691///
692/// # Safety
693///
694/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
695/// must be writable.
696#[no_mangle]
697pub unsafe extern "C" fn pamoja_cdr_reader_read_u32(
698    reader: *mut PamojaCdrReader,
699    out_value: *mut u32,
700) -> PamojaStatus {
701    read_field(reader, out_value, Field::Word, |cursor| cursor.read_u32())
702}
703
704/// Reads the next 32-bit float.
705///
706/// # Arguments
707///
708/// * `reader` - the decoder.
709/// * `out_value` - receives the value.
710///
711/// # Returns
712///
713/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
714/// buffer is exhausted or either pointer is null.
715///
716/// # Safety
717///
718/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
719/// must be writable.
720#[no_mangle]
721pub unsafe extern "C" fn pamoja_cdr_reader_read_f32(
722    reader: *mut PamojaCdrReader,
723    out_value: *mut f32,
724) -> PamojaStatus {
725    read_field(reader, out_value, Field::Word, |cursor| cursor.read_f32())
726}
727
728/// Reads the next 64-bit float.
729///
730/// # Arguments
731///
732/// * `reader` - the decoder.
733/// * `out_value` - receives the value.
734///
735/// # Returns
736///
737/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
738/// buffer is exhausted or either pointer is null.
739///
740/// # Safety
741///
742/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
743/// must be writable.
744#[no_mangle]
745pub unsafe extern "C" fn pamoja_cdr_reader_read_f64(
746    reader: *mut PamojaCdrReader,
747    out_value: *mut f64,
748) -> PamojaStatus {
749    read_field(reader, out_value, Field::Double, |cursor| cursor.read_f64())
750}
751
752/// Releases a CDR decoder handle.
753///
754/// Passing null is a no-op.
755///
756/// # Safety
757///
758/// `reader` must be a handle from [`pamoja_cdr_reader_new`] that has not already
759/// been freed, or null. After this call it must not be used again.
760#[no_mangle]
761pub unsafe extern "C" fn pamoja_cdr_reader_free(reader: *mut PamojaCdrReader) {
762    if !reader.is_null() {
763        drop(Box::from_raw(reader));
764    }
765}
766
767/// Borrows a writer handle, rejecting a null pointer.
768///
769/// # Safety
770///
771/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
772unsafe fn writer_handle<'a>(writer: *mut PamojaCdrWriter) -> Option<&'a mut PamojaCdrWriter> {
773    if writer.is_null() {
774        set_last_error("writer must not be null".to_owned());
775        return None;
776    }
777    Some(&mut *writer)
778}
779
780/// Reads one field, replaying the fields already taken to reach the cursor.
781///
782/// # Safety
783///
784/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
785/// must be writable.
786unsafe fn read_field<T>(
787    reader: *mut PamojaCdrReader,
788    out_value: *mut T,
789    width: Field,
790    read: impl FnOnce(&mut CdrReader<'_>) -> Option<T>,
791) -> PamojaStatus {
792    if reader.is_null() {
793        set_last_error("reader must not be null".to_owned());
794        return PamojaStatus::InvalidArgument;
795    }
796    if out_value.is_null() {
797        set_last_error("out_value must not be null".to_owned());
798        return PamojaStatus::InvalidArgument;
799    }
800    let handle = &mut *reader;
801    let Some(mut cursor) = CdrReader::new(&handle.data) else {
802        set_last_error("the bytes carry no valid CDR encapsulation header".to_owned());
803        return PamojaStatus::InvalidArgument;
804    };
805    for field in &handle.taken {
806        let stepped = match field {
807            Field::Word => cursor.read_u32().is_some(),
808            Field::Double => cursor.read_f64().is_some(),
809        };
810        if !stepped {
811            set_last_error("the CDR buffer is exhausted".to_owned());
812            return PamojaStatus::InvalidArgument;
813        }
814    }
815    match read(&mut cursor) {
816        Some(value) => {
817            *out_value = value;
818            handle.taken.push(width);
819            PamojaStatus::Ok
820        }
821        None => {
822            set_last_error("the CDR buffer is exhausted".to_owned());
823            PamojaStatus::InvalidArgument
824        }
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use std::ffi::{CStr, CString};
831
832    use super::*;
833
834    const CHATTER_HASH: &str =
835        "RIHS01_df668c740482bbd48fb39d76a70dfd4bd59db1288021743503259e948f6b1a18";
836
837    fn text_of(string: *mut PamojaString) -> String {
838        assert!(!string.is_null(), "the call produced no string");
839        let text = unsafe { CStr::from_ptr(crate::pamoja_string_data(string)) }
840            .to_str()
841            .expect("utf-8")
842            .to_owned();
843        unsafe { crate::pamoja_string_free(string) };
844        text
845    }
846
847    #[test]
848    fn a_topic_maps_onto_its_dds_name() {
849        let fqn = CString::new("/robot1/cmd_vel").expect("static");
850        let topic = unsafe { pamoja_ros2_dds_topic(fqn.as_ptr(), PamojaEntityKind::Topic) };
851        assert_eq!(text_of(topic), "rt/robot1/cmd_vel");
852    }
853
854    #[test]
855    fn a_name_that_breaks_the_rules_is_refused() {
856        let bad = CString::new("/2foo").expect("static");
857        assert!(!unsafe { pamoja_ros2_name_is_valid(bad.as_ptr()) });
858        let good = CString::new("/robot1/camera_left/image_raw").expect("static");
859        assert!(unsafe { pamoja_ros2_name_is_valid(good.as_ptr()) });
860    }
861
862    #[test]
863    fn an_entity_key_matches_the_published_example() {
864        let text = CString::new(CHATTER_HASH).expect("static");
865        let mut hash = PamojaTypeHash {
866            digest: [0u8; PAMOJA_TYPE_HASH_LEN],
867        };
868        assert_eq!(
869            unsafe { pamoja_ros2_type_hash_parse(text.as_ptr(), &mut hash) },
870            PamojaStatus::Ok
871        );
872        assert_eq!(text_of(pamoja_ros2_type_hash_to_string(hash)), CHATTER_HASH);
873
874        let fqn = CString::new("/chatter").expect("static");
875        let ros_type = CString::new("std_msgs/msg/String").expect("static");
876        let key = unsafe { pamoja_ros2_entity_key(0, fqn.as_ptr(), ros_type.as_ptr(), hash) };
877        assert_eq!(
878            text_of(key),
879            format!("0/chatter/std_msgs::msg::dds_::String_/{CHATTER_HASH}")
880        );
881    }
882
883    #[test]
884    fn a_twist_survives_a_cdr_round_trip() {
885        let sent = PamojaRos2Twist {
886            linear: PamojaVector3 {
887                x: 1.5,
888                y: 0.0,
889                z: 0.0,
890            },
891            angular: PamojaVector3 {
892                x: 0.0,
893                y: 0.0,
894                z: -0.25,
895            },
896        };
897        let buffer = pamoja_ros2_twist_to_cdr(sent);
898        assert!(!buffer.is_null());
899
900        let mut received = PamojaRos2Twist {
901            linear: PamojaVector3 {
902                x: 0.0,
903                y: 0.0,
904                z: 0.0,
905            },
906            angular: PamojaVector3 {
907                x: 0.0,
908                y: 0.0,
909                z: 0.0,
910            },
911        };
912        let status = unsafe {
913            pamoja_ros2_twist_from_cdr(
914                crate::pamoja_buffer_data(buffer),
915                crate::pamoja_buffer_len(buffer),
916                &mut received,
917            )
918        };
919        unsafe { crate::pamoja_buffer_free(buffer) };
920        assert_eq!(status, PamojaStatus::Ok);
921        assert_eq!(received, sent);
922    }
923
924    #[test]
925    fn mixed_width_fields_read_back_in_order() {
926        let writer = pamoja_cdr_writer_new();
927        unsafe {
928            assert_eq!(pamoja_cdr_writer_write_u32(writer, 7), PamojaStatus::Ok);
929            assert_eq!(pamoja_cdr_writer_write_f64(writer, 2.5), PamojaStatus::Ok);
930            assert_eq!(pamoja_cdr_writer_write_i32(writer, -3), PamojaStatus::Ok);
931        }
932        let buffer = unsafe { pamoja_cdr_writer_into_bytes(writer) };
933        assert!(!buffer.is_null());
934
935        let reader = unsafe {
936            pamoja_cdr_reader_new(
937                crate::pamoja_buffer_data(buffer),
938                crate::pamoja_buffer_len(buffer),
939            )
940        };
941        assert!(!reader.is_null());
942
943        let mut word = 0u32;
944        let mut double = 0f64;
945        let mut signed = 0i32;
946        unsafe {
947            assert_eq!(
948                pamoja_cdr_reader_read_u32(reader, &mut word),
949                PamojaStatus::Ok
950            );
951            assert_eq!(
952                pamoja_cdr_reader_read_f64(reader, &mut double),
953                PamojaStatus::Ok
954            );
955            assert_eq!(
956                pamoja_cdr_reader_read_i32(reader, &mut signed),
957                PamojaStatus::Ok
958            );
959            pamoja_cdr_reader_free(reader);
960            crate::pamoja_buffer_free(buffer);
961        }
962        assert_eq!(word, 7, "the first word reads back");
963        assert_eq!(double, 2.5, "an eight-byte field keeps its alignment");
964        assert_eq!(signed, -3, "and the field after it is not skewed");
965    }
966
967    #[test]
968    fn a_null_argument_is_rejected_rather_than_dereferenced() {
969        assert!(!unsafe { pamoja_ros2_name_is_valid(ptr::null()) });
970        assert!(!unsafe { pamoja_ros2_name_is_fully_qualified(ptr::null()) });
971        assert!(unsafe { pamoja_ros2_percent_mangle(ptr::null()) }.is_null());
972        assert!(unsafe { pamoja_ros2_dds_type_name(ptr::null()) }.is_null());
973        assert_eq!(
974            unsafe { pamoja_ros2_type_hash_parse(ptr::null(), ptr::null_mut()) },
975            PamojaStatus::InvalidArgument
976        );
977        assert_eq!(
978            unsafe { pamoja_cdr_writer_write_u32(ptr::null_mut(), 0) },
979            PamojaStatus::InvalidArgument
980        );
981        assert!(unsafe { pamoja_cdr_writer_into_bytes(ptr::null_mut()) }.is_null());
982    }
983}