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 and suffix.
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 and the `Request` suffix.
35    ServiceRequest = 1,
36    /// The reply side of a service, which takes the `rr` prefix and the `Reply` suffix.
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 meters 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 what the middleware appends to a name for a subsystem.
175///
176/// # Arguments
177///
178/// * `kind` - the subsystem.
179///
180/// # Returns
181///
182/// `""` for a topic, `"Request"` for a service request, and `"Reply"` for a service
183/// response, as a null-terminated string with static lifetime, which the caller does not
184/// free.
185#[no_mangle]
186pub extern "C" fn pamoja_ros2_entity_kind_suffix(kind: PamojaEntityKind) -> *const c_char {
187    match kind {
188        PamojaEntityKind::Topic => c"".as_ptr(),
189        PamojaEntityKind::ServiceRequest => c"Request".as_ptr(),
190        PamojaEntityKind::ServiceResponse => c"Reply".as_ptr(),
191    }
192}
193
194/// Returns the DDS topic a fully qualified ROS 2 name maps onto.
195///
196/// # Arguments
197///
198/// * `fqn` - the fully qualified name, as null-terminated UTF-8.
199/// * `kind` - which subsystem the name belongs to, which fixes the prefix and the
200///   suffix, so a service's request travels on `rq/<name>Request` and its reply on
201///   `rr/<name>Reply`.
202///
203/// # Returns
204///
205/// A string the caller must release with
206/// [`pamoja_string_free`](crate::pamoja_string_free), or null if the name is not
207/// fully qualified or `fqn` is null.
208///
209/// # Safety
210///
211/// `fqn` must be a valid null-terminated UTF-8 string for the duration of the
212/// call, or null.
213#[no_mangle]
214pub unsafe extern "C" fn pamoja_ros2_dds_topic(
215    fqn: *const c_char,
216    kind: PamojaEntityKind,
217) -> *mut PamojaString {
218    let Some(fqn) = read_str(fqn, "fqn") else {
219        return ptr::null_mut();
220    };
221    match dds_topic(fqn, kind.into()) {
222        Some(topic) => PamojaString::into_raw(topic),
223        None => {
224            set_last_error(format!("`{fqn}` is not a fully qualified ROS 2 name"));
225            ptr::null_mut()
226        }
227    }
228}
229
230/// Percent-mangles a name as `rmw_zenoh` writes it in a liveliness token, each `/` as `%`.
231///
232/// # Arguments
233///
234/// * `name` - the name to mangle, as null-terminated UTF-8.
235///
236/// # Returns
237///
238/// A string the caller must release with
239/// [`pamoja_string_free`](crate::pamoja_string_free), or null if `name` is null.
240///
241/// # Safety
242///
243/// `name` must be a valid null-terminated UTF-8 string for the duration of the
244/// call, or null.
245#[no_mangle]
246pub unsafe extern "C" fn pamoja_ros2_percent_mangle(name: *const c_char) -> *mut PamojaString {
247    let Some(name) = read_str(name, "name") else {
248        return ptr::null_mut();
249    };
250    PamojaString::into_raw(percent_mangle(name))
251}
252
253/// Returns the DDS type name a ROS 2 interface type maps onto.
254///
255/// # Arguments
256///
257/// * `ros_type` - the interface type as `package/namespace/Type`, such as
258///   `std_msgs/msg/String`, as null-terminated UTF-8.
259///
260/// # Returns
261///
262/// A string such as `std_msgs::msg::dds_::String_`, which the caller must
263/// release with [`pamoja_string_free`](crate::pamoja_string_free), or null if
264/// the type is not a valid three-part interface type or `ros_type` is null.
265///
266/// # Safety
267///
268/// `ros_type` must be a valid null-terminated UTF-8 string for the duration of
269/// the call, or null.
270#[no_mangle]
271pub unsafe extern "C" fn pamoja_ros2_dds_type_name(ros_type: *const c_char) -> *mut PamojaString {
272    let Some(ros_type) = read_str(ros_type, "ros_type") else {
273        return ptr::null_mut();
274    };
275    match dds_type_name(ros_type) {
276        Some(name) => PamojaString::into_raw(name),
277        None => {
278            set_last_error(format!(
279                "`{ros_type}` is not a `package/namespace/Type` interface type"
280            ));
281            ptr::null_mut()
282        }
283    }
284}
285
286/// Parses a RIHS01 type hash string.
287///
288/// # Arguments
289///
290/// * `text` - the candidate hash, expected as `RIHS01_` plus 64 lowercase hex
291///   digits, as null-terminated UTF-8.
292/// * `out_hash` - receives the parsed hash.
293///
294/// # Returns
295///
296/// [`PamojaStatus::Ok`] once parsed, or [`PamojaStatus::InvalidArgument`] if the
297/// text is malformed or either pointer is null.
298///
299/// # Safety
300///
301/// `text` must be a valid null-terminated UTF-8 string for the duration of the
302/// call, and `out_hash` must be writable.
303#[no_mangle]
304pub unsafe extern "C" fn pamoja_ros2_type_hash_parse(
305    text: *const c_char,
306    out_hash: *mut PamojaTypeHash,
307) -> PamojaStatus {
308    if out_hash.is_null() {
309        set_last_error("out_hash must not be null".to_owned());
310        return PamojaStatus::InvalidArgument;
311    }
312    let Some(text) = read_str(text, "text") else {
313        return PamojaStatus::InvalidArgument;
314    };
315    match TypeHash::parse(text) {
316        Some(hash) => {
317            *out_hash = PamojaTypeHash {
318                digest: hash.digest(),
319            };
320            PamojaStatus::Ok
321        }
322        None => {
323            set_last_error(format!("`{text}` is not a well-formed RIHS01 hash"));
324            PamojaStatus::InvalidArgument
325        }
326    }
327}
328
329/// Renders a type hash back to its RIHS01 string.
330///
331/// # Arguments
332///
333/// * `hash` - the hash to render.
334///
335/// # Returns
336///
337/// A string the caller must release with
338/// [`pamoja_string_free`](crate::pamoja_string_free).
339#[no_mangle]
340pub extern "C" fn pamoja_ros2_type_hash_to_string(hash: PamojaTypeHash) -> *mut PamojaString {
341    PamojaString::into_raw(hash.text())
342}
343
344/// Builds the Zenoh key an `rmw_zenoh` peer publishes an entity on.
345///
346/// # Arguments
347///
348/// * `domain_id` - the ROS 2 domain.
349/// * `fqn` - the fully qualified entity name, as null-terminated UTF-8.
350/// * `ros_type` - the interface type as `package/namespace/Type`, as
351///   null-terminated UTF-8.
352/// * `hash` - the message type hash.
353///
354/// # Returns
355///
356/// A key such as `0/chatter/std_msgs::msg::dds_::String_/RIHS01_...`, which the
357/// caller must release with [`pamoja_string_free`](crate::pamoja_string_free),
358/// or null if the name is not fully qualified, the type is not a valid interface
359/// type, or either string is null.
360///
361/// # Safety
362///
363/// `fqn` and `ros_type` must be valid null-terminated UTF-8 strings for the
364/// duration of the call, or null.
365#[no_mangle]
366pub unsafe extern "C" fn pamoja_ros2_entity_key(
367    domain_id: u32,
368    fqn: *const c_char,
369    ros_type: *const c_char,
370    hash: PamojaTypeHash,
371) -> *mut PamojaString {
372    let (Some(fqn), Some(ros_type)) = (read_str(fqn, "fqn"), read_str(ros_type, "ros_type")) else {
373        return ptr::null_mut();
374    };
375    let Some(parsed) = TypeHash::parse(&hash.text()) else {
376        set_last_error("the type hash is malformed".to_owned());
377        return ptr::null_mut();
378    };
379    match entity_key(domain_id, fqn, ros_type, &parsed) {
380        Some(key) => PamojaString::into_raw(key),
381        None => {
382            set_last_error(format!(
383                "no entity key for `{fqn}` of type `{ros_type}` in domain {domain_id}"
384            ));
385            ptr::null_mut()
386        }
387    }
388}
389
390/// Encodes a twist into its CDR representation.
391///
392/// # Arguments
393///
394/// * `twist` - the command to encode.
395///
396/// # Returns
397///
398/// A buffer the caller must release with
399/// [`pamoja_buffer_free`](crate::pamoja_buffer_free).
400#[no_mangle]
401pub extern "C" fn pamoja_ros2_twist_to_cdr(twist: PamojaRos2Twist) -> *mut PamojaBuffer {
402    let twist = Twist {
403        linear: twist.linear.into(),
404        angular: twist.angular.into(),
405    };
406    PamojaBuffer::into_raw(twist.to_cdr())
407}
408
409/// Decodes a twist from its CDR representation.
410///
411/// # Arguments
412///
413/// * `data` - the encoded bytes.
414/// * `data_len` - the length of `data`.
415/// * `out_twist` - receives the decoded command.
416///
417/// # Returns
418///
419/// [`PamojaStatus::Ok`] once decoded, or [`PamojaStatus::InvalidArgument`] if the
420/// bytes are not a well-formed twist or `out_twist` is null.
421///
422/// # Safety
423///
424/// `data` must point to at least `data_len` readable bytes or be null when that
425/// length is 0, and `out_twist` must be writable.
426#[no_mangle]
427pub unsafe extern "C" fn pamoja_ros2_twist_from_cdr(
428    data: *const u8,
429    data_len: usize,
430    out_twist: *mut PamojaRos2Twist,
431) -> PamojaStatus {
432    if out_twist.is_null() {
433        set_last_error("out_twist must not be null".to_owned());
434        return PamojaStatus::InvalidArgument;
435    }
436    let data = match read_bytes(data, data_len) {
437        Ok(data) => data,
438        Err(status) => return status,
439    };
440    match Twist::from_cdr(&data) {
441        Some(twist) => {
442            *out_twist = PamojaRos2Twist {
443                linear: twist.linear.into(),
444                angular: twist.angular.into(),
445            };
446            PamojaStatus::Ok
447        }
448        None => {
449            set_last_error("the bytes are not a well-formed CDR twist".to_owned());
450            PamojaStatus::InvalidArgument
451        }
452    }
453}
454
455/// An opaque handle to a CDR encoder.
456pub struct PamojaCdrWriter {
457    inner: CdrWriter,
458}
459
460/// Creates a CDR encoder with the encapsulation header already written.
461///
462/// # Returns
463///
464/// A handle the caller must release with [`pamoja_cdr_writer_free`] or consume
465/// with [`pamoja_cdr_writer_into_bytes`].
466#[no_mangle]
467pub extern "C" fn pamoja_cdr_writer_new() -> *mut PamojaCdrWriter {
468    Box::into_raw(Box::new(PamojaCdrWriter {
469        inner: CdrWriter::new(),
470    }))
471}
472
473/// Appends a 32-bit signed integer.
474///
475/// # Arguments
476///
477/// * `writer` - the encoder.
478/// * `value` - the value to append.
479///
480/// # Returns
481///
482/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
483/// `writer` is null.
484///
485/// # Safety
486///
487/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
488#[no_mangle]
489pub unsafe extern "C" fn pamoja_cdr_writer_write_i32(
490    writer: *mut PamojaCdrWriter,
491    value: i32,
492) -> PamojaStatus {
493    let Some(writer) = writer_handle(writer) else {
494        return PamojaStatus::InvalidArgument;
495    };
496    writer.inner.write_i32(value);
497    PamojaStatus::Ok
498}
499
500/// Appends a 32-bit unsigned integer.
501///
502/// # Arguments
503///
504/// * `writer` - the encoder.
505/// * `value` - the value to append.
506///
507/// # Returns
508///
509/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
510/// `writer` is null.
511///
512/// # Safety
513///
514/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
515#[no_mangle]
516pub unsafe extern "C" fn pamoja_cdr_writer_write_u32(
517    writer: *mut PamojaCdrWriter,
518    value: u32,
519) -> PamojaStatus {
520    let Some(writer) = writer_handle(writer) else {
521        return PamojaStatus::InvalidArgument;
522    };
523    writer.inner.write_u32(value);
524    PamojaStatus::Ok
525}
526
527/// Appends a 32-bit float.
528///
529/// # Arguments
530///
531/// * `writer` - the encoder.
532/// * `value` - the value to append.
533///
534/// # Returns
535///
536/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
537/// `writer` is null.
538///
539/// # Safety
540///
541/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
542#[no_mangle]
543pub unsafe extern "C" fn pamoja_cdr_writer_write_f32(
544    writer: *mut PamojaCdrWriter,
545    value: f32,
546) -> PamojaStatus {
547    let Some(writer) = writer_handle(writer) else {
548        return PamojaStatus::InvalidArgument;
549    };
550    writer.inner.write_f32(value);
551    PamojaStatus::Ok
552}
553
554/// Appends a 64-bit float.
555///
556/// # Arguments
557///
558/// * `writer` - the encoder.
559/// * `value` - the value to append.
560///
561/// # Returns
562///
563/// [`PamojaStatus::Ok`] once written, or [`PamojaStatus::InvalidArgument`] if
564/// `writer` is null.
565///
566/// # Safety
567///
568/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
569#[no_mangle]
570pub unsafe extern "C" fn pamoja_cdr_writer_write_f64(
571    writer: *mut PamojaCdrWriter,
572    value: f64,
573) -> PamojaStatus {
574    let Some(writer) = writer_handle(writer) else {
575        return PamojaStatus::InvalidArgument;
576    };
577    writer.inner.write_f64(value);
578    PamojaStatus::Ok
579}
580
581/// Takes the encoded bytes, consuming the encoder.
582///
583/// # Arguments
584///
585/// * `writer` - the encoder, which this call frees.
586///
587/// # Returns
588///
589/// A buffer the caller must release with
590/// [`pamoja_buffer_free`](crate::pamoja_buffer_free), or null if `writer` is
591/// null.
592///
593/// # Safety
594///
595/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null. After
596/// this call it must not be used again.
597#[no_mangle]
598pub unsafe extern "C" fn pamoja_cdr_writer_into_bytes(
599    writer: *mut PamojaCdrWriter,
600) -> *mut PamojaBuffer {
601    if writer.is_null() {
602        set_last_error("writer must not be null".to_owned());
603        return ptr::null_mut();
604    }
605    let writer = Box::from_raw(writer);
606    PamojaBuffer::into_raw(writer.inner.into_bytes())
607}
608
609/// Releases a CDR encoder handle.
610///
611/// Passing null is a no-op.
612///
613/// # Safety
614///
615/// `writer` must be a handle from [`pamoja_cdr_writer_new`] that has not already
616/// been freed or consumed, or null. After this call it must not be used again.
617#[no_mangle]
618pub unsafe extern "C" fn pamoja_cdr_writer_free(writer: *mut PamojaCdrWriter) {
619    if !writer.is_null() {
620        drop(Box::from_raw(writer));
621    }
622}
623
624/// The width of a field already taken from a decoder.
625///
626/// The core reader borrows the buffer it walks and keeps its cursor private, so
627/// this handle owns the bytes and replays the reads made so far to reach the
628/// cursor again. Alignment follows the width, so replaying by width lands in
629/// exactly the position the original sequence did.
630#[derive(Clone, Copy)]
631enum Field {
632    /// A four-byte field, aligned to four.
633    Word,
634    /// An eight-byte field, aligned to eight.
635    Double,
636}
637
638/// An opaque handle to a CDR decoder.
639pub struct PamojaCdrReader {
640    data: Vec<u8>,
641    taken: Vec<Field>,
642}
643
644/// Creates a CDR decoder over encoded bytes.
645///
646/// # Arguments
647///
648/// * `data` - the encoded bytes, which are copied.
649/// * `data_len` - the length of `data`.
650///
651/// # Returns
652///
653/// A handle the caller must release with [`pamoja_cdr_reader_free`], or null if
654/// the bytes carry no valid encapsulation header.
655///
656/// # Safety
657///
658/// `data` must point to at least `data_len` readable bytes, or be null when that
659/// length is 0.
660#[no_mangle]
661pub unsafe extern "C" fn pamoja_cdr_reader_new(
662    data: *const u8,
663    data_len: usize,
664) -> *mut PamojaCdrReader {
665    let Ok(data) = read_bytes(data, data_len) else {
666        return ptr::null_mut();
667    };
668    if CdrReader::new(&data).is_none() {
669        set_last_error("the bytes carry no valid CDR encapsulation header".to_owned());
670        return ptr::null_mut();
671    }
672    Box::into_raw(Box::new(PamojaCdrReader {
673        data,
674        taken: Vec::new(),
675    }))
676}
677
678/// Reads the next 32-bit signed integer.
679///
680/// # Arguments
681///
682/// * `reader` - the decoder.
683/// * `out_value` - receives the value.
684///
685/// # Returns
686///
687/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
688/// buffer is exhausted or either pointer is null.
689///
690/// # Safety
691///
692/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
693/// must be writable.
694#[no_mangle]
695pub unsafe extern "C" fn pamoja_cdr_reader_read_i32(
696    reader: *mut PamojaCdrReader,
697    out_value: *mut i32,
698) -> PamojaStatus {
699    read_field(reader, out_value, Field::Word, |cursor| cursor.read_i32())
700}
701
702/// Reads the next 32-bit unsigned integer.
703///
704/// # Arguments
705///
706/// * `reader` - the decoder.
707/// * `out_value` - receives the value.
708///
709/// # Returns
710///
711/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
712/// buffer is exhausted or either pointer is null.
713///
714/// # Safety
715///
716/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
717/// must be writable.
718#[no_mangle]
719pub unsafe extern "C" fn pamoja_cdr_reader_read_u32(
720    reader: *mut PamojaCdrReader,
721    out_value: *mut u32,
722) -> PamojaStatus {
723    read_field(reader, out_value, Field::Word, |cursor| cursor.read_u32())
724}
725
726/// Reads the next 32-bit float.
727///
728/// # Arguments
729///
730/// * `reader` - the decoder.
731/// * `out_value` - receives the value.
732///
733/// # Returns
734///
735/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
736/// buffer is exhausted or either pointer is null.
737///
738/// # Safety
739///
740/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
741/// must be writable.
742#[no_mangle]
743pub unsafe extern "C" fn pamoja_cdr_reader_read_f32(
744    reader: *mut PamojaCdrReader,
745    out_value: *mut f32,
746) -> PamojaStatus {
747    read_field(reader, out_value, Field::Word, |cursor| cursor.read_f32())
748}
749
750/// Reads the next 64-bit float.
751///
752/// # Arguments
753///
754/// * `reader` - the decoder.
755/// * `out_value` - receives the value.
756///
757/// # Returns
758///
759/// [`PamojaStatus::Ok`] once read, or [`PamojaStatus::InvalidArgument`] if the
760/// buffer is exhausted or either pointer is null.
761///
762/// # Safety
763///
764/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
765/// must be writable.
766#[no_mangle]
767pub unsafe extern "C" fn pamoja_cdr_reader_read_f64(
768    reader: *mut PamojaCdrReader,
769    out_value: *mut f64,
770) -> PamojaStatus {
771    read_field(reader, out_value, Field::Double, |cursor| cursor.read_f64())
772}
773
774/// Releases a CDR decoder handle.
775///
776/// Passing null is a no-op.
777///
778/// # Safety
779///
780/// `reader` must be a handle from [`pamoja_cdr_reader_new`] that has not already
781/// been freed, or null. After this call it must not be used again.
782#[no_mangle]
783pub unsafe extern "C" fn pamoja_cdr_reader_free(reader: *mut PamojaCdrReader) {
784    if !reader.is_null() {
785        drop(Box::from_raw(reader));
786    }
787}
788
789/// Borrows a writer handle, rejecting a null pointer.
790///
791/// # Safety
792///
793/// `writer` must be a live handle from [`pamoja_cdr_writer_new`], or null.
794unsafe fn writer_handle<'a>(writer: *mut PamojaCdrWriter) -> Option<&'a mut PamojaCdrWriter> {
795    if writer.is_null() {
796        set_last_error("writer must not be null".to_owned());
797        return None;
798    }
799    Some(&mut *writer)
800}
801
802/// Reads one field, replaying the fields already taken to reach the cursor.
803///
804/// # Safety
805///
806/// `reader` must be a live handle from [`pamoja_cdr_reader_new`] and `out_value`
807/// must be writable.
808unsafe fn read_field<T>(
809    reader: *mut PamojaCdrReader,
810    out_value: *mut T,
811    width: Field,
812    read: impl FnOnce(&mut CdrReader<'_>) -> Option<T>,
813) -> PamojaStatus {
814    if reader.is_null() {
815        set_last_error("reader must not be null".to_owned());
816        return PamojaStatus::InvalidArgument;
817    }
818    if out_value.is_null() {
819        set_last_error("out_value must not be null".to_owned());
820        return PamojaStatus::InvalidArgument;
821    }
822    let handle = &mut *reader;
823    let Some(mut cursor) = CdrReader::new(&handle.data) else {
824        set_last_error("the bytes carry no valid CDR encapsulation header".to_owned());
825        return PamojaStatus::InvalidArgument;
826    };
827    for field in &handle.taken {
828        let stepped = match field {
829            Field::Word => cursor.read_u32().is_some(),
830            Field::Double => cursor.read_f64().is_some(),
831        };
832        if !stepped {
833            set_last_error("the CDR buffer is exhausted".to_owned());
834            return PamojaStatus::InvalidArgument;
835        }
836    }
837    match read(&mut cursor) {
838        Some(value) => {
839            *out_value = value;
840            handle.taken.push(width);
841            PamojaStatus::Ok
842        }
843        None => {
844            set_last_error("the CDR buffer is exhausted".to_owned());
845            PamojaStatus::InvalidArgument
846        }
847    }
848}
849
850#[cfg(test)]
851mod tests {
852    use std::ffi::{CStr, CString};
853
854    use super::*;
855
856    const CHATTER_HASH: &str =
857        "RIHS01_df668c740482bbd48fb39d76a70dfd4bd59db1288021743503259e948f6b1a18";
858
859    fn text_of(string: *mut PamojaString) -> String {
860        assert!(!string.is_null(), "the call produced no string");
861        let text = unsafe { CStr::from_ptr(crate::pamoja_string_data(string)) }
862            .to_str()
863            .expect("utf-8")
864            .to_owned();
865        unsafe { crate::pamoja_string_free(string) };
866        text
867    }
868
869    #[test]
870    fn a_topic_maps_onto_its_dds_name() {
871        let fqn = CString::new("/robot1/cmd_vel").expect("static");
872        let topic = unsafe { pamoja_ros2_dds_topic(fqn.as_ptr(), PamojaEntityKind::Topic) };
873        assert_eq!(text_of(topic), "rt/robot1/cmd_vel");
874    }
875
876    #[test]
877    fn a_service_travels_on_a_request_and_a_reply_topic() {
878        let fqn = CString::new("/add_two_ints").expect("static");
879        // Safety: the name is valid for the call.
880        let (asked, answered) = unsafe {
881            (
882                pamoja_ros2_dds_topic(fqn.as_ptr(), PamojaEntityKind::ServiceRequest),
883                pamoja_ros2_dds_topic(fqn.as_ptr(), PamojaEntityKind::ServiceResponse),
884            )
885        };
886        assert_eq!(text_of(asked), "rq/add_two_intsRequest");
887        assert_eq!(text_of(answered), "rr/add_two_intsReply");
888        // Safety: the suffix is a static string the ABI owns.
889        let suffix = unsafe {
890            CStr::from_ptr(pamoja_ros2_entity_kind_suffix(
891                PamojaEntityKind::ServiceRequest,
892            ))
893        };
894        assert_eq!(suffix.to_str(), Ok("Request"));
895    }
896
897    #[test]
898    fn a_name_that_breaks_the_rules_is_refused() {
899        let bad = CString::new("/2foo").expect("static");
900        assert!(!unsafe { pamoja_ros2_name_is_valid(bad.as_ptr()) });
901        let good = CString::new("/robot1/camera_left/image_raw").expect("static");
902        assert!(unsafe { pamoja_ros2_name_is_valid(good.as_ptr()) });
903    }
904
905    #[test]
906    fn an_entity_key_matches_the_published_example() {
907        let text = CString::new(CHATTER_HASH).expect("static");
908        let mut hash = PamojaTypeHash {
909            digest: [0u8; PAMOJA_TYPE_HASH_LEN],
910        };
911        assert_eq!(
912            unsafe { pamoja_ros2_type_hash_parse(text.as_ptr(), &mut hash) },
913            PamojaStatus::Ok
914        );
915        assert_eq!(text_of(pamoja_ros2_type_hash_to_string(hash)), CHATTER_HASH);
916
917        let fqn = CString::new("/chatter").expect("static");
918        let ros_type = CString::new("std_msgs/msg/String").expect("static");
919        let key = unsafe { pamoja_ros2_entity_key(0, fqn.as_ptr(), ros_type.as_ptr(), hash) };
920        assert_eq!(
921            text_of(key),
922            format!("0/chatter/std_msgs::msg::dds_::String_/{CHATTER_HASH}")
923        );
924    }
925
926    #[test]
927    fn a_twist_survives_a_cdr_round_trip() {
928        let sent = PamojaRos2Twist {
929            linear: PamojaVector3 {
930                x: 1.5,
931                y: 0.0,
932                z: 0.0,
933            },
934            angular: PamojaVector3 {
935                x: 0.0,
936                y: 0.0,
937                z: -0.25,
938            },
939        };
940        let buffer = pamoja_ros2_twist_to_cdr(sent);
941        assert!(!buffer.is_null());
942
943        let mut received = PamojaRos2Twist {
944            linear: PamojaVector3 {
945                x: 0.0,
946                y: 0.0,
947                z: 0.0,
948            },
949            angular: PamojaVector3 {
950                x: 0.0,
951                y: 0.0,
952                z: 0.0,
953            },
954        };
955        let status = unsafe {
956            pamoja_ros2_twist_from_cdr(
957                crate::pamoja_buffer_data(buffer),
958                crate::pamoja_buffer_len(buffer),
959                &mut received,
960            )
961        };
962        unsafe { crate::pamoja_buffer_free(buffer) };
963        assert_eq!(status, PamojaStatus::Ok);
964        assert_eq!(received, sent);
965    }
966
967    #[test]
968    fn mixed_width_fields_read_back_in_order() {
969        let writer = pamoja_cdr_writer_new();
970        unsafe {
971            assert_eq!(pamoja_cdr_writer_write_u32(writer, 7), PamojaStatus::Ok);
972            assert_eq!(pamoja_cdr_writer_write_f64(writer, 2.5), PamojaStatus::Ok);
973            assert_eq!(pamoja_cdr_writer_write_i32(writer, -3), PamojaStatus::Ok);
974        }
975        let buffer = unsafe { pamoja_cdr_writer_into_bytes(writer) };
976        assert!(!buffer.is_null());
977
978        let reader = unsafe {
979            pamoja_cdr_reader_new(
980                crate::pamoja_buffer_data(buffer),
981                crate::pamoja_buffer_len(buffer),
982            )
983        };
984        assert!(!reader.is_null());
985
986        let mut word = 0u32;
987        let mut double = 0f64;
988        let mut signed = 0i32;
989        unsafe {
990            assert_eq!(
991                pamoja_cdr_reader_read_u32(reader, &mut word),
992                PamojaStatus::Ok
993            );
994            assert_eq!(
995                pamoja_cdr_reader_read_f64(reader, &mut double),
996                PamojaStatus::Ok
997            );
998            assert_eq!(
999                pamoja_cdr_reader_read_i32(reader, &mut signed),
1000                PamojaStatus::Ok
1001            );
1002            pamoja_cdr_reader_free(reader);
1003            crate::pamoja_buffer_free(buffer);
1004        }
1005        assert_eq!(word, 7, "the first word reads back");
1006        assert_eq!(double, 2.5, "an eight-byte field keeps its alignment");
1007        assert_eq!(signed, -3, "and the field after it is not skewed");
1008    }
1009
1010    #[test]
1011    fn a_null_argument_is_rejected_rather_than_dereferenced() {
1012        assert!(!unsafe { pamoja_ros2_name_is_valid(ptr::null()) });
1013        assert!(!unsafe { pamoja_ros2_name_is_fully_qualified(ptr::null()) });
1014        assert!(unsafe { pamoja_ros2_percent_mangle(ptr::null()) }.is_null());
1015        assert!(unsafe { pamoja_ros2_dds_type_name(ptr::null()) }.is_null());
1016        assert_eq!(
1017            unsafe { pamoja_ros2_type_hash_parse(ptr::null(), ptr::null_mut()) },
1018            PamojaStatus::InvalidArgument
1019        );
1020        assert_eq!(
1021            unsafe { pamoja_cdr_writer_write_u32(ptr::null_mut(), 0) },
1022            PamojaStatus::InvalidArgument
1023        );
1024        assert!(unsafe { pamoja_cdr_writer_into_bytes(ptr::null_mut()) }.is_null());
1025    }
1026}