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 accepted,
165 "accepted",
166 "The judge (orchestrator or human) accepted the consumer's work."
167 ),
168 (
169 rejected,
170 "rejected",
171 "The judge rejected the consumer's work — the escalation signal."
172 ),
173];
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn normalization_trims_and_lowercases() {
181 let c = Channel::new(" X-Twitter ").unwrap();
182 assert_eq!(c.as_str(), "x-twitter");
183 assert_eq!(Channel::new(c.as_str()).unwrap(), c);
185 }
186
187 #[test]
188 fn charset_and_length_are_enforced() {
189 assert!(matches!(Sharer::new(""), Err(SlugError::Length { .. })));
190 assert!(matches!(
191 Sharer::new("has space"),
192 Err(SlugError::Charset { .. })
193 ));
194 assert!(matches!(
195 Stage::new("Ünicode"),
196 Err(SlugError::Charset { .. })
197 ));
198 let long = "a".repeat(65);
199 assert!(matches!(Channel::new(&long), Err(SlugError::Length { .. })));
200 assert!(Channel::new("subagent/data-check").is_ok());
202 }
203
204 #[test]
205 fn well_known_stages_are_valid_slugs() {
206 for s in Stage::well_known() {
207 assert_eq!(Stage::new(s.as_str()).unwrap(), s);
208 }
209 assert_eq!(Stage::run().as_str(), "run");
210 }
211
212 #[test]
213 fn serde_validates_on_the_way_in() {
214 let ok: Channel = serde_json::from_str("\"slack\"").unwrap();
215 assert_eq!(ok.as_str(), "slack");
216 assert!(serde_json::from_str::<Channel>("\"bad channel!\"").is_err());
217 }
218}