1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::fmt;
5
6#[derive(Clone, Debug, Eq, Hash, PartialEq)]
8pub struct Slug(String);
9
10impl Slug {
11 pub fn new(value: &str) -> Option<Self> {
13 is_slug(value).then(|| Self(value.to_owned()))
14 }
15
16 pub fn from_text(input: &str) -> Self {
18 Self(slugify(input))
19 }
20
21 pub fn from_text_with_options(input: &str, options: SlugOptions) -> Self {
23 Self(slugify_with_options(input, options))
24 }
25
26 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30
31 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub struct SlugOptions {
52 pub separator: SlugSeparator,
54 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum SlugSeparator {
70 Dash,
72 Underscore,
74 Custom(char),
76}
77
78impl SlugSeparator {
79 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
89pub fn slugify(input: &str) -> String {
91 slugify_with_options(input, SlugOptions::default())
92}
93
94pub fn normalize_slug(input: &str) -> String {
96 slugify(input)
97}
98
99pub 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
111pub 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
121pub 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}