1use super::router::MessageRouter;
2use super::TransportError;
3
4pub const DEFAULT_BUS_NAMESPACE: &str = "default";
7pub const MAX_TOPOLOGY_NAME_LEN: usize = 128;
9
10#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct BusTopologyConfig {
19 group: Option<String>,
20 namespace: String,
21}
22
23impl Default for BusTopologyConfig {
24 fn default() -> Self {
25 Self {
26 group: None,
27 namespace: DEFAULT_BUS_NAMESPACE.to_string(),
28 }
29 }
30}
31
32impl BusTopologyConfig {
33 pub fn default_namespace() -> &'static str {
35 DEFAULT_BUS_NAMESPACE
36 }
37
38 pub fn group(mut self, group: impl Into<String>) -> Self {
40 self.group = Some(group.into());
41 self
42 }
43
44 pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
46 self.namespace = namespace.into();
47 self
48 }
49
50 pub fn namespace_unchecked(&self) -> &str {
52 &self.namespace
53 }
54
55 pub fn resolve_consumer_group<R: MessageRouter>(
58 &self,
59 router: &R,
60 transport: &str,
61 ) -> Result<String, TransportError> {
62 resolve_consumer_group(self.group.as_deref(), router, transport)
63 }
64
65 pub fn namespace_for(&self, transport: &str) -> Result<String, TransportError> {
67 validate_namespace(&self.namespace, transport)
68 }
69
70 pub fn validate_for(self, transport: &str) -> Result<Self, TransportError> {
72 let group = self
73 .group
74 .map(|group| validate_consumer_group(&group, transport))
75 .transpose()?;
76 let namespace = validate_namespace(&self.namespace, transport)?;
77 Ok(Self { group, namespace })
78 }
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82enum TopologyNameKind {
83 ConsumerGroup,
84 Namespace,
85}
86
87impl TopologyNameKind {
88 fn label(self) -> &'static str {
89 match self {
90 Self::ConsumerGroup => "consumer group",
91 Self::Namespace => "namespace",
92 }
93 }
94
95 fn allows_dot(self) -> bool {
96 matches!(self, Self::Namespace)
97 }
98}
99
100pub fn validate_consumer_group(value: &str, transport: &str) -> Result<String, TransportError> {
102 validate_topology_name(value, TopologyNameKind::ConsumerGroup, transport)
103}
104
105pub fn validate_namespace(value: &str, transport: &str) -> Result<String, TransportError> {
107 validate_topology_name(value, TopologyNameKind::Namespace, transport)
108}
109
110pub fn resolve_consumer_group<R: MessageRouter>(
113 explicit: Option<&str>,
114 router: &R,
115 transport: &str,
116) -> Result<String, TransportError> {
117 let Some(group) = explicit.or_else(|| router.consumer_group()) else {
118 return Err(TransportError::permanent(format!(
119 "{transport} bus requires a consumer group; call `Service::named(..)` \
120 for service consumers or `bus.group(..)` for direct consumers"
121 )));
122 };
123
124 validate_consumer_group(group, transport)
125}
126
127fn validate_topology_name(
128 value: &str,
129 kind: TopologyNameKind,
130 transport: &str,
131) -> Result<String, TransportError> {
132 let label = kind.label();
133 let trimmed = value.trim();
134 if trimmed.is_empty() {
135 return Err(topology_error(transport, label, "cannot be empty"));
136 }
137 if trimmed.len() != value.len() {
138 return Err(topology_error(
139 transport,
140 label,
141 "cannot contain leading or trailing whitespace",
142 ));
143 }
144 if value.len() > MAX_TOPOLOGY_NAME_LEN {
145 return Err(topology_error(
146 transport,
147 label,
148 format!("cannot exceed {MAX_TOPOLOGY_NAME_LEN} bytes"),
149 ));
150 }
151
152 for ch in value.chars() {
153 if ch.is_control() {
154 return Err(topology_error(
155 transport,
156 label,
157 format!("cannot contain control character {}", display_char(ch)),
158 ));
159 }
160 if ch.is_whitespace() {
161 return Err(topology_error(
162 transport,
163 label,
164 format!("cannot contain whitespace character {}", display_char(ch)),
165 ));
166 }
167 if matches!(ch, '*' | '>') {
168 return Err(topology_error(
169 transport,
170 label,
171 format!("cannot contain NATS wildcard {}", display_char(ch)),
172 ));
173 }
174 if matches!(ch, '/' | '\\') {
175 return Err(topology_error(
176 transport,
177 label,
178 format!("cannot contain path separator {}", display_char(ch)),
179 ));
180 }
181 if ch == '.' && !kind.allows_dot() {
182 return Err(topology_error(
183 transport,
184 label,
185 "cannot contain `.`; use `-` or `_` for portable group names",
186 ));
187 }
188 if !(ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') || ch == '.') {
189 return Err(topology_error(
190 transport,
191 label,
192 format!("cannot contain character {}", display_char(ch)),
193 ));
194 }
195 }
196
197 Ok(value.to_string())
198}
199
200fn topology_error(transport: &str, label: &str, reason: impl std::fmt::Display) -> TransportError {
201 TransportError::permanent(format!("{transport} bus {label} {reason}"))
202}
203
204fn display_char(ch: char) -> String {
205 if ch.is_control() {
206 format!("U+{:04X}", u32::from(ch))
207 } else {
208 format!("`{ch}`")
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::bus::{Message, MessageKind, SubscriptionPlan};
216
217 struct TestRouter {
218 group: Option<&'static str>,
219 }
220
221 impl MessageRouter for TestRouter {
222 fn consumer_group(&self) -> Option<&str> {
223 self.group
224 }
225
226 fn handles(&self, _kind: MessageKind, _name: &str) -> bool {
227 false
228 }
229
230 fn subscription_plan(&self) -> SubscriptionPlan {
231 SubscriptionPlan::default()
232 }
233
234 async fn dispatch(&self, _message: &Message) -> Result<(), TransportError> {
235 Ok(())
236 }
237 }
238
239 #[test]
240 fn resolve_consumer_group_prefers_explicit_bus_group() {
241 let router = TestRouter {
242 group: Some("service"),
243 };
244 let group = resolve_consumer_group(Some("override"), &router, "test").unwrap();
245 assert_eq!(group, "override");
246 }
247
248 #[test]
249 fn resolve_consumer_group_uses_router_identity() {
250 let router = TestRouter {
251 group: Some("service"),
252 };
253 let group = resolve_consumer_group(None, &router, "test").unwrap();
254 assert_eq!(group, "service");
255 }
256
257 #[test]
258 fn resolve_consumer_group_rejects_missing_identity() {
259 let router = TestRouter { group: None };
260 let err = resolve_consumer_group(None, &router, "test").unwrap_err();
261 assert!(err.is_permanent());
262 assert!(err.message().contains("Service::named"));
263 }
264
265 #[test]
266 fn resolve_consumer_group_rejects_whitespace_identity() {
267 let router = TestRouter {
268 group: Some(" service"),
269 };
270 let err = resolve_consumer_group(None, &router, "test").unwrap_err();
271 assert!(err.is_permanent());
272 assert!(err.message().contains("whitespace"));
273 }
274
275 #[test]
276 fn validate_consumer_group_rejects_wildcards() {
277 let err = validate_consumer_group("orders>*", "nats").unwrap_err();
278 assert!(err.message().contains("NATS wildcard"));
279 }
280
281 #[test]
282 fn validate_consumer_group_rejects_path_separators() {
283 let err = validate_consumer_group("tenant/orders", "rabbitmq").unwrap_err();
284 assert!(err.message().contains("path separator"));
285 }
286
287 #[test]
288 fn validate_consumer_group_rejects_overlong_names() {
289 let value = "a".repeat(MAX_TOPOLOGY_NAME_LEN + 1);
290 let err = validate_consumer_group(&value, "kafka").unwrap_err();
291 assert!(err.message().contains("cannot exceed"));
292 }
293
294 #[test]
295 fn validate_namespace_accepts_dotted_names() {
296 let namespace = validate_namespace("todos-prod.v1", "kafka").unwrap();
297 assert_eq!(namespace, "todos-prod.v1");
298 }
299
300 #[test]
301 fn validate_namespace_rejects_control_characters() {
302 let err = validate_namespace("todos\nprod", "nats").unwrap_err();
303 assert!(err.message().contains("control character"));
304 }
305}