use core::fmt;
use serde::{de, Deserialize, Deserializer, Serialize};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SlugError {
#[error("{field} length {len} outside 1..={max}")]
Length {
field: &'static str,
len: usize,
max: usize,
},
#[error("{field} contains a character outside [a-z0-9._/-]")]
Charset {
field: &'static str,
},
}
fn normalize(field: &'static str, raw: &str, max: usize) -> Result<String, SlugError> {
let s = raw.trim().to_ascii_lowercase();
if s.is_empty() || s.len() > max {
return Err(SlugError::Length {
field,
len: s.len(),
max,
});
}
let ok = s.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.' | b'/')
});
if ok {
Ok(s)
} else {
Err(SlugError::Charset { field })
}
}
macro_rules! slug_newtype {
($(#[$doc:meta])* $name:ident, $field:literal, $max:literal) => {
$(#[$doc])*
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
pub fn new(raw: &str) -> Result<Self, SlugError> {
normalize($field, raw, $max).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::new(&s).map_err(de::Error::custom)
}
}
};
}
slug_newtype!(
Sharer,
"sharer",
64
);
slug_newtype!(
Channel,
"channel",
64
);
slug_newtype!(
Stage,
"stage",
32
);
impl Channel {
#[must_use]
pub fn subagent_general() -> Self {
Self("subagent/general".to_owned())
}
}
macro_rules! well_known_stages {
($(($fn_name:ident, $lit:literal, $doc:literal)),+ $(,)?) => {
impl Stage {
$(
#[doc = $doc]
#[must_use]
pub fn $fn_name() -> Self { Self($lit.to_owned()) }
)+
#[must_use]
pub fn well_known() -> Vec<Self> {
vec![$(Self($lit.to_owned())),+]
}
}
};
}
well_known_stages![
(impression, "impression", "An unfurl bot fetched the link."),
(click, "click", "A human followed the link."),
(resolve, "resolve", "A consumer fetched a projection."),
(
read,
"read",
"A consumer read or searched the content surgically."
),
(
assess,
"assess",
"The consumer inspected before committing."
),
(consent, "consent", "A human approved the action."),
(install, "install", "A delta was installed."),
(signin, "signin", "Identity was established."),
(credential, "credential", "A credential was activated."),
(run, "run", "The referenced work was executed."),
(
repeat,
"repeat",
"A repeat execution — the retention signal."
),
(
accepted,
"accepted",
"The judge (orchestrator or human) accepted the consumer's work."
),
(
rejected,
"rejected",
"The judge rejected the consumer's work — the escalation signal."
),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalization_trims_and_lowercases() {
let c = Channel::new(" X-Twitter ").unwrap();
assert_eq!(c.as_str(), "x-twitter");
assert_eq!(Channel::new(c.as_str()).unwrap(), c);
}
#[test]
fn charset_and_length_are_enforced() {
assert!(matches!(Sharer::new(""), Err(SlugError::Length { .. })));
assert!(matches!(
Sharer::new("has space"),
Err(SlugError::Charset { .. })
));
assert!(matches!(
Stage::new("Ünicode"),
Err(SlugError::Charset { .. })
));
let long = "a".repeat(65);
assert!(matches!(Channel::new(&long), Err(SlugError::Length { .. })));
assert!(Channel::new("subagent/data-check").is_ok());
}
#[test]
fn well_known_stages_are_valid_slugs() {
for s in Stage::well_known() {
assert_eq!(Stage::new(s.as_str()).unwrap(), s);
}
assert_eq!(Stage::run().as_str(), "run");
}
#[test]
fn serde_validates_on_the_way_in() {
let ok: Channel = serde_json::from_str("\"slack\"").unwrap();
assert_eq!(ok.as_str(), "slack");
assert!(serde_json::from_str::<Channel>("\"bad channel!\"").is_err());
}
}