1#[must_use]
18pub fn to_kebab_case(s: &str) -> String {
19 let mut result = String::new();
20 let mut chars = s.chars().peekable();
21 let mut last_was_sep = true;
22 let mut last_was_lower = false;
23
24 while let Some(ch) = chars.next() {
25 match ch {
26 '\'' => {} c if c.is_alphanumeric() => {
28 let is_upper = c.is_uppercase();
29
30 if !last_was_sep && is_upper && last_was_lower {
32 result.push('-');
33 } else if !last_was_sep
34 && is_upper
35 && chars.peek().is_some_and(|&next| next.is_lowercase())
36 && !result.is_empty()
37 && !result.chars().last().unwrap_or(' ').is_numeric()
38 {
39 let remaining: Vec<char> = chars.clone().collect();
43 let should_add_hyphen = match remaining.as_slice() {
44 [next, _, ..] if next.is_lowercase() => true,
46 [next] if next.is_lowercase() => false,
49 _ => false,
51 };
52
53 if should_add_hyphen {
54 result.push('-');
55 }
56 }
57
58 for lower_ch in c.to_lowercase() {
60 result.push(lower_ch);
61 }
62
63 last_was_sep = false;
64 last_was_lower = c.is_lowercase() || c.is_numeric();
65 }
66 _ => {
67 if !last_was_sep && !result.is_empty() {
69 result.push('-');
70 last_was_sep = true;
71 last_was_lower = false;
72 }
73 }
74 }
75 }
76
77 result.trim_end_matches('-').to_string()
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83
84 #[test]
85 fn test_to_kebab_case() {
86 assert_eq!(
88 to_kebab_case("List an Organization's Issues"),
89 "list-an-organizations-issues"
90 );
91 assert_eq!(to_kebab_case("getUser"), "get-user");
92 assert_eq!(to_kebab_case("get_user_by_id"), "get-user-by-id");
93 assert_eq!(
94 to_kebab_case("Some---Multiple Spaces"),
95 "some-multiple-spaces"
96 );
97
98 assert_eq!(to_kebab_case("getUserByID"), "get-user-by-id");
100 assert_eq!(to_kebab_case("XMLHttpRequest"), "xml-http-request");
101 assert_eq!(to_kebab_case("Simple"), "simple");
102 assert_eq!(to_kebab_case("ALLCAPS"), "allcaps");
103 assert_eq!(
104 to_kebab_case("spaces between words"),
105 "spaces-between-words"
106 );
107 assert_eq!(to_kebab_case("special!@#$%^&*()chars"), "special-chars");
108 assert_eq!(to_kebab_case("trailing---"), "trailing");
109 assert_eq!(to_kebab_case("---leading"), "leading");
110 assert_eq!(to_kebab_case(""), "");
111 assert_eq!(to_kebab_case("a"), "a");
112 assert_eq!(to_kebab_case("A"), "a");
113
114 assert_eq!(to_kebab_case("don't"), "dont");
116 assert_eq!(to_kebab_case("it's"), "its");
117 assert_eq!(to_kebab_case("users'"), "users");
118
119 assert_eq!(to_kebab_case("IOError"), "io-error");
121 assert_eq!(to_kebab_case("HTTPSConnection"), "https-connection");
122 assert_eq!(to_kebab_case("getHTTPSConnection"), "get-https-connection");
123
124 assert_eq!(to_kebab_case("base64Encode"), "base64-encode");
126 assert_eq!(to_kebab_case("getV2API"), "get-v2-api");
127 assert_eq!(to_kebab_case("v2APIResponse"), "v2-api-response");
128
129 assert_eq!(
131 to_kebab_case("_startWithUnderscore"),
132 "start-with-underscore"
133 );
134 assert_eq!(to_kebab_case("endWithUnderscore_"), "end-with-underscore");
135 assert_eq!(
136 to_kebab_case("multiple___underscores"),
137 "multiple-underscores"
138 );
139 assert_eq!(to_kebab_case("mixedUP_down_CASE"), "mixed-up-down-case");
140 assert_eq!(to_kebab_case("123StartWithNumber"), "123-start-with-number");
141 assert_eq!(to_kebab_case("has123Numbers456"), "has123-numbers456");
142
143 assert_eq!(to_kebab_case("café"), "café"); assert_eq!(to_kebab_case("CAFÉ"), "café"); assert_eq!(to_kebab_case("ÑOÑO"), "ñoño"); assert_eq!(to_kebab_case("ÄÖÜ"), "äöü"); assert_eq!(to_kebab_case("МОСКВА"), "москва"); assert_eq!(to_kebab_case("hello@world.com"), "hello-world-com");
150 assert_eq!(to_kebab_case("price$99"), "price-99");
151 assert_eq!(to_kebab_case("100%Complete"), "100-complete");
152
153 assert_eq!(to_kebab_case("ABCDefg"), "abc-defg");
155 assert_eq!(to_kebab_case("HTTPSProxy"), "https-proxy");
156 assert_eq!(to_kebab_case("HTTPAPI"), "httpapi");
157 assert_eq!(to_kebab_case("HTTPAPIs"), "httpapis");
158
159 assert_eq!(
161 to_kebab_case("List an Organization's Projects"),
162 "list-an-organizations-projects"
163 );
164 assert_eq!(to_kebab_case("Update User's Avatar"), "update-users-avatar");
165 assert_eq!(
166 to_kebab_case("Delete Team's Repository Access"),
167 "delete-teams-repository-access"
168 );
169 }
170}