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-Konvention — Spec §7.8.2.
5//!
6//! Aus einem Service-Namen `S` werden zwei Topic-Namen abgeleitet:
7//!
8//! * Request-Topic: `<S>_Request`
9//! * Reply-Topic:   `<S>_Reply`
10//!
11//! Service-Namen muessen nicht-leer sein und duerfen nur ASCII-
12//! Buchstaben (`A-Z`, `a-z`), Ziffern (`0-9`) sowie `_` enthalten. Das
13//! erste Zeichen muss ein Buchstabe oder `_` sein (analog C-Identifier-
14//! Regel — die Spec referenziert IDL-Identifier).
15
16extern crate alloc;
17
18use alloc::format;
19use alloc::string::String;
20
21use crate::error::{RpcError, RpcResult};
22
23/// Topic-Suffix fuer Request-Topics (Spec §7.8.2).
24pub const REQUEST_SUFFIX: &str = "_Request";
25
26/// Topic-Suffix fuer Reply-Topics (Spec §7.8.2).
27pub const REPLY_SUFFIX: &str = "_Reply";
28
29/// Validiert einen Service-Namen.
30///
31/// # Errors
32/// `RpcError::InvalidServiceName` wenn der Name leer ist oder Zeichen
33/// ausserhalb von `[A-Za-z0-9_]` enthaelt, oder mit einer Ziffer beginnt.
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/// Liefert den Request-Topic-Namen fuer einen Service.
51///
52/// # Errors
53/// Siehe [`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/// Liefert den Reply-Topic-Namen fuer einen Service.
60///
61/// # Errors
62/// Siehe [`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/// Bequemer Container fuer das Pair an Topic-Namen.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ServiceTopicNames {
71    /// Service-Name (validiert).
72    pub service: String,
73    /// `<service>_Request`.
74    pub request: String,
75    /// `<service>_Reply`.
76    pub reply: String,
77}
78
79impl ServiceTopicNames {
80    /// Konstruktor mit Validation.
81    ///
82    /// # Errors
83    /// `RpcError::InvalidServiceName` bei leerem oder ungueltigem
84    /// Service-Namen.
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 referenziert IDL-Identifier; `_foo` ist legitim.
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}