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
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        // Idempotent: normalizing the normalized form changes nothing.
174        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        // Slash is allowed: subagent role channels depend on it.
191        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}