Skip to main content

eventuary_core/
namespace.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{Error, Result};
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(try_from = "String", into = "String")]
10pub struct Namespace(String);
11
12impl Namespace {
13    pub fn new(s: impl Into<String>) -> Result<Self> {
14        let s = s.into();
15        let normalized = if s.is_empty() || s == "/" {
16            "/".to_owned()
17        } else if s.starts_with('/') {
18            s
19        } else {
20            format!("/{s}")
21        };
22        Self::validate(&normalized)?;
23        Ok(Self(normalized))
24    }
25
26    pub fn root() -> Self {
27        Self("/".to_owned())
28    }
29
30    pub fn is_root(&self) -> bool {
31        self.0 == "/"
32    }
33
34    pub fn with_scope(&self, scope: &str) -> Result<Namespace> {
35        if self.is_root() {
36            Namespace::new(format!("/{scope}"))
37        } else {
38            Namespace::new(format!("{}/{scope}", self.0))
39        }
40    }
41
42    pub fn parent(&self) -> Namespace {
43        if self.is_root() {
44            return self.clone();
45        }
46        match self.0.rfind('/') {
47            Some(0) => Namespace::root(),
48            Some(pos) => Namespace(self.0[..pos].to_string()),
49            None => Namespace::root(),
50        }
51    }
52
53    pub fn starts_with(&self, prefix: &Namespace) -> bool {
54        if prefix.is_root() {
55            return true;
56        }
57        if self.0 == prefix.0 {
58            return true;
59        }
60        self.0.starts_with(&format!("{}/", prefix.0))
61    }
62
63    pub fn depth(&self) -> usize {
64        if self.is_root() {
65            return 0;
66        }
67        self.0[1..].split('/').count()
68    }
69
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73
74    fn validate(s: &str) -> Result<()> {
75        if s == "/" {
76            return Ok(());
77        }
78        if !s.starts_with('/') {
79            return Err(Error::InvalidNamespace("must start with '/'".into()));
80        }
81        for part in s[1..].split('/') {
82            if part.is_empty() {
83                return Err(Error::InvalidNamespace(
84                    "parts must not be empty (check for trailing or double slashes)".into(),
85                ));
86            }
87            for ch in part.chars() {
88                if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
89                    return Err(Error::InvalidNamespace(format!(
90                        "invalid character '{ch}' in part '{part}'"
91                    )));
92                }
93            }
94        }
95        Ok(())
96    }
97}
98
99impl TryFrom<String> for Namespace {
100    type Error = Error;
101
102    fn try_from(s: String) -> Result<Self> {
103        Self::new(s)
104    }
105}
106
107impl TryFrom<&str> for Namespace {
108    type Error = Error;
109
110    fn try_from(s: &str) -> Result<Self> {
111        Self::new(s)
112    }
113}
114
115impl From<Namespace> for String {
116    fn from(n: Namespace) -> Self {
117        n.0
118    }
119}
120
121impl fmt::Display for Namespace {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        write!(f, "{}", self.0)
124    }
125}
126
127impl AsRef<str> for Namespace {
128    fn as_ref(&self) -> &str {
129        &self.0
130    }
131}
132
133impl FromStr for Namespace {
134    type Err = Error;
135
136    fn from_str(s: &str) -> Result<Self> {
137        Self::new(s)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn root_namespace() {
147        let ns = Namespace::root();
148        assert_eq!(ns.as_ref(), "/");
149        assert!(ns.is_root());
150        assert_eq!(ns.depth(), 0);
151    }
152
153    #[test]
154    fn valid_namespace() {
155        let ns = Namespace::new("/backend").unwrap();
156        assert_eq!(ns.as_ref(), "/backend");
157        assert!(!ns.is_root());
158        assert_eq!(ns.depth(), 1);
159    }
160
161    #[test]
162    fn valid_nested_namespace() {
163        let ns = Namespace::new("/backend/auth").unwrap();
164        assert_eq!(ns.as_ref(), "/backend/auth");
165        assert_eq!(ns.depth(), 2);
166    }
167
168    #[test]
169    fn namespace_auto_prepends_slash() {
170        let ns = Namespace::new("backend").unwrap();
171        assert_eq!(ns.as_ref(), "/backend");
172    }
173
174    #[test]
175    fn empty_namespace_is_root() {
176        let ns = Namespace::new("").unwrap();
177        assert_eq!(ns.as_ref(), "/");
178        assert!(ns.is_root());
179    }
180
181    #[test]
182    fn no_double_slashes() {
183        assert!(Namespace::new("//backend").is_err());
184        assert!(Namespace::new("/backend//auth").is_err());
185    }
186
187    #[test]
188    fn no_trailing_slash() {
189        assert!(Namespace::new("/backend/").is_err());
190    }
191
192    #[test]
193    fn starts_with_root_matches_all() {
194        let root = Namespace::root();
195        let child = Namespace::new("/backend").unwrap();
196        let deep = Namespace::new("/backend/auth").unwrap();
197        assert!(child.starts_with(&root));
198        assert!(deep.starts_with(&root));
199        assert!(root.starts_with(&root));
200    }
201
202    #[test]
203    fn starts_with_hierarchy() {
204        let parent = Namespace::new("/backend").unwrap();
205        let child = Namespace::new("/backend/auth").unwrap();
206        let sibling = Namespace::new("/frontend").unwrap();
207
208        assert!(child.starts_with(&parent));
209        assert!(!parent.starts_with(&child));
210        assert!(!sibling.starts_with(&parent));
211    }
212
213    #[test]
214    fn with_scope_from_root() {
215        let root = Namespace::root();
216        let child = root.with_scope("backend").unwrap();
217        assert_eq!(child.as_ref(), "/backend");
218    }
219
220    #[test]
221    fn with_scope_from_nested() {
222        let ns = Namespace::new("/backend").unwrap();
223        let child = ns.with_scope("auth").unwrap();
224        assert_eq!(child.as_ref(), "/backend/auth");
225    }
226
227    #[test]
228    fn parent_of_root_is_root() {
229        assert_eq!(Namespace::root().parent(), Namespace::root());
230    }
231
232    #[test]
233    fn parent_of_child_is_root() {
234        let ns = Namespace::new("/backend").unwrap();
235        assert_eq!(ns.parent(), Namespace::root());
236    }
237
238    #[test]
239    fn parent_of_nested() {
240        let ns = Namespace::new("/backend/auth").unwrap();
241        assert_eq!(ns.parent().as_ref(), "/backend");
242    }
243}