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 let needs_simple_boundary_hyphen = !last_was_sep && is_upper && last_was_lower;
32
33 let needs_acronym_boundary_check = !needs_simple_boundary_hyphen
35 && !last_was_sep
36 && is_upper
37 && chars.peek().is_some_and(|&next| next.is_lowercase())
38 && !result.is_empty()
39 && !result.chars().last().unwrap_or(' ').is_numeric();
40
41 let needs_acronym_hyphen = needs_acronym_boundary_check && {
43 let remaining: Vec<char> = chars.clone().collect();
45 matches!(remaining.as_slice(),
46 [next, _, ..] if next.is_lowercase()
48 )
49 };
50
51 if needs_simple_boundary_hyphen || needs_acronym_hyphen {
53 result.push('-');
54 }
55
56 for lower_ch in c.to_lowercase() {
58 result.push(lower_ch);
59 }
60
61 last_was_sep = false;
62 last_was_lower = c.is_lowercase() || c.is_numeric();
63 }
64 _ => {
65 let should_add_separator = !last_was_sep && !result.is_empty();
67 if should_add_separator {
68 result.push('-');
69 }
70 if should_add_separator {
72 last_was_sep = true;
73 last_was_lower = false;
74 }
75 }
76 }
77 }
78
79 result.trim_end_matches('-').to_string()
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn test_to_kebab_case() {
88 assert_eq!(
90 to_kebab_case("List an Organization's Issues"),
91 "list-an-organizations-issues"
92 );
93 assert_eq!(to_kebab_case("getUser"), "get-user");
94 assert_eq!(to_kebab_case("get_user_by_id"), "get-user-by-id");
95 assert_eq!(
96 to_kebab_case("Some---Multiple Spaces"),
97 "some-multiple-spaces"
98 );
99
100 assert_eq!(to_kebab_case("getUserByID"), "get-user-by-id");
102 assert_eq!(to_kebab_case("XMLHttpRequest"), "xml-http-request");
103 assert_eq!(to_kebab_case("Simple"), "simple");
104 assert_eq!(to_kebab_case("ALLCAPS"), "allcaps");
105 assert_eq!(
106 to_kebab_case("spaces between words"),
107 "spaces-between-words"
108 );
109 assert_eq!(to_kebab_case("special!@#$%^&*()chars"), "special-chars");
110 assert_eq!(to_kebab_case("trailing---"), "trailing");
111 assert_eq!(to_kebab_case("---leading"), "leading");
112 assert_eq!(to_kebab_case(""), "");
113 assert_eq!(to_kebab_case("a"), "a");
114 assert_eq!(to_kebab_case("A"), "a");
115
116 assert_eq!(to_kebab_case("don't"), "dont");
118 assert_eq!(to_kebab_case("it's"), "its");
119 assert_eq!(to_kebab_case("users'"), "users");
120
121 assert_eq!(to_kebab_case("IOError"), "io-error");
123 assert_eq!(to_kebab_case("HTTPSConnection"), "https-connection");
124 assert_eq!(to_kebab_case("getHTTPSConnection"), "get-https-connection");
125
126 assert_eq!(to_kebab_case("base64Encode"), "base64-encode");
128 assert_eq!(to_kebab_case("getV2API"), "get-v2-api");
129 assert_eq!(to_kebab_case("v2APIResponse"), "v2-api-response");
130
131 assert_eq!(
133 to_kebab_case("_startWithUnderscore"),
134 "start-with-underscore"
135 );
136 assert_eq!(to_kebab_case("endWithUnderscore_"), "end-with-underscore");
137 assert_eq!(
138 to_kebab_case("multiple___underscores"),
139 "multiple-underscores"
140 );
141 assert_eq!(to_kebab_case("mixedUP_down_CASE"), "mixed-up-down-case");
142 assert_eq!(to_kebab_case("123StartWithNumber"), "123-start-with-number");
143 assert_eq!(to_kebab_case("has123Numbers456"), "has123-numbers456");
144
145 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");
152 assert_eq!(to_kebab_case("price$99"), "price-99");
153 assert_eq!(to_kebab_case("100%Complete"), "100-complete");
154
155 assert_eq!(to_kebab_case("ABCDefg"), "abc-defg");
157 assert_eq!(to_kebab_case("HTTPSProxy"), "https-proxy");
158 assert_eq!(to_kebab_case("HTTPAPI"), "httpapi");
159 assert_eq!(to_kebab_case("HTTPAPIs"), "httpapis");
160
161 assert_eq!(
163 to_kebab_case("List an Organization's Projects"),
164 "list-an-organizations-projects"
165 );
166 assert_eq!(to_kebab_case("Update User's Avatar"), "update-users-avatar");
167 assert_eq!(
168 to_kebab_case("Delete Team's Repository Access"),
169 "delete-teams-repository-access"
170 );
171 }
172}