use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
const USER_PREFIX: &str = "user:";
const TEMP_PREFIX: &str = "temp:";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum StateScope {
User,
Context,
Temp,
}
impl StateScope {
pub fn prefix(self) -> &'static str {
match self {
Self::User => USER_PREFIX,
Self::Context => "",
Self::Temp => TEMP_PREFIX,
}
}
pub fn is_stored(self) -> bool {
!matches!(self, Self::Temp)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum StateKeyError {
#[error("a state key cannot be empty")]
Empty,
#[error(
"'{prefix}:' is not a memory scope — write `user:{name}` for something that outlives \
this conversation, `temp:{name}` for something that is not stored, or `{name}` on its own"
)]
UnknownScope { prefix: String, name: String },
#[error("a state key cannot contain control characters or newlines")]
ControlCharacter,
#[error("a state key is at most {max} characters, got {len}")]
TooLong { len: usize, max: usize },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct StateKey {
scope: StateScope,
name: String,
}
impl StateKey {
pub const MAX_LEN: usize = 128;
pub fn scoped(scope: StateScope, name: &str) -> Result<Self, StateKeyError> {
let name = name.trim();
if name.is_empty() {
return Err(StateKeyError::Empty);
}
let len = scope.prefix().chars().count() + name.chars().count();
if len > Self::MAX_LEN {
return Err(StateKeyError::TooLong {
len,
max: Self::MAX_LEN,
});
}
if name.chars().any(char::is_control) {
return Err(StateKeyError::ControlCharacter);
}
Ok(Self {
scope,
name: name.to_string(),
})
}
pub fn scope(&self) -> StateScope {
self.scope
}
pub fn name(&self) -> &str {
&self.name
}
}
impl FromStr for StateKey {
type Err = StateKeyError;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
let raw = raw.trim();
if raw.is_empty() {
return Err(StateKeyError::Empty);
}
let len = raw.chars().count();
if len > Self::MAX_LEN {
return Err(StateKeyError::TooLong {
len,
max: Self::MAX_LEN,
});
}
if raw.chars().any(char::is_control) {
return Err(StateKeyError::ControlCharacter);
}
let (scope, name) = if let Some(name) = raw.strip_prefix(USER_PREFIX) {
(StateScope::User, name)
} else if let Some(name) = raw.strip_prefix(TEMP_PREFIX) {
(StateScope::Temp, name)
} else if let Some((prefix, name)) = raw.split_once(':') {
return Err(StateKeyError::UnknownScope {
prefix: prefix.to_string(),
name: name.to_string(),
});
} else {
(StateScope::Context, raw)
};
Self::scoped(scope, name)
}
}
impl fmt::Display for StateKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}{}", self.scope.prefix(), self.name)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ContextState {
entries: BTreeMap<StateKey, String>,
}
impl ContextState {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: StateKey, value: impl Into<String>) {
self.entries.insert(key, value.into());
}
pub fn get(&self, key: &StateKey) -> Option<&str> {
self.entries.get(key).map(String::as_str)
}
pub fn remove(&mut self, key: &StateKey) -> bool {
self.entries.remove(key).is_some()
}
pub fn iter(&self) -> impl Iterator<Item = (&StateKey, &str)> {
self.entries
.iter()
.map(|(key, value)| (key, value.as_str()))
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Remembered {
Stored,
Unchanged,
Replaced {
previous: String,
},
NotStored,
}
impl Remembered {
pub fn replaced_value(&self) -> Option<&str> {
match self {
Self::Replaced { previous } => Some(previous),
_ => None,
}
}
}
impl FromIterator<(StateKey, String)> for ContextState {
fn from_iter<I: IntoIterator<Item = (StateKey, String)>>(iter: I) -> Self {
Self {
entries: iter.into_iter().collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(raw: &str) -> StateKey {
raw.parse().unwrap()
}
#[test]
fn a_bare_key_is_scoped_to_the_context() {
let parsed = key("project");
assert_eq!(parsed.scope(), StateScope::Context);
assert_eq!(parsed.name(), "project");
assert_eq!(parsed.to_string(), "project");
}
#[test]
fn the_prefixes_name_the_other_two_scopes() {
assert_eq!(key("user:tone").scope(), StateScope::User);
assert_eq!(key("user:tone").name(), "tone");
assert_eq!(key("temp:draft").scope(), StateScope::Temp);
assert_eq!(key("user:tone").to_string(), "user:tone");
}
#[test]
fn only_temp_is_kept_out_of_storage() {
assert!(!StateScope::Temp.is_stored());
assert!(StateScope::User.is_stored());
assert!(StateScope::Context.is_stored());
}
#[test]
fn an_unknown_prefix_is_refused_rather_than_read_as_a_name() {
let err = "app:tone".parse::<StateKey>().unwrap_err();
assert_eq!(
err,
StateKeyError::UnknownScope {
prefix: "app".to_string(),
name: "tone".to_string(),
}
);
assert!(err.to_string().contains("user:tone"));
}
#[test]
fn an_empty_key_is_refused_with_or_without_a_prefix() {
assert_eq!("".parse::<StateKey>(), Err(StateKeyError::Empty));
assert_eq!(" ".parse::<StateKey>(), Err(StateKeyError::Empty));
assert_eq!("user:".parse::<StateKey>(), Err(StateKeyError::Empty));
}
#[test]
fn control_characters_are_refused() {
assert_eq!(
"to\nne".parse::<StateKey>(),
Err(StateKeyError::ControlCharacter)
);
}
#[test]
fn an_over_long_key_is_refused() {
let long = "k".repeat(StateKey::MAX_LEN + 1);
assert!(matches!(
long.parse::<StateKey>(),
Err(StateKeyError::TooLong { .. })
));
}
#[test]
fn entries_iterate_in_a_stable_order() {
let mut state = ContextState::new();
state.insert(key("project"), "a2a-rs");
state.insert(key("user:tone"), "brief");
state.insert(key("area"), "storage");
let rendered: Vec<String> = state.iter().map(|(k, v)| format!("{k}={v}")).collect();
assert_eq!(
rendered,
["user:tone=brief", "area=storage", "project=a2a-rs"]
);
}
#[test]
fn writing_the_same_key_twice_replaces_it() {
let mut state = ContextState::new();
state.insert(key("project"), "old");
state.insert(key("project"), "new");
assert_eq!(state.len(), 1);
assert_eq!(state.get(&key("project")), Some("new"));
}
#[test]
fn removing_reports_whether_anything_was_there() {
let mut state = ContextState::new();
state.insert(key("project"), "a2a-rs");
assert!(state.remove(&key("project")));
assert!(!state.remove(&key("project")));
assert!(state.is_empty());
}
}