eventuary_core/
namespace.rs1use 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}