1use core::fmt;
7use serde::{de, Deserialize, Deserializer, Serialize};
8use thiserror::Error;
9
10#[derive(Debug, Error, PartialEq, Eq)]
12pub enum SlugError {
13 #[error("{field} length {len} outside 1..={max}")]
15 Length {
16 field: &'static str,
18 len: usize,
20 max: usize,
22 },
23 #[error("{field} contains a character outside [a-z0-9._/-]")]
25 Charset {
26 field: &'static str,
28 },
29}
30
31fn normalize(field: &'static str, raw: &str, max: usize) -> Result<String, SlugError> {
33 let s = raw.trim().to_ascii_lowercase();
34 if s.is_empty() || s.len() > max {
35 return Err(SlugError::Length {
36 field,
37 len: s.len(),
38 max,
39 });
40 }
41 let ok = s.bytes().all(|b| {
42 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.' | b'/')
43 });
44 if ok {
45 Ok(s)
46 } else {
47 Err(SlugError::Charset { field })
48 }
49}
50
51macro_rules! slug_newtype {
52 ($(#[$doc:meta])* $name:ident, $field:literal, $max:literal) => {
53 $(#[$doc])*
54 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
55 #[serde(transparent)]
56 pub struct $name(String);
57
58 impl $name {
59 pub fn new(raw: &str) -> Result<Self, SlugError> {
61 normalize($field, raw, $max).map(Self)
62 }
63
64 #[must_use]
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69 }
70
71 impl fmt::Display for $name {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.write_str(&self.0)
74 }
75 }
76
77 impl<'de> Deserialize<'de> for $name {
78 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
79 let s = String::deserialize(d)?;
80 Self::new(&s).map_err(de::Error::custom)
81 }
82 }
83 };
84}
85
86slug_newtype!(
87 Sharer,
90 "sharer",
91 64
92);
93
94slug_newtype!(
95 Channel,
99 "channel",
100 64
101);
102
103slug_newtype!(
104 Stage,
108 "stage",
109 32
110);
111
112impl Channel {
113 #[must_use]
116 pub fn subagent_general() -> Self {
117 Self("subagent/general".to_owned())
118 }
119}
120
121macro_rules! well_known_stages {
122 ($(($fn_name:ident, $lit:literal, $doc:literal)),+ $(,)?) => {
123 impl Stage {
124 $(
125 #[doc = $doc]
126 #[must_use]
127 pub fn $fn_name() -> Self { Self($lit.to_owned()) }
128 )+
129
130 #[must_use]
132 pub fn well_known() -> Vec<Self> {
133 vec![$(Self($lit.to_owned())),+]
134 }
135 }
136 };
137}
138
139well_known_stages![
140 (impression, "impression", "An unfurl bot fetched the link."),
141 (click, "click", "A human followed the link."),
142 (resolve, "resolve", "A consumer fetched a projection."),
143 (
144 read,
145 "read",
146 "A consumer read or searched the content surgically."
147 ),
148 (
149 assess,
150 "assess",
151 "The consumer inspected before committing."
152 ),
153 (consent, "consent", "A human approved the action."),
154 (install, "install", "A delta was installed."),
155 (signin, "signin", "Identity was established."),
156 (credential, "credential", "A credential was activated."),
157 (run, "run", "The referenced work was executed."),
158 (
159 repeat,
160 "repeat",
161 "A repeat execution — the retention signal."
162 ),
163];
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn normalization_trims_and_lowercases() {
171 let c = Channel::new(" X-Twitter ").unwrap();
172 assert_eq!(c.as_str(), "x-twitter");
173 assert_eq!(Channel::new(c.as_str()).unwrap(), c);
175 }
176
177 #[test]
178 fn charset_and_length_are_enforced() {
179 assert!(matches!(Sharer::new(""), Err(SlugError::Length { .. })));
180 assert!(matches!(
181 Sharer::new("has space"),
182 Err(SlugError::Charset { .. })
183 ));
184 assert!(matches!(
185 Stage::new("Ünicode"),
186 Err(SlugError::Charset { .. })
187 ));
188 let long = "a".repeat(65);
189 assert!(matches!(Channel::new(&long), Err(SlugError::Length { .. })));
190 assert!(Channel::new("subagent/data-check").is_ok());
192 }
193
194 #[test]
195 fn well_known_stages_are_valid_slugs() {
196 for s in Stage::well_known() {
197 assert_eq!(Stage::new(s.as_str()).unwrap(), s);
198 }
199 assert_eq!(Stage::run().as_str(), "run");
200 }
201
202 #[test]
203 fn serde_validates_on_the_way_in() {
204 let ok: Channel = serde_json::from_str("\"slack\"").unwrap();
205 assert_eq!(ok.as_str(), "slack");
206 assert!(serde_json::from_str::<Channel>("\"bad channel!\"").is_err());
207 }
208}