Skip to main content

use_slug/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::fmt;
5
6/// A validated default-separator slug.
7#[derive(Clone, Debug, Eq, Hash, PartialEq)]
8pub struct Slug(String);
9
10impl Slug {
11    /// Creates a slug from an existing normalized value.
12    pub fn new(value: &str) -> Option<Self> {
13        is_slug(value).then(|| Self(value.to_owned()))
14    }
15
16    /// Normalizes free-form text into a slug with the default options.
17    pub fn from_text(input: &str) -> Self {
18        Self(slugify(input))
19    }
20
21    /// Normalizes free-form text into a slug with custom options.
22    pub fn from_text_with_options(input: &str, options: SlugOptions) -> Self {
23        Self(slugify_with_options(input, options))
24    }
25
26    /// Returns the slug text.
27    pub fn as_str(&self) -> &str {
28        &self.0
29    }
30
31    /// Consumes the slug and returns the owned string.
32    pub fn into_string(self) -> String {
33        self.0
34    }
35}
36
37impl AsRef<str> for Slug {
38    fn as_ref(&self) -> &str {
39        self.as_str()
40    }
41}
42
43impl fmt::Display for Slug {
44    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
45        formatter.write_str(self.as_str())
46    }
47}
48
49/// Configures conservative slug shaping.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct SlugOptions {
52    /// Separator used between normalized words.
53    pub separator: SlugSeparator,
54    /// Optional maximum output length.
55    pub max_length: Option<usize>,
56}
57
58impl Default for SlugOptions {
59    fn default() -> Self {
60        Self {
61            separator: SlugSeparator::Dash,
62            max_length: None,
63        }
64    }
65}
66
67/// Supported separators for generated slugs.
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum SlugSeparator {
70    /// `-`
71    Dash,
72    /// `_`
73    Underscore,
74    /// Any other non-alphanumeric ASCII separator.
75    Custom(char),
76}
77
78impl SlugSeparator {
79    /// Returns the separator character.
80    pub const fn as_char(self) -> char {
81        match self {
82            Self::Dash => '-',
83            Self::Underscore => '_',
84            Self::Custom(character) => character,
85        }
86    }
87}
88
89/// Converts free-form text into a default slug.
90pub fn slugify(input: &str) -> String {
91    slugify_with_options(input, SlugOptions::default())
92}
93
94/// Normalizes a candidate slug using the default separator.
95pub fn normalize_slug(input: &str) -> String {
96    slugify(input)
97}
98
99/// Returns `true` when the input is already a normalized default slug.
100pub fn is_slug(input: &str) -> bool {
101    !input.is_empty()
102        && input == input.trim()
103        && !input.starts_with('-')
104        && !input.ends_with('-')
105        && !input.contains("--")
106        && input.chars().all(|character| {
107            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
108        })
109}
110
111/// Returns the normalized slug segments.
112pub fn slug_words(input: &str) -> Vec<String> {
113    let slug = slugify(input);
114    if slug.is_empty() {
115        return Vec::new();
116    }
117
118    slug.split('-').map(ToOwned::to_owned).collect()
119}
120
121/// Truncates a slug without leaving trailing separators when possible.
122pub fn truncate_slug(input: &str, max_length: usize) -> String {
123    let slug = slugify(input);
124    truncate_normalized_slug(&slug, max_length, '-')
125}
126
127fn slugify_with_options(input: &str, options: SlugOptions) -> String {
128    let separator = normalize_separator(options.separator);
129    let mut output = String::new();
130    let mut previous_was_separator = false;
131
132    for character in input.trim().chars() {
133        if character.is_ascii_alphanumeric() {
134            output.push(character.to_ascii_lowercase());
135            previous_was_separator = false;
136            continue;
137        }
138
139        if (character.is_whitespace()
140            || (character.is_ascii() && !character.is_ascii_alphanumeric()))
141            && !output.is_empty()
142            && !previous_was_separator
143        {
144            output.push(separator);
145            previous_was_separator = true;
146        }
147    }
148
149    while output.ends_with(separator) {
150        output.pop();
151    }
152
153    if let Some(max_length) = options.max_length {
154        return truncate_normalized_slug(&output, max_length, separator);
155    }
156
157    output
158}
159
160fn normalize_separator(separator: SlugSeparator) -> char {
161    let candidate = separator.as_char();
162    if candidate.is_ascii_alphanumeric() || candidate.is_ascii_whitespace() {
163        '-'
164    } else {
165        candidate
166    }
167}
168
169fn truncate_normalized_slug(slug: &str, max_length: usize, separator: char) -> String {
170    if max_length == 0 || slug.is_empty() {
171        return String::new();
172    }
173
174    if slug.len() <= max_length {
175        return slug.to_owned();
176    }
177
178    let prefix = &slug[..max_length];
179    if let Some(index) = prefix.rfind(separator) {
180        let candidate = prefix[..index].trim_end_matches(separator);
181        if !candidate.is_empty() {
182            return candidate.to_owned();
183        }
184    }
185
186    prefix.trim_end_matches(separator).to_owned()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::{
192        Slug, SlugOptions, SlugSeparator, is_slug, normalize_slug, slug_words, slugify,
193        truncate_slug,
194    };
195
196    #[test]
197    fn handles_empty_and_whitespace_only_input() {
198        assert_eq!(slugify(""), "");
199        assert_eq!(slugify("   \n"), "");
200        assert_eq!(slug_words("   \n"), Vec::<String>::new());
201    }
202
203    #[test]
204    fn normalizes_ascii_text_and_repeated_separators() {
205        assert_eq!(slugify("Release Candidate 1"), "release-candidate-1");
206        assert_eq!(slugify("release---candidate___1"), "release-candidate-1");
207        assert_eq!(normalize_slug("  release_candidate  "), "release-candidate");
208    }
209
210    #[test]
211    fn validates_and_truncates_slugs() {
212        assert!(is_slug("release-candidate-1"));
213        assert!(!is_slug("Release-Candidate-1"));
214        assert_eq!(truncate_slug("release candidate patch", 14), "release");
215        assert_eq!(
216            truncate_slug("release candidate patch", 20),
217            "release-candidate"
218        );
219    }
220
221    #[test]
222    fn exposes_slug_words_and_custom_options() {
223        assert_eq!(
224            slug_words("Release Candidate"),
225            vec![String::from("release"), String::from("candidate")]
226        );
227
228        let slug = Slug::from_text_with_options(
229            "Release Candidate",
230            SlugOptions {
231                separator: SlugSeparator::Underscore,
232                max_length: None,
233            },
234        );
235
236        assert_eq!(slug.as_str(), "release_candidate");
237    }
238
239    #[test]
240    fn treats_unicode_conservatively() {
241        assert_eq!(slugify("Élan vital"), "lan-vital");
242        assert_eq!(slugify("naïve façade"), "nave-faade");
243    }
244
245    #[test]
246    fn slug_type_validates_normalized_values() {
247        assert!(Slug::new("release-candidate").is_some());
248        assert!(Slug::new("Release Candidate").is_none());
249    }
250}