Skip to main content

api_bones/
slug.rs

1//! Validated `Slug` newtype for URL-friendly identifiers.
2//!
3//! A `Slug` is a lowercase ASCII string used as a human-readable URL segment.
4//! It enforces the following constraints at construction time:
5//!
6//! - Only lowercase ASCII letters (`a-z`), digits (`0-9`), and hyphens (`-`)
7//! - Length: 1–128 characters
8//! - No leading, trailing, or consecutive hyphens
9//!
10//! # Example
11//!
12//! ```rust
13//! use api_bones::slug::{Slug, SlugError};
14//!
15//! let slug = Slug::new("hello-world").unwrap();
16//! assert_eq!(slug.as_str(), "hello-world");
17//!
18//! let auto = Slug::from_title("Hello, World! 2024");
19//! assert_eq!(auto.as_str(), "hello-world-2024");
20//!
21//! assert!(matches!(Slug::new("Hello"), Err(SlugError::InvalidChars)));
22//! assert!(matches!(Slug::new("-bad"), Err(SlugError::LeadingHyphen)));
23//! assert!(matches!(Slug::new(""), Err(SlugError::Empty)));
24//! ```
25
26#[cfg(all(not(feature = "std"), feature = "alloc"))]
27use alloc::{borrow::ToOwned, string::String};
28use core::{fmt, ops::Deref};
29#[cfg(feature = "serde")]
30use serde::{Deserialize, Deserializer, Serialize};
31use thiserror::Error;
32
33// ---------------------------------------------------------------------------
34// SlugError
35// ---------------------------------------------------------------------------
36
37/// Errors that can occur when constructing a [`Slug`].
38#[derive(Debug, Clone, PartialEq, Eq, Error)]
39pub enum SlugError {
40    /// The input was empty.
41    #[error("slug must not be empty")]
42    Empty,
43    /// The input exceeds 128 characters.
44    #[error("slug must not exceed 128 characters")]
45    TooLong,
46    /// The input contains characters other than `[a-z0-9-]`.
47    #[error("slug may only contain lowercase ASCII letters, digits, and hyphens")]
48    InvalidChars,
49    /// The input starts with a hyphen.
50    #[error("slug must not start with a hyphen")]
51    LeadingHyphen,
52    /// The input ends with a hyphen.
53    #[error("slug must not end with a hyphen")]
54    TrailingHyphen,
55    /// The input contains two or more consecutive hyphens.
56    #[error("slug must not contain consecutive hyphens")]
57    ConsecutiveHyphens,
58}
59
60// ---------------------------------------------------------------------------
61// Slug
62// ---------------------------------------------------------------------------
63
64/// A validated, URL-friendly identifier.
65///
66/// See the [module-level documentation](self) for the full invariant set.
67#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
68#[cfg_attr(feature = "serde", derive(Serialize))]
69#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
70#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
71pub struct Slug(String);
72
73impl Slug {
74    /// Construct a `Slug` from a string slice, returning a [`SlugError`] if any
75    /// constraint is violated.
76    ///
77    /// # Errors
78    ///
79    /// Returns a [`SlugError`] variant that describes which constraint failed.
80    ///
81    /// ```
82    /// use api_bones::slug::{Slug, SlugError};
83    ///
84    /// let slug = Slug::new("hello-world").unwrap();
85    /// assert_eq!(slug.as_str(), "hello-world");
86    ///
87    /// assert!(matches!(Slug::new(""), Err(SlugError::Empty)));
88    /// assert!(matches!(Slug::new("Hello"), Err(SlugError::InvalidChars)));
89    /// assert!(matches!(Slug::new("-bad"), Err(SlugError::LeadingHyphen)));
90    /// ```
91    pub fn new(s: impl AsRef<str>) -> Result<Self, SlugError> {
92        let s = s.as_ref();
93        if s.is_empty() {
94            return Err(SlugError::Empty);
95        }
96        if s.len() > 128 {
97            return Err(SlugError::TooLong);
98        }
99        if !s
100            .chars()
101            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
102        {
103            return Err(SlugError::InvalidChars);
104        }
105        if s.starts_with('-') {
106            return Err(SlugError::LeadingHyphen);
107        }
108        if s.ends_with('-') {
109            return Err(SlugError::TrailingHyphen);
110        }
111        if s.contains("--") {
112            return Err(SlugError::ConsecutiveHyphens);
113        }
114        Ok(Self(s.to_owned()))
115    }
116
117    /// Automatically convert an arbitrary title string into a valid `Slug`.
118    ///
119    /// The transformation pipeline:
120    /// 1. Lowercase everything.
121    /// 2. Replace any character that is not `[a-z0-9]` with a hyphen.
122    /// 3. Collapse runs of hyphens into a single hyphen.
123    /// 4. Strip leading and trailing hyphens.
124    /// 5. Truncate to 128 characters.
125    ///
126    /// The result is guaranteed to be a valid `Slug` as long as the input
127    /// contains at least one alphanumeric ASCII character; otherwise this
128    /// returns a `Slug` with the value `"untitled"`.
129    ///
130    /// ```
131    /// use api_bones::slug::Slug;
132    ///
133    /// let slug = Slug::from_title("Hello, World! 2024");
134    /// assert_eq!(slug.as_str(), "hello-world-2024");
135    /// ```
136    #[must_use]
137    pub fn from_title(s: impl AsRef<str>) -> Self {
138        let lowered = s.as_ref().to_lowercase();
139        // Replace non-(a-z0-9) chars with hyphens
140        let replaced: String = lowered
141            .chars()
142            .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
143            .collect();
144        // Collapse consecutive hyphens
145        let mut collapsed = String::with_capacity(replaced.len());
146        let mut prev_hyphen = false;
147        for c in replaced.chars() {
148            if c == '-' {
149                if !prev_hyphen {
150                    collapsed.push(c);
151                }
152                prev_hyphen = true;
153            } else {
154                collapsed.push(c);
155                prev_hyphen = false;
156            }
157        }
158        // Strip leading/trailing hyphens and truncate
159        let trimmed = collapsed.trim_matches('-');
160        let truncated: String = trimmed.chars().take(128).collect();
161        // Re-strip after truncation in case the truncation point is a hyphen
162        let final_str = truncated.trim_matches('-');
163        if final_str.is_empty() {
164            Self("untitled".to_owned())
165        } else {
166            Self(final_str.to_owned())
167        }
168    }
169
170    /// Return the inner string slice.
171    ///
172    /// ```
173    /// use api_bones::slug::Slug;
174    ///
175    /// let slug = Slug::new("my-slug").unwrap();
176    /// assert_eq!(slug.as_str(), "my-slug");
177    /// ```
178    #[must_use]
179    pub fn as_str(&self) -> &str {
180        &self.0
181    }
182
183    /// Consume the `Slug` and return the underlying `String`.
184    ///
185    /// ```
186    /// use api_bones::slug::Slug;
187    ///
188    /// let slug = Slug::new("hello").unwrap();
189    /// let s: String = slug.into_string();
190    /// assert_eq!(s, "hello");
191    /// ```
192    #[must_use]
193    pub fn into_string(self) -> String {
194        self.0
195    }
196}
197
198// ---------------------------------------------------------------------------
199// Standard trait impls
200// ---------------------------------------------------------------------------
201
202impl Deref for Slug {
203    type Target = str;
204
205    fn deref(&self) -> &str {
206        &self.0
207    }
208}
209
210impl AsRef<str> for Slug {
211    fn as_ref(&self) -> &str {
212        &self.0
213    }
214}
215
216impl fmt::Display for Slug {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        f.write_str(&self.0)
219    }
220}
221
222impl TryFrom<String> for Slug {
223    type Error = SlugError;
224
225    fn try_from(s: String) -> Result<Self, Self::Error> {
226        Self::new(s)
227    }
228}
229
230impl TryFrom<&str> for Slug {
231    type Error = SlugError;
232
233    fn try_from(s: &str) -> Result<Self, Self::Error> {
234        Self::new(s)
235    }
236}
237
238// ---------------------------------------------------------------------------
239// Serde
240// ---------------------------------------------------------------------------
241
242#[cfg(feature = "serde")]
243impl<'de> Deserialize<'de> for Slug {
244    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245        let s = String::deserialize(deserializer)?;
246        Self::new(&s).map_err(serde::de::Error::custom)
247    }
248}
249
250// ---------------------------------------------------------------------------
251// arbitrary
252// ---------------------------------------------------------------------------
253
254#[cfg(feature = "arbitrary")]
255impl<'a> arbitrary::Arbitrary<'a> for Slug {
256    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
257        const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
258        let len = u.int_in_range(1usize..=20)?;
259        // Build a candidate from allowed chars (no hyphens in arbitrary to keep it simple)
260        let inner: String = (0..len)
261            .map(|_| -> arbitrary::Result<char> {
262                let idx = u.int_in_range(0..=(CHARSET.len() - 1))?;
263                Ok(CHARSET[idx] as char)
264            })
265            .collect::<arbitrary::Result<_>>()?;
266        // Optionally intersperse hyphens between segments
267        let segments = u.int_in_range(1usize..=3)?;
268        if segments == 1 || inner.len() < 2 {
269            Ok(Self(inner))
270        } else {
271            let step = inner.len() / segments;
272            let joined: String = inner
273                .as_bytes()
274                .chunks(step.max(1))
275                .map(|c| std::str::from_utf8(c).unwrap_or("a"))
276                .collect::<Vec<_>>()
277                .join("-");
278            // Guaranteed valid because we only used CHARSET and joined with single hyphens
279            Ok(Self(joined.trim_matches('-').to_owned()))
280        }
281    }
282}
283
284// ---------------------------------------------------------------------------
285// proptest
286// ---------------------------------------------------------------------------
287
288#[cfg(feature = "proptest")]
289pub mod proptest_strategies {
290    use super::Slug;
291    use proptest::prelude::*;
292
293    /// A `proptest` strategy that generates valid [`Slug`] values.
294    pub fn slug_strategy() -> impl Strategy<Value = Slug> {
295        // Pattern: one or more segments of [a-z0-9]{1,20} joined by single hyphens
296        prop::collection::vec("[a-z0-9]{1,20}", 1..=4).prop_map(|segs| {
297            let s = segs.join("-");
298            // Guaranteed valid by construction
299            Slug::new(s).expect("generated slug must be valid")
300        })
301    }
302}
303
304#[cfg(feature = "proptest")]
305impl proptest::arbitrary::Arbitrary for Slug {
306    type Parameters = ();
307    type Strategy = proptest::strategy::BoxedStrategy<Self>;
308
309    fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
310        use proptest::prelude::*;
311        proptest_strategies::slug_strategy().boxed()
312    }
313}
314
315// ---------------------------------------------------------------------------
316// Tests
317// ---------------------------------------------------------------------------
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    // --- Slug::new ---
324
325    #[test]
326    fn valid_slugs_are_accepted() {
327        for s in ["a", "hello", "hello-world", "abc-123", "a1b2c3", "x"] {
328            assert!(Slug::new(s).is_ok(), "expected {s:?} to be valid");
329        }
330    }
331
332    #[test]
333    fn empty_string_is_rejected() {
334        assert_eq!(Slug::new(""), Err(SlugError::Empty));
335    }
336
337    #[test]
338    fn too_long_string_is_rejected() {
339        let s: String = "a".repeat(129);
340        assert_eq!(Slug::new(&s), Err(SlugError::TooLong));
341    }
342
343    #[test]
344    fn exactly_128_chars_is_accepted() {
345        let s: String = "a".repeat(128);
346        assert!(Slug::new(&s).is_ok());
347    }
348
349    #[test]
350    fn uppercase_chars_are_rejected() {
351        assert_eq!(Slug::new("Hello"), Err(SlugError::InvalidChars));
352    }
353
354    #[test]
355    fn special_chars_are_rejected() {
356        for s in ["hello_world", "hello world", "héllo", "hello.world"] {
357            assert_eq!(
358                Slug::new(s),
359                Err(SlugError::InvalidChars),
360                "expected {s:?} to be invalid"
361            );
362        }
363    }
364
365    #[test]
366    fn leading_hyphen_is_rejected() {
367        assert_eq!(Slug::new("-hello"), Err(SlugError::LeadingHyphen));
368    }
369
370    #[test]
371    fn trailing_hyphen_is_rejected() {
372        assert_eq!(Slug::new("hello-"), Err(SlugError::TrailingHyphen));
373    }
374
375    #[test]
376    fn consecutive_hyphens_are_rejected() {
377        assert_eq!(
378            Slug::new("hello--world"),
379            Err(SlugError::ConsecutiveHyphens)
380        );
381    }
382
383    // --- Slug::from_title ---
384
385    #[test]
386    fn from_title_basic() {
387        let slug = Slug::from_title("Hello World");
388        assert_eq!(slug.as_str(), "hello-world");
389    }
390
391    #[test]
392    fn from_title_strips_special_chars() {
393        let slug = Slug::from_title("Hello, World! 2024");
394        assert_eq!(slug.as_str(), "hello-world-2024");
395    }
396
397    #[test]
398    fn from_title_collapses_multiple_spaces() {
399        let slug = Slug::from_title("Hello   World");
400        assert_eq!(slug.as_str(), "hello-world");
401    }
402
403    #[test]
404    fn from_title_strips_leading_trailing_separators() {
405        let slug = Slug::from_title("  Hello World  ");
406        assert_eq!(slug.as_str(), "hello-world");
407    }
408
409    #[test]
410    fn from_title_all_non_alnum_returns_untitled() {
411        let slug = Slug::from_title("!!! ???");
412        assert_eq!(slug.as_str(), "untitled");
413    }
414
415    #[test]
416    fn from_title_empty_returns_untitled() {
417        let slug = Slug::from_title("");
418        assert_eq!(slug.as_str(), "untitled");
419    }
420
421    #[test]
422    fn from_title_truncates_to_128() {
423        let long: String = "a ".repeat(200);
424        let slug = Slug::from_title(&long);
425        assert!(slug.as_str().len() <= 128);
426        assert!(Slug::new(slug.as_str()).is_ok());
427    }
428
429    // --- Trait impls ---
430
431    #[test]
432    fn deref_to_str() {
433        let slug = Slug::new("hello").unwrap();
434        let s: &str = &slug;
435        assert_eq!(s, "hello");
436    }
437
438    #[test]
439    fn display() {
440        let slug = Slug::new("hello-world").unwrap();
441        assert_eq!(format!("{slug}"), "hello-world");
442    }
443
444    #[test]
445    fn as_ref_str() {
446        let slug = Slug::new("hello").unwrap();
447        let s: &str = slug.as_ref();
448        assert_eq!(s, "hello");
449    }
450
451    #[test]
452    fn try_from_string() {
453        let slug = Slug::try_from("hello".to_owned()).unwrap();
454        assert_eq!(slug.as_str(), "hello");
455    }
456
457    #[test]
458    fn try_from_str_ref() {
459        let slug = Slug::try_from("world").unwrap();
460        assert_eq!(slug.as_str(), "world");
461    }
462
463    #[test]
464    fn into_string() {
465        let slug = Slug::new("hello").unwrap();
466        assert_eq!(slug.into_string(), "hello".to_owned());
467    }
468
469    // --- Serde ---
470
471    #[cfg(feature = "serde")]
472    #[test]
473    fn serde_roundtrip() {
474        let slug = Slug::new("hello-world").unwrap();
475        let json = serde_json::to_string(&slug).unwrap();
476        assert_eq!(json, r#""hello-world""#);
477        let back: Slug = serde_json::from_str(&json).unwrap();
478        assert_eq!(back, slug);
479    }
480
481    #[cfg(feature = "serde")]
482    #[test]
483    fn serde_deserialize_invalid_rejects() {
484        let result: Result<Slug, _> = serde_json::from_str(r#""Hello-World""#);
485        assert!(result.is_err());
486    }
487
488    // --- arbitrary ---
489
490    #[cfg(feature = "arbitrary")]
491    mod arbitrary_tests {
492        use super::super::Slug;
493        use arbitrary::{Arbitrary, Unstructured};
494
495        #[test]
496        fn arbitrary_generates_valid_slugs() {
497            let raw: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
498            let mut u = Unstructured::new(&raw);
499            for _ in 0..50 {
500                if let Ok(slug) = Slug::arbitrary(&mut u) {
501                    assert!(
502                        Slug::new(slug.as_str()).is_ok(),
503                        "arbitrary produced invalid slug: {slug:?}"
504                    );
505                }
506            }
507        }
508    }
509
510    // --- proptest ---
511
512    #[cfg(feature = "proptest")]
513    mod proptest_tests {
514        use super::super::*;
515        use proptest::prelude::*;
516
517        proptest! {
518            #[test]
519            fn arbitrary_with_generates_valid_slugs(slug in <Slug as proptest::arbitrary::Arbitrary>::arbitrary_with(())) {
520                prop_assert!(Slug::new(slug.as_str()).is_ok());
521            }
522
523            #[test]
524            fn generated_slugs_are_always_valid(slug in proptest_strategies::slug_strategy()) {
525                prop_assert!(Slug::new(slug.as_str()).is_ok());
526                prop_assert!(!slug.as_str().is_empty());
527                prop_assert!(slug.as_str().len() <= 128);
528                prop_assert!(!slug.as_str().starts_with('-'));
529                prop_assert!(!slug.as_str().ends_with('-'));
530                prop_assert!(!slug.as_str().contains("--"));
531            }
532
533            #[test]
534            fn from_title_always_produces_valid_slug(title in ".*") {
535                let slug = Slug::from_title(&title);
536                prop_assert!(Slug::new(slug.as_str()).is_ok());
537            }
538        }
539    }
540
541    #[cfg(feature = "schemars")]
542    #[test]
543    fn slug_schema_is_valid() {
544        let schema = schemars::schema_for!(Slug);
545        let json = serde_json::to_value(&schema).expect("schema serializable");
546        assert!(json.is_object());
547    }
548}