1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::error::Error;
5use std::fmt;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum TextCase {
10 Empty,
12 Lower,
14 Upper,
16 Snake,
18 Kebab,
20 Pascal,
22 Camel,
24 Title,
26 Constant,
28 Mixed,
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub struct CaseConversion {
35 source: TextCase,
36 target: TextCase,
37}
38
39impl CaseConversion {
40 pub const fn new(source: TextCase, target: TextCase) -> Self {
42 Self { source, target }
43 }
44
45 pub const fn source(self) -> TextCase {
47 self.source
48 }
49
50 pub const fn target(self) -> TextCase {
52 self.target
53 }
54
55 pub fn apply(self, input: &str) -> Result<String, CaseError> {
57 convert_to_case(input, self.target)
58 }
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum CaseError {
64 UnsupportedTarget(TextCase),
66}
67
68impl fmt::Display for CaseError {
69 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
70 match self {
71 Self::UnsupportedTarget(case) => {
72 write!(formatter, "unsupported conversion target: {case:?}")
73 },
74 }
75 }
76}
77
78impl Error for CaseError {}
79
80pub fn to_snake_case(input: &str) -> String {
82 join_words(&normalized_words(input), "_")
83}
84
85pub fn to_kebab_case(input: &str) -> String {
87 join_words(&normalized_words(input), "-")
88}
89
90pub fn to_pascal_case(input: &str) -> String {
92 split_words(input)
93 .into_iter()
94 .map(|word| titlecase_word(&word))
95 .collect()
96}
97
98pub fn to_camel_case(input: &str) -> String {
100 let words = normalized_words(input);
101 let Some((first, rest)) = words.split_first() else {
102 return String::new();
103 };
104
105 let mut output = first.clone();
106 for word in rest {
107 output.push_str(&titlecase_word(word));
108 }
109 output
110}
111
112pub fn to_title_case(input: &str) -> String {
114 split_words(input)
115 .into_iter()
116 .map(|word| titlecase_word(&word))
117 .collect::<Vec<_>>()
118 .join(" ")
119}
120
121pub fn to_constant_case(input: &str) -> String {
123 split_words(input)
124 .into_iter()
125 .map(|word| uppercase_word(&word))
126 .collect::<Vec<_>>()
127 .join("_")
128}
129
130pub fn detect_case(input: &str) -> TextCase {
132 let trimmed = input.trim();
133
134 if trimmed.is_empty() {
135 return TextCase::Empty;
136 }
137
138 if is_constant_case(trimmed) {
139 return TextCase::Constant;
140 }
141
142 if is_snake_case(trimmed) {
143 return TextCase::Snake;
144 }
145
146 if is_kebab_case(trimmed) {
147 return TextCase::Kebab;
148 }
149
150 if is_title_case(trimmed) {
151 return TextCase::Title;
152 }
153
154 if is_pascal_case(trimmed) {
155 return TextCase::Pascal;
156 }
157
158 if is_camel_case(trimmed) {
159 return TextCase::Camel;
160 }
161
162 if is_upper_case(trimmed) {
163 return TextCase::Upper;
164 }
165
166 if is_lower_case(trimmed) {
167 return TextCase::Lower;
168 }
169
170 TextCase::Mixed
171}
172
173fn convert_to_case(input: &str, target: TextCase) -> Result<String, CaseError> {
174 let output = match target {
175 TextCase::Empty => return Ok(String::new()),
176 TextCase::Lower => lowercase_word(input.trim()),
177 TextCase::Upper => uppercase_word(input.trim()),
178 TextCase::Snake => to_snake_case(input),
179 TextCase::Kebab => to_kebab_case(input),
180 TextCase::Pascal => to_pascal_case(input),
181 TextCase::Camel => to_camel_case(input),
182 TextCase::Title => to_title_case(input),
183 TextCase::Constant => to_constant_case(input),
184 TextCase::Mixed => return Err(CaseError::UnsupportedTarget(TextCase::Mixed)),
185 };
186
187 Ok(output)
188}
189
190fn split_words(input: &str) -> Vec<String> {
191 let trimmed = input.trim();
192 if trimmed.is_empty() {
193 return Vec::new();
194 }
195
196 let mut words = Vec::new();
197 let mut chunk = String::new();
198
199 for character in trimmed.chars() {
200 if character.is_alphanumeric() {
201 chunk.push(character);
202 } else if !chunk.is_empty() {
203 extend_chunk_words(&chunk, &mut words);
204 chunk.clear();
205 }
206 }
207
208 if !chunk.is_empty() {
209 extend_chunk_words(&chunk, &mut words);
210 }
211
212 words
213}
214
215fn extend_chunk_words(chunk: &str, words: &mut Vec<String>) {
216 let characters: Vec<char> = chunk.chars().collect();
217 if characters.is_empty() {
218 return;
219 }
220
221 let mut start = 0;
222 for index in 1..characters.len() {
223 let previous = characters[index - 1];
224 let current = characters[index];
225 let next = characters.get(index + 1).copied();
226
227 if is_chunk_boundary(previous, current, next) {
228 words.push(characters[start..index].iter().collect());
229 start = index;
230 }
231 }
232
233 words.push(characters[start..].iter().collect());
234}
235
236fn is_chunk_boundary(previous: char, current: char, next: Option<char>) -> bool {
237 (previous.is_lowercase() && current.is_uppercase())
238 || (previous.is_alphabetic() && current.is_numeric())
239 || (previous.is_numeric() && current.is_alphabetic())
240 || (previous.is_uppercase()
241 && current.is_uppercase()
242 && next.is_some_and(|candidate| candidate.is_lowercase()))
243}
244
245fn normalized_words(input: &str) -> Vec<String> {
246 split_words(input)
247 .into_iter()
248 .map(|word| lowercase_word(&word))
249 .collect()
250}
251
252fn join_words(words: &[String], separator: &str) -> String {
253 words.join(separator)
254}
255
256fn lowercase_word(word: &str) -> String {
257 word.chars().flat_map(char::to_lowercase).collect()
258}
259
260fn uppercase_word(word: &str) -> String {
261 word.chars().flat_map(char::to_uppercase).collect()
262}
263
264fn titlecase_word(word: &str) -> String {
265 let mut characters = word.chars();
266 let Some(first) = characters.next() else {
267 return String::new();
268 };
269
270 first
271 .to_uppercase()
272 .chain(characters.flat_map(char::to_lowercase))
273 .collect()
274}
275
276fn is_snake_case(input: &str) -> bool {
277 input.contains('_')
278 && !input.starts_with('_')
279 && !input.ends_with('_')
280 && !input.contains("__")
281 && input.chars().all(|character| {
282 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
283 })
284}
285
286fn is_constant_case(input: &str) -> bool {
287 input.contains('_')
288 && !input.starts_with('_')
289 && !input.ends_with('_')
290 && !input.contains("__")
291 && input.chars().all(|character| {
292 character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
293 })
294}
295
296fn is_kebab_case(input: &str) -> bool {
297 input.contains('-')
298 && !input.starts_with('-')
299 && !input.ends_with('-')
300 && !input.contains("--")
301 && input.chars().all(|character| {
302 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
303 })
304}
305
306fn is_title_case(input: &str) -> bool {
307 input.contains(char::is_whitespace)
308 && input.split_whitespace().all(|word| {
309 let mut characters = word.chars();
310 let Some(first) = characters.next() else {
311 return false;
312 };
313
314 first.is_uppercase()
315 && characters
316 .all(|character| !character.is_alphabetic() || character.is_lowercase())
317 })
318}
319
320fn is_pascal_case(input: &str) -> bool {
321 !input.contains(|character: char| !character.is_alphanumeric())
322 && input.chars().next().is_some_and(char::is_uppercase)
323 && !is_upper_case(input)
324}
325
326fn is_camel_case(input: &str) -> bool {
327 !input.contains(|character: char| !character.is_alphanumeric())
328 && input.chars().next().is_some_and(char::is_lowercase)
329 && input.chars().any(char::is_uppercase)
330}
331
332fn is_lower_case(input: &str) -> bool {
333 !input.contains(|character: char| !character.is_alphanumeric())
334 && input
335 .chars()
336 .all(|character| !character.is_alphabetic() || character.is_lowercase())
337}
338
339fn is_upper_case(input: &str) -> bool {
340 !input.contains(|character: char| !character.is_alphanumeric())
341 && input
342 .chars()
343 .all(|character| !character.is_alphabetic() || character.is_uppercase())
344}
345
346#[cfg(test)]
347mod tests {
348 use super::{
349 CaseConversion, CaseError, TextCase, detect_case, to_camel_case, to_constant_case,
350 to_kebab_case, to_pascal_case, to_snake_case, to_title_case,
351 };
352
353 #[test]
354 fn handles_empty_and_whitespace_only_input() {
355 assert_eq!(to_snake_case(""), "");
356 assert_eq!(to_kebab_case(" \t"), "");
357 assert_eq!(detect_case(" \n"), TextCase::Empty);
358 }
359
360 #[test]
361 fn converts_ascii_and_mixed_case_inputs() {
362 assert_eq!(to_snake_case("HTTPServerError"), "http_server_error");
363 assert_eq!(to_kebab_case("userProfile"), "user-profile");
364 assert_eq!(to_pascal_case("user_profile"), "UserProfile");
365 assert_eq!(to_camel_case("user-profile"), "userProfile");
366 assert_eq!(to_title_case("user_profile id"), "User Profile Id");
367 assert_eq!(to_constant_case("userProfile42"), "USER_PROFILE_42");
368 }
369
370 #[test]
371 fn collapses_punctuation_and_repeated_separators() {
372 assert_eq!(to_snake_case("hello---world"), "hello_world");
373 assert_eq!(to_kebab_case("hello___world"), "hello-world");
374 assert_eq!(
375 to_title_case(" release...candidate "),
376 "Release Candidate"
377 );
378 }
379
380 #[test]
381 fn supports_unicode_case_conversions_conservatively() {
382 assert_eq!(to_title_case("élan vital"), "Élan Vital");
383 assert_eq!(to_snake_case("Straße Mode"), "straße_mode");
384 }
385
386 #[test]
387 fn detects_supported_cases() {
388 assert_eq!(detect_case("snake_case"), TextCase::Snake);
389 assert_eq!(detect_case("kebab-case"), TextCase::Kebab);
390 assert_eq!(detect_case("Title Case"), TextCase::Title);
391 assert_eq!(detect_case("CamelCase"), TextCase::Pascal);
392 assert_eq!(detect_case("camelCase"), TextCase::Camel);
393 assert_eq!(detect_case("MIXED_case-value"), TextCase::Mixed);
394 }
395
396 #[test]
397 fn case_conversion_reports_unsupported_targets() {
398 let conversion = CaseConversion::new(TextCase::Snake, TextCase::Mixed);
399 assert_eq!(
400 conversion.apply("hello_world"),
401 Err(CaseError::UnsupportedTarget(TextCase::Mixed))
402 );
403 }
404
405 #[test]
406 fn case_conversion_applies_supported_targets() {
407 let conversion = CaseConversion::new(TextCase::Snake, TextCase::Pascal);
408 assert_eq!(
409 conversion.apply("hello_world"),
410 Ok(String::from("HelloWorld"))
411 );
412 assert_eq!(conversion.source(), TextCase::Snake);
413 assert_eq!(conversion.target(), TextCase::Pascal);
414 }
415}