Skip to main content

waggle_core/
slug.rs

1//! Domain slugs: [`Sharer`], [`Channel`], and [`Stage`] — validated,
2//! normalized newtypes so a bare `String` never carries domain meaning
3//! (design docs `02 §2`, `13 §3`: a bare string in a public signature is a
4//! review rejection).
5
6use core::fmt;
7use serde::{de, Deserialize, Deserializer, Serialize};
8use thiserror::Error;
9
10/// Why a slug was rejected.
11#[derive(Debug, Error, PartialEq, Eq)]
12pub enum SlugError {
13    /// Empty after trimming, or longer than the field's cap.
14    #[error("{field} length {len} outside 1..={max}")]
15    Length {
16        /// Which field rejected the input.
17        field: &'static str,
18        /// Observed length after trimming.
19        len: usize,
20        /// The field's maximum length.
21        max: usize,
22    },
23    /// A character outside `[a-z0-9._/-]` (after lowercasing).
24    #[error("{field} contains a character outside [a-z0-9._/-]")]
25    Charset {
26        /// Which field rejected the input.
27        field: &'static str,
28    },
29}
30
31/// Trim, lowercase, and validate a slug against the shared charset.
32fn 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            /// Normalize (trim, lowercase) and validate.
60            pub fn new(raw: &str) -> Result<Self, SlugError> {
61                normalize($field, raw, $max).map(Self)
62            }
63
64            /// The normalized slug.
65            #[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    /// Who performed a distribution act. Deliberately *not* an authenticated
88    /// identity in the core — hosts bind sharers to their own auth (02 §2).
89    Sharer,
90    "sharer",
91    64
92);
93
94slug_newtype!(
95    /// Where a share lives: `x-twitter`, `slack`, `qr-event`,
96    /// `subagent/researcher`, … One token per channel is the design's rule;
97    /// the API enforces it by taking exactly one.
98    Channel,
99    "channel",
100    64
101);
102
103slug_newtype!(
104    /// A funnel stage. Well-known constructors below; custom stages are any
105    /// valid slug — the open vocabulary is what extends the funnel past the
106    /// click into the consuming product's own lifecycle.
107    Stage,
108    "stage",
109    32
110);
111
112impl Channel {
113    /// The default channel for agent handoffs when none is given
114    /// (one-call mint, design doc `17 §1` rule 3).
115    #[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            /// Every well-known stage, in funnel order.
131            #[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        // Idempotent: normalizing the normalized form changes nothing.
184        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        // Slash is allowed: subagent role channels depend on it.
201        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}