1pub fn lower(s: &str) -> String {
8 s.to_lowercase()
9}
10
11pub fn upper(s: &str) -> String {
13 s.to_uppercase()
14}
15
16pub fn capitalize(s: &str) -> String {
19 let mut chars = s.chars();
20 match chars.next() {
21 None => String::new(),
22 Some(first) => {
23 let upper_first: String = first.to_uppercase().collect();
24 let lower_rest: String = chars.as_str().to_lowercase();
25 format!("{upper_first}{lower_rest}")
26 }
27 }
28}
29
30pub fn title(s: &str) -> String {
33 s.split_whitespace()
34 .map(capitalize)
35 .collect::<Vec<_>>()
36 .join(" ")
37}
38
39pub fn slug(s: &str) -> String {
42 slug_with_separator(s, "-")
43}
44
45pub fn slug_with_separator(s: &str, separator: &str) -> String {
47 let lowered = s.to_lowercase();
48 let mut result = String::with_capacity(lowered.len());
49 let mut prev_was_sep = true; for ch in lowered.chars() {
52 if ch.is_ascii_alphanumeric() {
53 result.push(ch);
54 prev_was_sep = false;
55 } else if !prev_was_sep {
56 result.push_str(separator);
57 prev_was_sep = true;
58 }
59 }
60
61 if result.ends_with(separator) {
62 result.truncate(result.len() - separator.len());
63 }
64
65 result
66}
67
68pub fn deslug(s: &str) -> String {
71 s.chars()
72 .map(|c| if c == '-' || c == '_' { ' ' } else { c })
73 .collect()
74}
75
76pub fn deslug_title(s: &str) -> String {
79 title(&deslug(s))
80}
81
82pub fn strip_non_ascii(s: &str, separator: &str) -> String {
85 let mut result = String::with_capacity(s.len());
86 let mut prev_was_sep = true;
87
88 for ch in s.chars() {
89 if ch.is_ascii_alphanumeric() {
90 result.push(ch);
91 prev_was_sep = false;
92 } else if ch.is_ascii() && !prev_was_sep {
93 result.push_str(separator);
94 prev_was_sep = true;
95 }
96 }
97
98 if result.ends_with(separator) {
99 result.truncate(result.len() - separator.len());
100 }
101
102 result
103}
104
105pub fn normalize_unicode(s: &str) -> String {
107 unicode_normalization::UnicodeNormalization::nfc(s).collect()
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
117 fn lower_basic() {
118 assert_eq!(lower("Hello World"), "hello world");
119 }
120
121 #[test]
122 fn lower_unicode() {
123 assert_eq!(lower("CAFÉ"), "café");
124 }
125
126 #[test]
127 fn lower_empty() {
128 assert_eq!(lower(""), "");
129 }
130
131 #[test]
134 fn upper_basic() {
135 assert_eq!(upper("hello world"), "HELLO WORLD");
136 }
137
138 #[test]
139 fn upper_unicode() {
140 assert_eq!(upper("café"), "CAFÉ");
141 }
142
143 #[test]
144 fn upper_empty() {
145 assert_eq!(upper(""), "");
146 }
147
148 #[test]
151 fn capitalize_basic() {
152 assert_eq!(capitalize("hello"), "Hello");
153 }
154
155 #[test]
156 fn capitalize_all_caps() {
157 assert_eq!(capitalize("HELLO"), "Hello");
158 }
159
160 #[test]
161 fn capitalize_empty() {
162 assert_eq!(capitalize(""), "");
163 }
164
165 #[test]
166 fn capitalize_unicode_first_char() {
167 assert_eq!(capitalize("über"), "Über");
168 }
169
170 #[test]
171 fn capitalize_single_char() {
172 assert_eq!(capitalize("a"), "A");
173 }
174
175 #[test]
178 fn title_multi_word() {
179 assert_eq!(title("hello world"), "Hello World");
180 }
181
182 #[test]
183 fn title_mixed_case() {
184 assert_eq!(title("hELLO wORLD"), "Hello World");
185 }
186
187 #[test]
188 fn title_extra_spaces() {
189 assert_eq!(title("hello world"), "Hello World");
191 }
192
193 #[test]
194 fn title_single_word() {
195 assert_eq!(title("hello"), "Hello");
196 }
197
198 #[test]
199 fn title_empty() {
200 assert_eq!(title(""), "");
201 }
202
203 #[test]
206 fn slug_spaces() {
207 assert_eq!(slug("Hello World"), "hello-world");
208 }
209
210 #[test]
211 fn slug_special_chars() {
212 assert_eq!(slug("file (copy).bak"), "file-copy-bak");
213 }
214
215 #[test]
216 fn slug_consecutive_separators() {
217 assert_eq!(slug("file---name...here"), "file-name-here");
218 }
219
220 #[test]
221 fn slug_leading_trailing() {
222 assert_eq!(slug(" Hello World! "), "hello-world");
223 }
224
225 #[test]
226 fn slug_unicode() {
227 assert_eq!(slug("Café Résumé"), "caf-r-sum");
229 }
230
231 #[test]
232 fn slug_already_clean() {
233 assert_eq!(slug("already-clean"), "already-clean");
234 }
235
236 #[test]
237 fn slug_with_custom_separator() {
238 assert_eq!(slug_with_separator("Hello World", "_"), "hello_world");
239 }
240
241 #[test]
244 fn deslug_hyphens() {
245 assert_eq!(deslug("vehicles-and-monsters"), "vehicles and monsters");
246 }
247
248 #[test]
249 fn deslug_underscores() {
250 assert_eq!(deslug("my_project_name"), "my project name");
251 }
252
253 #[test]
254 fn deslug_mixed() {
255 assert_eq!(deslug("my-project_name"), "my project name");
256 }
257
258 #[test]
259 fn deslug_no_separators() {
260 assert_eq!(deslug("hello"), "hello");
261 }
262
263 #[test]
264 fn deslug_empty() {
265 assert_eq!(deslug(""), "");
266 }
267
268 #[test]
271 fn deslug_title_hyphens() {
272 assert_eq!(
273 deslug_title("vehicles-and-monsters"),
274 "Vehicles And Monsters"
275 );
276 }
277
278 #[test]
279 fn deslug_title_underscores() {
280 assert_eq!(deslug_title("my_project_name"), "My Project Name");
281 }
282
283 #[test]
284 fn deslug_title_single_word() {
285 assert_eq!(deslug_title("hello"), "Hello");
286 }
287
288 #[test]
289 fn deslug_title_empty() {
290 assert_eq!(deslug_title(""), "");
291 }
292
293 #[test]
296 fn strip_non_ascii_accented() {
297 assert_eq!(strip_non_ascii("café résumé", "-"), "caf-rsum");
298 }
299
300 #[test]
301 fn strip_non_ascii_emoji() {
302 assert_eq!(strip_non_ascii("hello 🎉 world", "-"), "hello-world");
303 }
304
305 #[test]
306 fn strip_non_ascii_clean_input() {
307 assert_eq!(strip_non_ascii("hello", "-"), "hello");
308 }
309
310 #[test]
311 fn strip_non_ascii_preserves_case() {
312 assert_eq!(strip_non_ascii("Café", "-"), "Caf");
313 }
314
315 #[test]
318 fn normalize_composed_vs_decomposed() {
319 let nfd = "re\u{0301}sume\u{0301}";
321 let nfc = normalize_unicode(nfd);
322 assert_eq!(nfc, "r\u{00e9}sum\u{00e9}");
323 }
324
325 #[test]
326 fn normalize_already_nfc() {
327 let nfc = "résumé";
328 assert_eq!(normalize_unicode(nfc), nfc);
329 }
330
331 #[test]
332 fn normalize_empty() {
333 assert_eq!(normalize_unicode(""), "");
334 }
335}