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