1use std::collections::HashMap;
9
10pub fn github_slugify(input: &str) -> String {
13 let lower = input.trim().to_lowercase();
14 let mut out = String::with_capacity(lower.len());
15 let mut prev_dash = false;
16 for ch in lower.chars() {
17 if ch.is_control() {
18 continue;
19 }
20 if ch.is_whitespace() {
21 if !prev_dash && !out.is_empty() {
22 out.push('-');
23 prev_dash = true;
24 }
25 continue;
26 }
27 if ch.is_alphanumeric() || ch == '_' || ch == '-' {
30 if ch == '-' {
31 if prev_dash {
32 continue;
33 }
34 out.push('-');
35 prev_dash = true;
36 } else {
37 out.push(ch);
38 prev_dash = false;
39 }
40 }
41 }
42 while out.ends_with('-') {
43 out.pop();
44 }
45 out
46}
47
48#[derive(Debug, Default)]
50pub struct Slugger {
51 seen: HashMap<String, u32>,
52}
53
54impl Slugger {
55 pub fn new() -> Self {
56 Self { seen: HashMap::new() }
57 }
58
59 pub fn slug(&mut self, text: &str) -> String {
62 let base = github_slugify(text);
63 let count = self.seen.entry(base.clone()).or_insert(0);
64 let out = if *count == 0 { base.clone() } else { format!("{}-{}", base, *count) };
65 *count += 1;
66 out
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn strips_dots() {
76 assert_eq!(github_slugify("0.4.3"), "043");
77 }
78
79 #[test]
80 fn strips_apostrophes() {
81 assert_eq!(github_slugify("How It's Built"), "how-its-built");
82 }
83
84 #[test]
85 fn replaces_spaces_with_dash() {
86 assert_eq!(github_slugify("Patch Changes"), "patch-changes");
87 }
88
89 #[test]
90 fn collapses_runs() {
91 assert_eq!(github_slugify("Hello -- World"), "hello-world");
92 assert_eq!(github_slugify("foo bar"), "foo-bar");
93 }
94
95 #[test]
96 fn dedupes() {
97 let mut s = Slugger::new();
98 assert_eq!(s.slug("Patch Changes"), "patch-changes");
99 assert_eq!(s.slug("Patch Changes"), "patch-changes-1");
100 assert_eq!(s.slug("Patch Changes"), "patch-changes-2");
101 }
102
103 #[test]
104 fn keeps_underscores_and_existing_dashes() {
105 assert_eq!(github_slugify("foo_bar-baz"), "foo_bar-baz");
106 }
107}