zerodds_rpc/
topic_naming.rs1extern crate alloc;
17
18use alloc::format;
19use alloc::string::String;
20
21use crate::error::{RpcError, RpcResult};
22
23pub const REQUEST_SUFFIX: &str = "_Request";
25
26pub const REPLY_SUFFIX: &str = "_Reply";
28
29pub 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
50pub fn request_topic_name(service: &str) -> RpcResult<String> {
55 validate_service_name(service)?;
56 Ok(format!("{service}{REQUEST_SUFFIX}"))
57}
58
59pub fn reply_topic_name(service: &str) -> RpcResult<String> {
64 validate_service_name(service)?;
65 Ok(format!("{service}{REPLY_SUFFIX}"))
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct ServiceTopicNames {
71 pub service: String,
73 pub request: String,
75 pub reply: String,
77}
78
79impl ServiceTopicNames {
80 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 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}