use crate::error::{AreevError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NsScope {
Exact(String),
Prefix {
base: String,
sep: char,
},
}
impl NsScope {
pub fn parse(value: &str) -> Result<NsScope> {
if !value.contains('*') {
return Ok(NsScope::Exact(value.to_string()));
}
let Some(head) = value.strip_suffix('*') else {
return Err(AreevError::Validation(format!(
"namespace pattern \"{value}\": '*' is only valid as the trailing character \
(e.g. \"org.*\")"
)));
};
if head.contains('*') {
return Err(AreevError::Validation(format!(
"namespace pattern \"{value}\": only one '*' is allowed, as the trailing \
character (e.g. \"org.*\")"
)));
}
let Some(sep) = head.chars().last() else {
return Err(AreevError::Validation(
"namespace pattern \"*\": a prefix scope needs a base namespace \
(e.g. \"org.*\"); \"every namespace\" is not a recall scope"
.into(),
));
};
if sep.is_alphanumeric() {
return Err(AreevError::Validation(format!(
"namespace pattern \"{value}\": '*' must follow a separator — write \
\"{head}.*\" to select \"{head}\" and its descendants ({head}.x, {head}.y.z); \
\"{value}\" would ambiguously match unrelated names sharing the spelling"
)));
}
let base: String = head[..head.len() - sep.len_utf8()].to_string();
if base.is_empty() {
return Err(AreevError::Validation(format!(
"namespace pattern \"{value}\": a prefix scope needs a base namespace before \
the separator (e.g. \"org{sep}*\")"
)));
}
Ok(NsScope::Prefix { base, sep })
}
#[inline]
pub fn is_pattern(value: &str) -> bool {
value.contains('*')
}
pub fn matches(&self, ns: &str) -> bool {
match self {
NsScope::Exact(e) => ns == e,
NsScope::Prefix { base, sep } => {
ns == base
|| (ns.len() > base.len()
&& ns.starts_with(base.as_str())
&& ns[base.len()..].starts_with(*sep))
}
}
}
}
const UNSPELLABLE: [char; 7] = [
'\u{200b}', '\u{200c}', '\u{200d}', '\u{200e}', '\u{200f}', '\u{00ad}', '\u{feff}', ];
pub fn require_writable_ns(ns: &str) -> Result<()> {
require_exact_ns("a grain write", ns)?;
let bad = ns
.char_indices()
.find(|(_, c)| c.is_whitespace() || c.is_control() || UNSPELLABLE.contains(c));
if let Some((at, c)) = bad {
return Err(AreevError::Validation(format!(
"a grain write takes a spellable namespace (got \"{}\": U+{:04X} at byte {at}): \
a namespace is an identifier, and whitespace or an invisible character in one is \
a splice, a quoting accident or a bad paste. It would be accepted everywhere and \
found nowhere — grains written under it are invisible to every reader that names \
the namespace you meant",
ns.escape_debug(),
c as u32
)));
}
Ok(())
}
pub fn require_exact_ns(what: &str, ns: &str) -> Result<()> {
if NsScope::is_pattern(ns) {
return Err(AreevError::Validation(format!(
"{what} takes an exact namespace, not a pattern (got \"{ns}\"): '*' is reserved \
for read scoping (e.g. RECALL … WHERE namespace = \"org.*\")"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_when_no_star() {
assert_eq!(NsScope::parse("org").unwrap(), NsScope::Exact("org".into()));
assert_eq!(
NsScope::parse("org.sales").unwrap(),
NsScope::Exact("org.sales".into())
);
assert_eq!(NsScope::parse("").unwrap(), NsScope::Exact("".into()));
}
#[test]
fn prefix_forms_parse() {
assert_eq!(
NsScope::parse("org.*").unwrap(),
NsScope::Prefix { base: "org".into(), sep: '.' }
);
assert_eq!(
NsScope::parse("agent:*").unwrap(),
NsScope::Prefix { base: "agent".into(), sep: ':' }
);
assert_eq!(
NsScope::parse("org.sales.*").unwrap(),
NsScope::Prefix { base: "org.sales".into(), sep: '.' }
);
assert_eq!(
NsScope::parse("areev-*").unwrap(),
NsScope::Prefix { base: "areev".into(), sep: '-' }
);
}
#[test]
fn malformed_patterns_refuse() {
for bad in ["*", "org*", "*.org", "org.*x", "o*.sales.*", "**", "org.**"] {
let err = NsScope::parse(bad).unwrap_err();
assert!(
matches!(err, AreevError::Validation(_)),
"{bad} should be a validation error, got {err:?}"
);
}
}
#[test]
fn separator_only_pattern_needs_a_base() {
assert!(NsScope::parse(".*").is_err());
assert!(NsScope::parse(":*").is_err());
}
#[test]
fn matches_parent_and_descendants_only() {
let s = NsScope::parse("org.*").unwrap();
assert!(s.matches("org"), "parent is included");
assert!(s.matches("org.sales"));
assert!(s.matches("org.sales.emea"));
assert!(!s.matches("organization"), "separator required");
assert!(!s.matches("org:x"), "the caller chose '.' as the hierarchy");
assert!(!s.matches("orgs"));
assert!(!s.matches(""));
assert!(!s.matches("xorg.sales"));
}
#[test]
fn matches_with_colon_separator() {
let s = NsScope::parse("agent:*").unwrap();
assert!(s.matches("agent"));
assert!(s.matches("agent:authz"));
assert!(!s.matches("agent.authz"));
assert!(!s.matches("agents"));
}
#[test]
fn unicode_separator_boundary_is_char_correct() {
let s = NsScope::parse("org→*").unwrap();
assert_eq!(s, NsScope::Prefix { base: "org".into(), sep: '→' });
assert!(s.matches("org"));
assert!(s.matches("org→x"));
assert!(!s.matches("org.x"));
}
#[test]
fn exact_matches_exactly() {
let s = NsScope::parse("org").unwrap();
assert!(s.matches("org"));
assert!(!s.matches("org.sales"));
assert!(!s.matches("or"));
}
#[test]
fn writable_ns_accepts_the_names_hosts_actually_use() {
for ok in [
"",
"caller",
"agent:harness",
"org.sales.emea",
"claude-code",
"deal.energy.42",
"retention:org.sales",
"部門:営業",
"org→x", ] {
assert!(require_writable_ns(ok).is_ok(), "{ok:?} should be writable");
}
}
#[test]
fn writable_ns_refuses_the_unspellable() {
let err = require_writable_ns("age, build_messagesnt:harness").unwrap_err();
assert!(err.to_string().starts_with("VAL-E001"), "{err}");
assert!(err.to_string().contains("U+0020"), "names the character: {err}");
for bad in [
"agent harness", "agent\tharness", "agent\nharness", " caller", "caller ", " ", "agent\u{200b}harness", "agent\u{feff}harness", "agent\u{00ad}harness", "agent\u{0007}harness", ] {
let err = require_writable_ns(bad).unwrap_err();
assert!(
matches!(err, AreevError::Validation(_)),
"{bad:?} should be a validation error, got {err:?}"
);
}
}
#[test]
fn writable_ns_still_refuses_the_reserved_star() {
assert!(require_writable_ns("org.*").is_err());
assert!(require_writable_ns("o*rg").is_err());
}
#[test]
fn writable_ns_error_does_not_leak_a_raw_control_character() {
let msg = require_writable_ns("a\nb\u{1b}[31m").unwrap_err().to_string();
assert!(!msg.contains('\n'), "no raw newline: {msg:?}");
assert!(!msg.contains('\u{1b}'), "no raw escape: {msg:?}");
assert!(msg.contains("\\n"), "escaped instead: {msg:?}");
}
#[test]
fn read_surfaces_still_accept_a_legacy_unspellable_name() {
assert!(require_exact_ns("forget_subject", "age, build_messagesnt:harness").is_ok());
assert!(require_exact_ns("subject_report", "agent harness").is_ok());
assert!(NsScope::parse("agent harness").is_ok());
}
#[test]
fn require_exact_refuses_patterns() {
assert!(require_exact_ns("latest", "org").is_ok());
let err = require_exact_ns("PURGE", "org.*").unwrap_err();
assert!(err.to_string().starts_with("VAL-E001"), "{err}");
assert!(require_exact_ns("forget_subject", "o*rg").is_err());
}
}