Skip to main content

use_slug/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Slug normalization and validation helpers.
5
6use core::fmt;
7
8pub mod prelude;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SlugError {
12    Empty,
13    InvalidCharacter { character: char, index: usize },
14    LeadingSeparator,
15    TrailingSeparator,
16    RepeatedSeparator { index: usize },
17}
18
19impl fmt::Display for SlugError {
20    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::Empty => formatter.write_str("slug cannot be empty"),
23            Self::InvalidCharacter { character, index } => {
24                write!(
25                    formatter,
26                    "invalid slug character `{character}` at byte {index}"
27                )
28            },
29            Self::LeadingSeparator => formatter.write_str("slug cannot start with `-`"),
30            Self::TrailingSeparator => formatter.write_str("slug cannot end with `-`"),
31            Self::RepeatedSeparator { index } => {
32                write!(formatter, "slug cannot repeat `-` at byte {index}")
33            },
34        }
35    }
36}
37
38impl std::error::Error for SlugError {}
39
40#[must_use]
41pub fn normalize_slug(input: &str) -> String {
42    let mut normalized = String::new();
43    let mut previous_was_separator = false;
44
45    for character in input.trim().chars() {
46        let lowered = character.to_ascii_lowercase();
47
48        if lowered.is_ascii_alphanumeric() {
49            normalized.push(lowered);
50            previous_was_separator = false;
51        } else if !normalized.is_empty() && !previous_was_separator {
52            normalized.push('-');
53            previous_was_separator = true;
54        }
55    }
56
57    while normalized.ends_with('-') {
58        normalized.pop();
59    }
60
61    normalized
62}
63
64/// Validates that a slug is non-empty, lowercase ASCII, and hyphen-delimited.
65///
66/// # Errors
67///
68/// Returns a [`SlugError`] variant describing the first invalid slug condition.
69pub fn validate_slug(input: &str) -> Result<(), SlugError> {
70    if input.is_empty() {
71        return Err(SlugError::Empty);
72    }
73
74    if input.starts_with('-') {
75        return Err(SlugError::LeadingSeparator);
76    }
77
78    if input.ends_with('-') {
79        return Err(SlugError::TrailingSeparator);
80    }
81
82    let mut previous_was_separator = false;
83
84    for (index, character) in input.char_indices() {
85        if character == '-' {
86            if previous_was_separator {
87                return Err(SlugError::RepeatedSeparator { index });
88            }
89
90            previous_was_separator = true;
91            continue;
92        }
93
94        if !character.is_ascii_lowercase() && !character.is_ascii_digit() {
95            return Err(SlugError::InvalidCharacter { character, index });
96        }
97
98        previous_was_separator = false;
99    }
100
101    Ok(())
102}
103
104#[must_use]
105pub fn is_valid_slug(input: &str) -> bool {
106    validate_slug(input).is_ok()
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
110pub struct Slug(String);
111
112impl Slug {
113    /// Creates a normalized slug from user-provided text.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`SlugError::Empty`] when normalization produces an empty value or another
118    /// [`SlugError`] variant when the canonical representation is invalid.
119    pub fn new(input: &str) -> Result<Self, SlugError> {
120        let normalized = normalize_slug(input);
121        validate_slug(&normalized)?;
122        Ok(Self(normalized))
123    }
124
125    #[must_use]
126    pub fn as_str(&self) -> &str {
127        &self.0
128    }
129
130    #[must_use]
131    pub fn into_inner(self) -> String {
132        self.0
133    }
134
135    pub fn segments(&self) -> impl Iterator<Item = &str> {
136        self.0.split('-')
137    }
138}
139
140impl fmt::Display for Slug {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        formatter.write_str(self.as_str())
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::{Slug, SlugError, validate_slug};
149
150    #[test]
151    fn normalizes_spaces_and_case() -> Result<(), SlugError> {
152        let slug = Slug::new("RustUse Docs")?;
153        assert_eq!(slug.as_str(), "rustuse-docs");
154        Ok(())
155    }
156
157    #[test]
158    fn constructor_collapses_repeated_separators() -> Result<(), SlugError> {
159        let slug = Slug::new("rustuse--docs")?;
160        assert_eq!(slug.as_str(), "rustuse-docs");
161        Ok(())
162    }
163
164    #[test]
165    fn validator_rejects_repeated_separators() {
166        assert_eq!(
167            validate_slug("rustuse--docs"),
168            Err(SlugError::RepeatedSeparator { index: 8 })
169        );
170    }
171}