use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MemorySubject {
Literal(String),
Correlation(String),
Case,
Input(String),
}
impl MemorySubject {
pub fn parse(raw: &str) -> Result<Self, String> {
if let Some(rest) = raw.strip_prefix("$$") {
return Ok(Self::Literal(format!("${rest}")));
}
let Some(binding) = raw.strip_prefix('$') else {
return Ok(Self::Literal(raw.to_owned()));
};
if binding == "case" {
return Ok(Self::Case);
}
if let Some(namespace) = binding.strip_prefix("correlation/") {
if namespace.is_empty() {
return Err(
"'$correlation/' names no namespace — write the correlation key's \
namespace after the slash, e.g. '$correlation/meter'"
.to_owned(),
);
}
return Ok(Self::Correlation(namespace.to_owned()));
}
if let Some(pointer) = binding.strip_prefix("input") {
if !pointer.is_empty() && !pointer.starts_with('/') {
return Err(format!(
"'{raw}' is not an input reference — a pointer after '$input' is \
RFC 6901 and begins with '/', e.g. '$input/customer/id'"
));
}
return Ok(Self::Input(pointer.to_owned()));
}
Err(format!(
"'{raw}' is not a binding this crate understands. Use \
'$correlation/<namespace>', '$case', '$input/<pointer>', or write '$$' \
for a literal that really begins with a dollar sign — an unrecognised \
binding is not read as a constant, because a typo would file every \
subject's memories under the typo"
))
}
#[must_use]
pub fn as_written(&self) -> String {
match self {
Self::Literal(value) if value.starts_with('$') => format!("${value}"),
Self::Literal(value) => value.clone(),
Self::Correlation(namespace) => format!("$correlation/{namespace}"),
Self::Case => "$case".to_owned(),
Self::Input(pointer) => format!("$input{pointer}"),
}
}
#[must_use]
pub const fn is_bound(&self) -> bool {
!matches!(self, Self::Literal(_))
}
#[must_use]
pub const fn needs_case(&self) -> bool {
matches!(self, Self::Correlation(_) | Self::Case)
}
}
impl std::fmt::Display for MemorySubject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.as_written())
}
}
impl Serialize for MemorySubject {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.as_written())
}
}
impl schemars::JsonSchema for MemorySubject {
fn schema_name() -> std::borrow::Cow<'static, str> {
"MemorySubject".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"minLength": 1,
"description": "A memory scope: a literal name, or a run binding — \
`$correlation/<namespace>`, `$case`, or \
`$input/<RFC 6901 pointer>`. Write `$$` for a \
literal that really begins with a dollar sign; any \
other `$` spelling is refused rather than read as \
a constant."
})
}
}
impl<'de> Deserialize<'de> for MemorySubject {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
Self::parse(&raw).map_err(D::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_binding_round_trips_through_its_written_form() {
for written in [
"agent:triage",
"$correlation/malo",
"$correlation/document-number",
"$case",
"$input",
"$input/customer/id",
"$$literal-dollar",
] {
let parsed = MemorySubject::parse(written).expect("a valid binding");
assert_eq!(parsed.as_written(), written, "round trip of {written}");
}
}
#[test]
fn an_unrecognised_binding_is_refused_rather_than_taken_as_a_constant() {
assert_eq!(
MemorySubject::parse("$$agent:triage"),
Ok(MemorySubject::Literal("$agent:triage".to_owned()))
);
let refused = MemorySubject::parse("$correlaton/malo").expect_err("a typo is refused");
assert!(refused.contains("$correlation/<namespace>"), "{refused}");
assert!(
MemorySubject::parse("$correlation/")
.expect_err("an empty namespace is refused")
.contains("names no namespace")
);
assert!(
MemorySubject::parse("$inputcustomer")
.expect_err("a malformed pointer is refused")
.contains("RFC 6901")
);
}
#[test]
fn a_binding_states_whether_it_is_dynamic_and_whether_it_needs_a_case() {
let literal = MemorySubject::parse("team:billing").expect("literal");
assert!(!literal.is_bound());
assert!(!literal.needs_case());
for written in ["$correlation/malo", "$case"] {
let bound = MemorySubject::parse(written).expect("binding");
assert!(bound.is_bound(), "{written}");
assert!(bound.needs_case(), "{written}");
}
let from_input = MemorySubject::parse("$input/malo").expect("binding");
assert!(from_input.is_bound());
assert!(
!from_input.needs_case(),
"an input binding reads the run's own input and needs no case"
);
}
}