use entelix_core::{Error, Result, TenantId};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
pub struct Namespace {
tenant_id: TenantId,
scope: Vec<String>,
}
impl Namespace {
pub const fn new(tenant_id: TenantId) -> Self {
Self {
tenant_id,
scope: Vec::new(),
}
}
#[must_use]
pub fn with_scope(mut self, segment: impl Into<String>) -> Self {
self.scope.push(segment.into());
self
}
pub const fn tenant_id(&self) -> &TenantId {
&self.tenant_id
}
pub fn scope(&self) -> &[String] {
&self.scope
}
pub fn render(&self) -> String {
let tenant_id = self.tenant_id.as_str();
let mut out = String::with_capacity(
tenant_id.len() + self.scope.iter().map(|s| s.len() + 1).sum::<usize>(),
);
push_escaped(&mut out, tenant_id);
for s in &self.scope {
out.push(':');
push_escaped(&mut out, s);
}
out
}
pub fn parse(rendered: &str) -> Result<Self> {
let mut segments: Vec<String> = Vec::new();
let mut current = String::with_capacity(rendered.len());
let mut chars = rendered.chars();
while let Some(ch) = chars.next() {
match ch {
':' => {
segments.push(std::mem::take(&mut current));
}
'\\' => match chars.next() {
Some(escaped @ (':' | '\\')) => current.push(escaped),
Some(other) => {
return Err(Error::invalid_request(format!(
"Namespace::parse: unknown escape \\{other}"
)));
}
None => {
return Err(Error::invalid_request(
"Namespace::parse: trailing backslash",
));
}
},
other => current.push(other),
}
}
segments.push(current);
let tenant_id = TenantId::try_from(segments.remove(0))?;
Ok(Self {
tenant_id,
scope: segments,
})
}
}
fn push_escaped(out: &mut String, segment: &str) {
if !segment.contains([':', '\\']) {
out.push_str(segment);
return;
}
for ch in segment.chars() {
match ch {
':' | '\\' => {
out.push('\\');
out.push(ch);
}
other => out.push(other),
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct NamespacePrefix {
tenant_id: TenantId,
scope: Vec<String>,
}
impl NamespacePrefix {
#[must_use]
pub const fn new(tenant_id: TenantId) -> Self {
Self {
tenant_id,
scope: Vec::new(),
}
}
#[must_use]
pub fn with_scope(mut self, segment: impl Into<String>) -> Self {
self.scope.push(segment.into());
self
}
#[must_use]
pub const fn tenant_id(&self) -> &TenantId {
&self.tenant_id
}
#[must_use]
pub fn scope(&self) -> &[String] {
&self.scope
}
#[must_use]
pub fn matches(&self, ns: &Namespace) -> bool {
ns.tenant_id() == &self.tenant_id && ns.scope().starts_with(&self.scope)
}
}
impl From<&Namespace> for NamespacePrefix {
fn from(ns: &Namespace) -> Self {
Self {
tenant_id: ns.tenant_id().clone(),
scope: ns.scope().to_vec(),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn t(s: &str) -> TenantId {
TenantId::new(s)
}
#[test]
fn prefix_matches_subnamespace_and_rejects_other_tenant() {
let parent = NamespacePrefix::new(t("acme")).with_scope("agent-a");
assert!(parent.matches(&Namespace::new(t("acme")).with_scope("agent-a")));
assert!(
parent.matches(
&Namespace::new(t("acme"))
.with_scope("agent-a")
.with_scope("conv-7")
)
);
assert!(!parent.matches(&Namespace::new(t("acme")).with_scope("agent-b")));
assert!(!parent.matches(&Namespace::new(t("other-tenant")).with_scope("agent-a")));
}
#[test]
fn prefix_with_empty_scope_matches_every_namespace_under_tenant() {
let p = NamespacePrefix::new(t("acme"));
assert!(p.matches(&Namespace::new(t("acme"))));
assert!(p.matches(&Namespace::new(t("acme")).with_scope("any")));
assert!(!p.matches(&Namespace::new(t("other"))));
}
#[test]
fn from_namespace_round_trips() {
let ns = Namespace::new(t("acme"))
.with_scope("agent-a")
.with_scope("conv-1");
let prefix = NamespacePrefix::from(&ns);
assert_eq!(prefix.tenant_id().as_str(), "acme");
assert_eq!(prefix.scope(), &["agent-a".to_owned(), "conv-1".to_owned()]);
assert!(prefix.matches(&ns));
}
fn round_trip(ns: &Namespace) {
let rendered = ns.render();
let parsed = Namespace::parse(&rendered).unwrap();
assert_eq!(&parsed, ns, "round-trip failed for {rendered:?}");
}
#[test]
fn parse_round_trips_simple_namespace() {
round_trip(&Namespace::new(t("acme")));
round_trip(&Namespace::new(t("acme")).with_scope("agent-a"));
round_trip(
&Namespace::new(t("acme"))
.with_scope("agent-a")
.with_scope("conv-1"),
);
}
#[test]
fn parse_round_trips_empty_scope_segments() {
round_trip(&Namespace::new(t("acme")).with_scope(""));
round_trip(&Namespace::new(t("acme")).with_scope("a").with_scope(""));
}
#[test]
fn parse_round_trips_segments_with_colon() {
round_trip(&Namespace::new(t("a:b")).with_scope("c:d"));
round_trip(&Namespace::new(t("acme")).with_scope("k8s:pod:foo"));
}
#[test]
fn parse_round_trips_segments_with_backslash() {
round_trip(&Namespace::new(t("a\\b")).with_scope("c\\d"));
round_trip(&Namespace::new(t("acme")).with_scope("\\\\\\:"));
}
#[test]
fn parse_extracts_tenant_and_scope_from_simple_input() {
let ns = Namespace::parse("acme:agent-a:conv-1").unwrap();
assert_eq!(ns.tenant_id().as_str(), "acme");
assert_eq!(ns.scope(), &["agent-a".to_owned(), "conv-1".to_owned()]);
}
#[test]
fn parse_decodes_escapes() {
let ns = Namespace::parse("a\\:b:c\\\\d").unwrap();
assert_eq!(ns.tenant_id().as_str(), "a:b");
assert_eq!(ns.scope(), &["c\\d".to_owned()]);
}
#[test]
fn parse_rejects_trailing_backslash() {
let err = Namespace::parse("acme\\").unwrap_err();
assert!(format!("{err}").contains("trailing backslash"));
}
#[test]
fn parse_rejects_unknown_escape() {
let err = Namespace::parse("acme\\x").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("unknown escape"), "got {msg}");
}
#[test]
fn parse_rejects_leading_colon_for_empty_tenant() {
let err = Namespace::parse(":scope").unwrap_err();
let msg = format!("{err}");
assert!(matches!(err, Error::InvalidRequest(_)), "got {err:?}");
assert!(msg.contains("tenant_id must be non-empty"), "got {msg}");
}
#[test]
fn parse_rejects_empty_string_for_empty_tenant() {
let err = Namespace::parse("").unwrap_err();
assert!(matches!(err, Error::InvalidRequest(_)), "got {err:?}");
}
#[test]
fn deserialize_rejects_empty_tenant_in_wire_payload() {
let err = serde_json::from_str::<Namespace>(r#"{"tenant_id":"","scope":["agent-a"]}"#)
.unwrap_err();
assert!(
err.to_string().contains("tenant_id must be non-empty"),
"got {err}"
);
}
}