ruststream_pulsar/
topic.rs1use crate::error::PulsarError;
8
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24#[must_use]
25pub struct PulsarTopic {
26 full: String,
27}
28
29fn valid_part(part: &str) -> bool {
30 !part.is_empty()
31 && part
32 .chars()
33 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
34}
35
36impl PulsarTopic {
37 fn of(scheme: &str, tenant: &str, namespace: &str, topic: &str) -> Result<Self, PulsarError> {
38 for (label, part) in [
39 ("tenant", tenant),
40 ("namespace", namespace),
41 ("topic", topic),
42 ] {
43 if !valid_part(part) {
44 return Err(PulsarError::Invalid(format!(
45 "{label} '{part}' must be non-empty and contain only alphanumerics, '-', '_', '.'"
46 )));
47 }
48 }
49 Ok(Self {
50 full: format!("{scheme}://{tenant}/{namespace}/{topic}"),
51 })
52 }
53
54 pub fn persistent(tenant: &str, namespace: &str, topic: &str) -> Self {
61 Self::of("persistent", tenant, namespace, topic).expect("invalid pulsar topic component")
62 }
63
64 pub fn non_persistent(tenant: &str, namespace: &str, topic: &str) -> Self {
71 Self::of("non-persistent", tenant, namespace, topic)
72 .expect("invalid pulsar topic component")
73 }
74
75 pub fn parse(name: &str) -> Result<Self, PulsarError> {
83 let (scheme, rest) = name.strip_prefix("non-persistent://").map_or_else(
84 || {
85 name.strip_prefix("persistent://")
86 .map_or(("persistent", name), |rest| ("persistent", rest))
87 },
88 |rest| ("non-persistent", rest),
89 );
90 let parts: Vec<&str> = rest.split('/').collect();
91 match parts.as_slice() {
92 [topic] => Self::of(scheme, "public", "default", topic),
93 [tenant, namespace, topic] => Self::of(scheme, tenant, namespace, topic),
94 _ => Err(PulsarError::Invalid(format!(
95 "topic '{name}' must be 'topic', 'tenant/namespace/topic', or fully qualified"
96 ))),
97 }
98 }
99
100 #[must_use]
102 pub fn as_str(&self) -> &str {
103 &self.full
104 }
105}
106
107impl std::fmt::Display for PulsarTopic {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.write_str(&self.full)
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn constructors_qualify_fully() {
119 assert_eq!(
120 PulsarTopic::persistent("acme", "orders", "created").as_str(),
121 "persistent://acme/orders/created"
122 );
123 assert_eq!(
124 PulsarTopic::non_persistent("acme", "telemetry", "ticks").as_str(),
125 "non-persistent://acme/telemetry/ticks"
126 );
127 }
128
129 #[test]
130 fn parse_defaults_bare_names_to_public_default() {
131 assert_eq!(
132 PulsarTopic::parse("orders").expect("parses").as_str(),
133 "persistent://public/default/orders"
134 );
135 }
136
137 #[test]
138 fn parse_rejects_malformed_shapes() {
139 assert!(PulsarTopic::parse("a/b").is_err());
140 assert!(PulsarTopic::parse("persistent://a//c").is_err());
141 assert!(PulsarTopic::parse("bad name").is_err());
142 }
143}