entelix_memory/
namespace.rs1use entelix_core::{Error, Result, TenantId};
15use serde::{Deserialize, Serialize};
16
17#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
23pub struct Namespace {
24 tenant_id: TenantId,
25 scope: Vec<String>,
26}
27
28impl Namespace {
29 pub const fn new(tenant_id: TenantId) -> Self {
36 Self {
37 tenant_id,
38 scope: Vec::new(),
39 }
40 }
41
42 #[must_use]
44 pub fn with_scope(mut self, segment: impl Into<String>) -> Self {
45 self.scope.push(segment.into());
46 self
47 }
48
49 pub const fn tenant_id(&self) -> &TenantId {
51 &self.tenant_id
52 }
53
54 pub fn scope(&self) -> &[String] {
56 &self.scope
57 }
58
59 pub fn render(&self) -> String {
64 let tenant_id = self.tenant_id.as_str();
65 let mut out = String::with_capacity(
66 tenant_id.len() + self.scope.iter().map(|s| s.len() + 1).sum::<usize>(),
67 );
68 push_escaped(&mut out, tenant_id);
69 for s in &self.scope {
70 out.push(':');
71 push_escaped(&mut out, s);
72 }
73 out
74 }
75
76 pub fn parse(rendered: &str) -> Result<Self> {
101 let mut segments: Vec<String> = Vec::new();
102 let mut current = String::with_capacity(rendered.len());
103 let mut chars = rendered.chars();
104 while let Some(ch) = chars.next() {
105 match ch {
106 ':' => {
107 segments.push(std::mem::take(&mut current));
108 }
109 '\\' => match chars.next() {
110 Some(escaped @ (':' | '\\')) => current.push(escaped),
111 Some(other) => {
112 return Err(Error::invalid_request(format!(
113 "Namespace::parse: unknown escape \\{other}"
114 )));
115 }
116 None => {
117 return Err(Error::invalid_request(
118 "Namespace::parse: trailing backslash",
119 ));
120 }
121 },
122 other => current.push(other),
123 }
124 }
125 segments.push(current);
126 let tenant_id = TenantId::try_from(segments.remove(0))?;
133 Ok(Self {
134 tenant_id,
135 scope: segments,
136 })
137 }
138}
139
140fn push_escaped(out: &mut String, segment: &str) {
141 if !segment.contains([':', '\\']) {
142 out.push_str(segment);
143 return;
144 }
145 for ch in segment.chars() {
146 match ch {
147 ':' | '\\' => {
148 out.push('\\');
149 out.push(ch);
150 }
151 other => out.push(other),
152 }
153 }
154}
155
156#[derive(Clone, Debug, Eq, Hash, PartialEq)]
165pub struct NamespacePrefix {
166 tenant_id: TenantId,
167 scope: Vec<String>,
168}
169
170impl NamespacePrefix {
171 #[must_use]
177 pub const fn new(tenant_id: TenantId) -> Self {
178 Self {
179 tenant_id,
180 scope: Vec::new(),
181 }
182 }
183
184 #[must_use]
186 pub fn with_scope(mut self, segment: impl Into<String>) -> Self {
187 self.scope.push(segment.into());
188 self
189 }
190
191 #[must_use]
193 pub const fn tenant_id(&self) -> &TenantId {
194 &self.tenant_id
195 }
196
197 #[must_use]
199 pub fn scope(&self) -> &[String] {
200 &self.scope
201 }
202
203 #[must_use]
205 pub fn matches(&self, ns: &Namespace) -> bool {
206 ns.tenant_id() == &self.tenant_id && ns.scope().starts_with(&self.scope)
207 }
208}
209
210impl From<&Namespace> for NamespacePrefix {
211 fn from(ns: &Namespace) -> Self {
212 Self {
213 tenant_id: ns.tenant_id().clone(),
214 scope: ns.scope().to_vec(),
215 }
216 }
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used)]
221mod tests {
222 use super::*;
223
224 fn t(s: &str) -> TenantId {
225 TenantId::new(s)
226 }
227
228 #[test]
229 fn prefix_matches_subnamespace_and_rejects_other_tenant() {
230 let parent = NamespacePrefix::new(t("acme")).with_scope("agent-a");
231 assert!(parent.matches(&Namespace::new(t("acme")).with_scope("agent-a")));
232 assert!(
233 parent.matches(
234 &Namespace::new(t("acme"))
235 .with_scope("agent-a")
236 .with_scope("conv-7")
237 )
238 );
239 assert!(!parent.matches(&Namespace::new(t("acme")).with_scope("agent-b")));
240 assert!(!parent.matches(&Namespace::new(t("other-tenant")).with_scope("agent-a")));
241 }
242
243 #[test]
244 fn prefix_with_empty_scope_matches_every_namespace_under_tenant() {
245 let p = NamespacePrefix::new(t("acme"));
246 assert!(p.matches(&Namespace::new(t("acme"))));
247 assert!(p.matches(&Namespace::new(t("acme")).with_scope("any")));
248 assert!(!p.matches(&Namespace::new(t("other"))));
249 }
250
251 #[test]
252 fn from_namespace_round_trips() {
253 let ns = Namespace::new(t("acme"))
254 .with_scope("agent-a")
255 .with_scope("conv-1");
256 let prefix = NamespacePrefix::from(&ns);
257 assert_eq!(prefix.tenant_id().as_str(), "acme");
258 assert_eq!(prefix.scope(), &["agent-a".to_owned(), "conv-1".to_owned()]);
259 assert!(prefix.matches(&ns));
260 }
261
262 fn round_trip(ns: &Namespace) {
263 let rendered = ns.render();
264 let parsed = Namespace::parse(&rendered).unwrap();
265 assert_eq!(&parsed, ns, "round-trip failed for {rendered:?}");
266 }
267
268 #[test]
269 fn parse_round_trips_simple_namespace() {
270 round_trip(&Namespace::new(t("acme")));
271 round_trip(&Namespace::new(t("acme")).with_scope("agent-a"));
272 round_trip(
273 &Namespace::new(t("acme"))
274 .with_scope("agent-a")
275 .with_scope("conv-1"),
276 );
277 }
278
279 #[test]
280 fn parse_round_trips_empty_scope_segments() {
281 round_trip(&Namespace::new(t("acme")).with_scope(""));
286 round_trip(&Namespace::new(t("acme")).with_scope("a").with_scope(""));
287 }
288
289 #[test]
290 fn parse_round_trips_segments_with_colon() {
291 round_trip(&Namespace::new(t("a:b")).with_scope("c:d"));
292 round_trip(&Namespace::new(t("acme")).with_scope("k8s:pod:foo"));
293 }
294
295 #[test]
296 fn parse_round_trips_segments_with_backslash() {
297 round_trip(&Namespace::new(t("a\\b")).with_scope("c\\d"));
298 round_trip(&Namespace::new(t("acme")).with_scope("\\\\\\:"));
299 }
300
301 #[test]
302 fn parse_extracts_tenant_and_scope_from_simple_input() {
303 let ns = Namespace::parse("acme:agent-a:conv-1").unwrap();
304 assert_eq!(ns.tenant_id().as_str(), "acme");
305 assert_eq!(ns.scope(), &["agent-a".to_owned(), "conv-1".to_owned()]);
306 }
307
308 #[test]
309 fn parse_decodes_escapes() {
310 let ns = Namespace::parse("a\\:b:c\\\\d").unwrap();
311 assert_eq!(ns.tenant_id().as_str(), "a:b");
312 assert_eq!(ns.scope(), &["c\\d".to_owned()]);
313 }
314
315 #[test]
316 fn parse_rejects_trailing_backslash() {
317 let err = Namespace::parse("acme\\").unwrap_err();
318 assert!(format!("{err}").contains("trailing backslash"));
319 }
320
321 #[test]
322 fn parse_rejects_unknown_escape() {
323 let err = Namespace::parse("acme\\x").unwrap_err();
324 let msg = format!("{err}");
325 assert!(msg.contains("unknown escape"), "got {msg}");
326 }
327
328 #[test]
329 fn parse_rejects_leading_colon_for_empty_tenant() {
330 let err = Namespace::parse(":scope").unwrap_err();
335 let msg = format!("{err}");
336 assert!(matches!(err, Error::InvalidRequest(_)), "got {err:?}");
337 assert!(msg.contains("tenant_id must be non-empty"), "got {msg}");
338 }
339
340 #[test]
341 fn parse_rejects_empty_string_for_empty_tenant() {
342 let err = Namespace::parse("").unwrap_err();
346 assert!(matches!(err, Error::InvalidRequest(_)), "got {err:?}");
347 }
348
349 #[test]
350 fn deserialize_rejects_empty_tenant_in_wire_payload() {
351 let err = serde_json::from_str::<Namespace>(r#"{"tenant_id":"","scope":["agent-a"]}"#)
355 .unwrap_err();
356 assert!(
357 err.to_string().contains("tenant_id must be non-empty"),
358 "got {err}"
359 );
360 }
361}