use smol_str::SmolStr;
use thiserror::Error;
const VALID_SCOPE_CHARS: fn(char) -> bool =
|c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.');
const MAX_SCOPE_LEN: usize = 128;
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
#[serde(transparent)]
pub struct Scope(SmolStr);
impl<'de> serde::Deserialize<'de> for Scope {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = SmolStr::deserialize(d)?;
Scope::new(s.as_str()).map_err(serde::de::Error::custom)
}
}
impl Scope {
pub fn new(s: impl AsRef<str>) -> Result<Self, ScopeError> {
let s = s.as_ref();
if s.is_empty() || s.len() > MAX_SCOPE_LEN || !s.chars().all(VALID_SCOPE_CHARS) {
return Err(ScopeError::Invalid(s.to_string()));
}
Ok(Self(SmolStr::new(s)))
}
#[inline]
pub fn as_str(&self) -> &str {
self.0.as_str()
}
#[inline]
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
#[allow(dead_code)]
#[inline]
pub(crate) fn from_trusted(s: &str) -> Self {
Self(SmolStr::new(s))
}
#[doc(hidden)]
pub fn dev() -> Self {
Self(SmolStr::new("_dev_"))
}
#[inline]
pub fn is_valid_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
pub fn child(&self, segment: &str) -> Result<Scope, ScopeError> {
if !Self::is_valid_segment(segment) {
return Err(ScopeError::Invalid(segment.to_string()));
}
Scope::new(format!("{}.{segment}", self.0.as_str()))
}
}
impl std::fmt::Display for Scope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0.as_str())
}
}
impl AsRef<str> for Scope {
fn as_ref(&self) -> &str {
self.0.as_str()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MemoryLevel {
User,
Agent,
Session,
}
impl MemoryLevel {
#[inline]
pub fn tag(&self) -> &'static str {
match self {
MemoryLevel::User => "u",
MemoryLevel::Agent => "a",
MemoryLevel::Session => "s",
}
}
}
pub fn compose_levels(
base: &Scope,
user: Option<&str>,
agent: Option<&str>,
session: Option<&str>,
) -> Result<Scope, ScopeError> {
let mut scope = base.clone();
for (level, id) in
[(MemoryLevel::User, user), (MemoryLevel::Agent, agent), (MemoryLevel::Session, session)]
{
if let Some(id) = id {
scope = scope.child(&format!("{}-{id}", level.tag()))?;
}
}
Ok(scope)
}
#[derive(Debug, Error)]
pub enum ScopeError {
#[error("scope must be 1..=128 chars of [A-Za-z0-9_\\-.]; got {0:?}")]
Invalid(String),
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn valid_scope_roundtrip() {
let s = Scope::new("acme.agent-42").unwrap();
assert_eq!(s.as_str(), "acme.agent-42");
}
#[test]
fn single_char_is_accepted() {
assert!(Scope::new("a").is_ok());
assert!(Scope::new("Z").is_ok());
assert!(Scope::new("0").is_ok());
assert!(Scope::new("_").is_ok());
}
#[test]
fn max_length_scope_is_accepted() {
let at_limit = "a".repeat(128);
assert!(Scope::new(&at_limit).is_ok());
}
#[test]
fn all_regex_specials_individually_accepted() {
assert!(Scope::new("under_score").is_ok(), "underscore must be valid");
assert!(Scope::new("hy-phen").is_ok(), "hyphen must be valid");
assert!(Scope::new("do.t").is_ok(), "dot must be valid");
assert!(Scope::new("A0._.-").is_ok(), "all specials together must be valid");
}
#[test]
fn colon_is_rejected() {
assert!(Scope::new("co:lon").is_err(), "colon MUST be rejected post-v0.2.1");
assert!(
Scope::new("a:episode").is_err(),
"the SCAN-aliasing scope form `a:episode` MUST be rejected at the type level"
);
assert!(Scope::new("tenant:1").is_err());
assert!(Scope::new(":lead").is_err());
assert!(Scope::new("trail:").is_err());
}
#[test]
fn valid_chars_accepted() {
assert!(Scope::new("org.team_agent-1.v2").is_ok());
assert!(Scope::new("_dev_").is_ok());
}
#[test]
fn empty_scope_is_rejected() {
let err = Scope::new("").unwrap_err();
assert!(matches!(err, ScopeError::Invalid(ref s) if s.is_empty()));
}
#[test]
fn one_over_max_length_is_rejected() {
let too_long = "a".repeat(129);
let err = Scope::new(&too_long).unwrap_err();
assert!(matches!(err, ScopeError::Invalid(ref s) if s.len() == 129));
}
#[test]
fn invalid_chars_rejected() {
for bad in &[
"has space",
" leading",
"trailing ",
"\thas_tab",
"has/slash",
"has@at",
"has#hash",
"has!bang",
"has+plus",
"has=eq",
"has[bracket",
"has{brace",
"has\"quote",
"has\\backslash",
"has:colon",
] {
let err = Scope::new(*bad);
assert!(err.is_err(), "expected rejection for {:?} but got Ok", bad);
let ScopeError::Invalid(carried) = err.unwrap_err();
assert_eq!(&carried, bad, "ScopeError::Invalid must carry the exact bad input");
}
}
#[test]
fn whitespace_not_trimmed_or_silently_accepted() {
assert!(Scope::new(" acme").is_err());
assert!(Scope::new("acme ").is_err());
assert!(Scope::new(" ").is_err());
}
#[test]
fn dev_scope_is_valid() {
let s = Scope::dev();
assert_eq!(s.as_str(), "_dev_");
assert!(Scope::new("_dev_").is_ok());
}
#[test]
fn scope_serde_transparent() {
let s = Scope::new("tenant-1").unwrap();
let json = serde_json::to_string(&s).unwrap();
assert_eq!(json, r#""tenant-1""#);
let back: Scope = serde_json::from_str(&json).unwrap();
assert_eq!(back, s);
}
#[test]
fn serde_rejects_invalid_scope_string() {
let result: Result<Scope, _> = serde_json::from_str(r#""has space""#);
assert!(result.is_err(), "invalid scope must be rejected at deserialize");
let ok: Scope = serde_json::from_str(r#""acme.agent-1""#).unwrap();
assert_eq!(ok.as_str(), "acme.agent-1");
let colon: Result<Scope, _> = serde_json::from_str(r#""acme:agent-1""#);
assert!(colon.is_err(), "post-v0.2.1: colon must be rejected on the wire too");
let too_long = format!("\"{}\"", "a".repeat(129));
let bad: Result<Scope, _> = serde_json::from_str(&too_long);
assert!(bad.is_err(), "129-char scope must be rejected at deserialize");
}
#[test]
fn scope_equality_is_byte_exact() {
let a = Scope::new("Tenant").unwrap();
let b = Scope::new("tenant").unwrap();
assert_ne!(a, b);
}
#[test]
fn equal_scopes_have_equal_hashes() {
let a = Scope::new("acme.agent-1").unwrap();
let b = Scope::new("acme.agent-1").unwrap();
assert_eq!(a, b);
let mut set = HashSet::new();
set.insert(a);
assert!(set.contains(&b), "equal Scope must hash to the same bucket");
}
#[test]
fn distinct_scopes_are_not_equal() {
let a = Scope::new("acme.agent-1").unwrap();
let b = Scope::new("acme.agent-2").unwrap();
assert_ne!(a, b);
}
#[test]
fn scope_ord_is_lexicographic() {
let a = Scope::new("a").unwrap();
let b = Scope::new("b").unwrap();
assert!(a < b);
assert!(b > a);
assert_eq!(a.cmp(&a), std::cmp::Ordering::Equal);
}
#[test]
fn scope_sort_is_stable() {
let mut scopes: Vec<Scope> = vec![
Scope::new("z.agent").unwrap(),
Scope::new("a.agent").unwrap(),
Scope::new("m.agent").unwrap(),
];
scopes.sort();
assert_eq!(scopes[0].as_str(), "a.agent");
assert_eq!(scopes[1].as_str(), "m.agent");
assert_eq!(scopes[2].as_str(), "z.agent");
}
#[test]
fn display_matches_as_str() {
let s = Scope::new("org.team_agent-1.v2").unwrap();
assert_eq!(format!("{s}"), s.as_str());
}
#[test]
fn as_ref_str_matches_as_str() {
let s = Scope::new("acme.agent-42").unwrap();
let r: &str = s.as_ref();
assert_eq!(r, s.as_str());
}
}