1pub fn slugify(title: &str) -> String {
15 let mut slug = String::with_capacity(title.len());
16
17 for ch in title.chars() {
18 if ch.is_ascii_alphanumeric() {
19 slug.push(ch.to_ascii_lowercase());
20 } else if ch == '-' || ch == '_' || ch == ' ' {
21 slug.push('-');
22 }
23 }
25
26 let mut result = String::with_capacity(slug.len());
28 let mut prev_hyphen = false;
29 for ch in slug.chars() {
30 if ch == '-' {
31 if !prev_hyphen {
32 result.push('-');
33 }
34 prev_hyphen = true;
35 } else {
36 result.push(ch);
37 prev_hyphen = false;
38 }
39 }
40
41 result.trim_matches('-').to_string()
43}
44
45pub fn title_from_stem(stem: &str) -> String {
47 stem.split('-')
48 .map(|word| {
49 let mut chars = word.chars();
50 match chars.next() {
51 None => String::new(),
52 Some(first) => {
53 let mut s = first.to_uppercase().to_string();
54 s.extend(chars);
55 s
56 }
57 }
58 })
59 .collect::<Vec<_>>()
60 .join(" ")
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
68 fn basic_slugify() {
69 assert_eq!(slugify("Classic Chicken Adobo"), "classic-chicken-adobo");
70 }
71
72 #[test]
73 fn slugify_with_special_chars() {
74 assert_eq!(slugify("Pasta alla Norma!"), "pasta-alla-norma");
75 }
76
77 #[test]
78 fn slugify_collapses_hyphens() {
79 assert_eq!(slugify("one---two"), "one-two");
80 }
81
82 #[test]
83 fn slugify_trims_edges() {
84 assert_eq!(slugify(" hello "), "hello");
85 }
86
87 #[test]
88 fn slugify_drops_non_ascii() {
89 assert_eq!(slugify("Crème Brûlée"), "crme-brle");
90 }
91
92 #[test]
93 fn slugify_mixed_unicode() {
94 assert_eq!(slugify("Mapo Tofu (四川麻婆豆腐)"), "mapo-tofu");
95 }
96
97 #[test]
98 fn title_from_stem_basic() {
99 assert_eq!(title_from_stem("chicken-adobo"), "Chicken Adobo");
100 }
101
102 #[test]
103 fn title_from_stem_single_word() {
104 assert_eq!(title_from_stem("sourdough"), "Sourdough");
105 }
106}