1extern crate alloc;
12use alloc::string::String;
13use core::fmt;
14
15#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum NamespaceError {
18 Empty,
19 TooLong { max: usize },
20 InvalidCharacter { ch: char },
21 EmptySegment,
22 TrailingSeparator,
23}
24
25impl fmt::Display for NamespaceError {
26 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27 match self {
28 Self::Empty => f.write_str("namespace must not be empty"),
29 Self::TooLong { max } => write!(f, "namespace exceeds {max} characters"),
30 Self::InvalidCharacter { ch } => {
31 write!(f, "namespace contains invalid character {ch:?}")
32 }
33 Self::EmptySegment => f.write_str("namespace must not contain empty path segments"),
34 Self::TrailingSeparator => f.write_str("namespace must not end with ':'"),
35 }
36 }
37}
38
39#[cfg(feature = "std")]
40impl std::error::Error for NamespaceError {}
41
42fn validate_namespace(value: &str) -> Result<(), NamespaceError> {
43 const MAX_LEN: usize = 256;
44 if value.is_empty() {
45 return Err(NamespaceError::Empty);
46 }
47 if value.len() > MAX_LEN {
48 return Err(NamespaceError::TooLong { max: MAX_LEN });
49 }
50 if value.ends_with(':') {
51 return Err(NamespaceError::TrailingSeparator);
52 }
53 for segment in value.split(':') {
54 if segment.is_empty() {
55 return Err(NamespaceError::EmptySegment);
56 }
57 for ch in segment.chars() {
58 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' && ch != '.' {
59 return Err(NamespaceError::InvalidCharacter { ch });
60 }
61 }
62 }
63 Ok(())
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
71pub struct Namespace(String);
72
73impl Namespace {
74 pub const LOCAL: &'static str = "local";
76
77 pub fn parse(value: &str) -> Result<Self, NamespaceError> {
82 validate_namespace(value)?;
83 Ok(Self(String::from(value)))
84 }
85
86 pub fn local() -> Self {
88 Self(String::from(Self::LOCAL))
89 }
90
91 #[inline]
92 pub fn as_str(&self) -> &str {
93 &self.0
94 }
95
96 pub fn into_inner(self) -> String {
97 self.0
98 }
99}
100
101impl core::convert::TryFrom<String> for Namespace {
102 type Error = NamespaceError;
103
104 fn try_from(value: String) -> Result<Self, Self::Error> {
105 Self::parse(&value)
106 }
107}
108
109impl core::convert::TryFrom<&str> for Namespace {
110 type Error = NamespaceError;
111
112 fn try_from(value: &str) -> Result<Self, Self::Error> {
113 Self::parse(value)
114 }
115}
116
117impl fmt::Display for Namespace {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 f.write_str(&self.0)
120 }
121}
122
123impl AsRef<str> for Namespace {
124 #[inline]
125 fn as_ref(&self) -> &str {
126 &self.0
127 }
128}
129
130pub fn has_segment_prefix(child: &Namespace, parent: &Namespace) -> bool {
134 let c = child.as_str();
135 let p = parent.as_str();
136 c.len() > p.len() && c.starts_with(p) && c.as_bytes().get(p.len()) == Some(&b':')
137}
138
139#[cfg(feature = "serde")]
140mod serde_impl {
141 use super::*;
142 use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
143
144 impl Serialize for Namespace {
145 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
146 s.serialize_str(&self.0)
147 }
148 }
149
150 impl<'de> Deserialize<'de> for Namespace {
151 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
152 let s = String::deserialize(d)?;
153 Namespace::parse(&s).map_err(de::Error::custom)
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn parse_valid_namespace() {
164 let ns = Namespace::parse("research").unwrap();
165 assert_eq!(ns.as_str(), "research");
166 }
167
168 #[test]
169 fn local_is_local() {
170 assert_eq!(Namespace::local().as_str(), "local");
171 }
172
173 #[test]
174 fn parse_hierarchical_namespace() {
175 let ns = Namespace::parse("research:lattice").unwrap();
176 assert_eq!(ns.as_str(), "research:lattice");
177 }
178
179 #[test]
180 fn parse_empty_returns_error() {
181 assert_eq!(Namespace::parse(""), Err(NamespaceError::Empty));
182 }
183
184 #[test]
185 fn parse_trailing_separator_returns_error() {
186 assert_eq!(
187 Namespace::parse("research:"),
188 Err(NamespaceError::TrailingSeparator)
189 );
190 }
191
192 #[test]
193 fn parse_double_colon_returns_empty_segment() {
194 assert_eq!(Namespace::parse("a::b"), Err(NamespaceError::EmptySegment));
195 }
196
197 #[test]
198 fn parse_invalid_char_returns_error() {
199 assert!(matches!(
200 Namespace::parse("bad namespace"),
201 Err(NamespaceError::InvalidCharacter { ch: ' ' })
202 ));
203 }
204
205 #[test]
206 fn try_from_string() {
207 use core::convert::TryFrom;
208 let ns = Namespace::try_from(String::from("my-ns")).unwrap();
209 assert_eq!(ns.as_str(), "my-ns");
210 }
211
212 #[test]
213 fn has_segment_prefix_detects_child() {
214 let parent = Namespace::parse("research").unwrap();
215 let child = Namespace::parse("research:lattice").unwrap();
216 let sibling = Namespace::parse("other").unwrap();
217
218 assert!(has_segment_prefix(&child, &parent));
219 assert!(!has_segment_prefix(&sibling, &parent));
220 assert!(!has_segment_prefix(&parent, &parent));
221 }
222
223 #[cfg(feature = "serde")]
224 #[test]
225 fn serde_roundtrip() {
226 let ns = Namespace::parse("proj-123").unwrap();
227 let json = serde_json::to_string(&ns).unwrap();
228 let back: Namespace = serde_json::from_str(&json).unwrap();
229 assert_eq!(ns, back);
230 }
231
232 #[cfg(feature = "serde")]
233 #[test]
234 fn serde_deserialize_rejects_invalid() {
235 let result: Result<Namespace, _> = serde_json::from_str("\"\"");
236 assert!(result.is_err());
237 }
238
239 #[test]
240 fn parse_slash_is_rejected() {
241 assert!(matches!(
243 Namespace::parse("tenant/sub"),
244 Err(NamespaceError::InvalidCharacter { ch: '/' })
245 ));
246 }
247
248 #[test]
249 fn parse_unicode_is_rejected() {
250 assert!(matches!(
252 Namespace::parse("café"),
253 Err(NamespaceError::InvalidCharacter { .. })
254 ));
255 }
256
257 #[test]
258 fn parse_dot_is_valid() {
259 let ns = Namespace::parse("v1.5").unwrap();
261 assert_eq!(ns.as_str(), "v1.5");
262 }
263
264 #[test]
265 fn parse_too_long_is_rejected() {
266 let long = "a".repeat(257);
267 assert!(matches!(
268 Namespace::parse(&long),
269 Err(NamespaceError::TooLong { .. })
270 ));
271 }
272
273 #[test]
274 fn parse_exactly_256_chars_is_valid() {
275 let max = "a".repeat(256);
276 assert!(Namespace::parse(&max).is_ok());
277 }
278}