Skip to main content

zerodds_rpc/
topic_naming.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! DDS-RPC topic naming convention — Spec §7.8.2.
5//!
6//! From a service name `S`, two topic names are derived:
7//!
8//! * Request topic: `<S>_Request`
9//! * Reply topic:   `<S>_Reply`
10//!
11//! Service names must be non-empty and may only contain ASCII
12//! letters (`A-Z`, `a-z`), digits (`0-9`) and `_`. The
13//! first character must be a letter or `_` (analogous to the C identifier
14//! rule — the spec references IDL identifiers).
15
16extern crate alloc;
17
18use alloc::format;
19use alloc::string::String;
20
21use crate::error::{RpcError, RpcResult};
22
23/// Topic suffix for request topics (Spec §7.8.2).
24pub const REQUEST_SUFFIX: &str = "_Request";
25
26/// Topic suffix for reply topics (Spec §7.8.2).
27pub const REPLY_SUFFIX: &str = "_Reply";
28
29/// Validates a service name.
30///
31/// # Errors
32/// `RpcError::InvalidServiceName` if the name is empty or contains
33/// characters outside `[A-Za-z0-9_]`, or begins with a digit.
34pub fn validate_service_name(service: &str) -> RpcResult<()> {
35    if service.is_empty() {
36        return Err(RpcError::InvalidServiceName(String::new()));
37    }
38    let first = service.as_bytes()[0];
39    if !(first.is_ascii_alphabetic() || first == b'_') {
40        return Err(RpcError::InvalidServiceName(service.into()));
41    }
42    for &b in service.as_bytes() {
43        if !(b.is_ascii_alphanumeric() || b == b'_') {
44            return Err(RpcError::InvalidServiceName(service.into()));
45        }
46    }
47    Ok(())
48}
49
50/// Returns the request topic name for a service.
51///
52/// # Errors
53/// See [`validate_service_name`].
54pub fn request_topic_name(service: &str) -> RpcResult<String> {
55    validate_service_name(service)?;
56    Ok(format!("{service}{REQUEST_SUFFIX}"))
57}
58
59/// Returns the reply topic name for a service.
60///
61/// # Errors
62/// See [`validate_service_name`].
63pub fn reply_topic_name(service: &str) -> RpcResult<String> {
64    validate_service_name(service)?;
65    Ok(format!("{service}{REPLY_SUFFIX}"))
66}
67
68/// Convenient container for the pair of topic names.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ServiceTopicNames {
71    /// Service name (validated).
72    pub service: String,
73    /// `<service>_Request`.
74    pub request: String,
75    /// `<service>_Reply`.
76    pub reply: String,
77}
78
79impl ServiceTopicNames {
80    /// Constructor with validation.
81    ///
82    /// # Errors
83    /// `RpcError::InvalidServiceName` on an empty or invalid
84    /// service name.
85    pub fn new(service: &str) -> RpcResult<Self> {
86        let request = request_topic_name(service)?;
87        let reply = reply_topic_name(service)?;
88        Ok(Self {
89            service: service.into(),
90            request,
91            reply,
92        })
93    }
94}
95
96#[cfg(test)]
97#[allow(clippy::unwrap_used, clippy::expect_used)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn happy_path_calculator() {
103        assert_eq!(
104            request_topic_name("Calculator").unwrap(),
105            "Calculator_Request"
106        );
107        assert_eq!(reply_topic_name("Calculator").unwrap(), "Calculator_Reply");
108    }
109
110    #[test]
111    fn underscore_prefix_is_valid() {
112        // Spec references IDL identifiers; `_foo` is legitimate.
113        assert!(validate_service_name("_foo").is_ok());
114    }
115
116    #[test]
117    fn alphanumeric_with_digits_in_middle() {
118        assert!(validate_service_name("Calc2_v3").is_ok());
119    }
120
121    #[test]
122    fn empty_name_rejected() {
123        let err = validate_service_name("").unwrap_err();
124        assert_eq!(err, RpcError::InvalidServiceName(String::new()));
125    }
126
127    #[test]
128    fn whitespace_rejected() {
129        let err = validate_service_name("My Service").unwrap_err();
130        assert!(matches!(err, RpcError::InvalidServiceName(_)));
131    }
132
133    #[test]
134    fn dash_rejected() {
135        let err = validate_service_name("my-service").unwrap_err();
136        assert!(matches!(err, RpcError::InvalidServiceName(_)));
137    }
138
139    #[test]
140    fn starts_with_digit_rejected() {
141        let err = validate_service_name("9calc").unwrap_err();
142        assert!(matches!(err, RpcError::InvalidServiceName(_)));
143    }
144
145    #[test]
146    fn non_ascii_unicode_rejected() {
147        let err = validate_service_name("Rechnerü").unwrap_err();
148        assert!(matches!(err, RpcError::InvalidServiceName(_)));
149    }
150
151    #[test]
152    fn service_topic_names_pair() {
153        let names = ServiceTopicNames::new("Calc").unwrap();
154        assert_eq!(names.service, "Calc");
155        assert_eq!(names.request, "Calc_Request");
156        assert_eq!(names.reply, "Calc_Reply");
157    }
158
159    #[test]
160    fn service_topic_names_propagates_error() {
161        let err = ServiceTopicNames::new("").unwrap_err();
162        assert!(matches!(err, RpcError::InvalidServiceName(_)));
163    }
164}