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