Skip to main content

khive_types/
namespace.rs

1//! Namespace — validated string token for scoping substrate records.
2
3extern crate alloc;
4use alloc::string::String;
5use core::fmt;
6
7/// Validation error returned when a namespace string is rejected.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum NamespaceError {
10    /// The input was empty.
11    Empty,
12    /// The input exceeded the maximum allowed length.
13    TooLong {
14        /// The maximum allowed number of bytes.
15        max: usize,
16    },
17    /// The input contained a character outside `[a-zA-Z0-9\-_.]`.
18    InvalidCharacter {
19        /// The offending character.
20        ch: char,
21    },
22    /// A `::`-separated segment was empty.
23    EmptySegment,
24    /// The input ended with a `:` separator.
25    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/// A validated, opaque namespace identifier.
70///
71/// Construct via [`Namespace::parse`] or [`Namespace::local`]. The absence of
72/// `From<String>` / `From<&str>` impls is intentional — callers must validate.
73#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
74pub struct Namespace(String);
75
76impl Namespace {
77    /// The name of the default local namespace.
78    pub const LOCAL: &'static str = "local";
79
80    /// Parse and validate a namespace string.
81    ///
82    /// Returns `Err(NamespaceError)` if the string is empty, too long, contains
83    /// invalid characters, has empty segments, or ends with `:`.
84    pub fn parse(value: &str) -> Result<Self, NamespaceError> {
85        validate_namespace(value)?;
86        Ok(Self(String::from(value)))
87    }
88
89    /// Construct the default `"local"` namespace (always valid; no allocation).
90    pub fn local() -> Self {
91        Self(String::from(Self::LOCAL))
92    }
93
94    /// Return the namespace as a string slice.
95    #[inline]
96    pub fn as_str(&self) -> &str {
97        &self.0
98    }
99
100    /// Consume the `Namespace` and return the underlying owned string.
101    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
135/// Returns `true` if `child` is a hierarchical prefix-descendant of `parent`.
136///
137/// Example: `"research:lattice"` is a prefix-child of `"research"`.
138pub 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        // Forward slashes are not in the allowed charset (alphanumeric, `-`, `_`, `.`).
247        assert!(matches!(
248            Namespace::parse("tenant/sub"),
249            Err(NamespaceError::InvalidCharacter { ch: '/' })
250        ));
251    }
252
253    #[test]
254    fn parse_unicode_is_rejected() {
255        // Only ASCII characters are allowed; non-ASCII (e.g. accented letters) must fail.
256        assert!(matches!(
257            Namespace::parse("café"),
258            Err(NamespaceError::InvalidCharacter { .. })
259        ));
260    }
261
262    #[test]
263    fn parse_dot_is_valid() {
264        // Dots are explicitly allowed to support version-style namespaces like "v1.5".
265        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}