Skip to main content

use_case/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::error::Error;
5use std::fmt;
6
7/// Describes a detected or requested text case.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum TextCase {
10    /// Empty or whitespace-only input.
11    Empty,
12    /// Lowercase text without separators.
13    Lower,
14    /// Uppercase text without separators.
15    Upper,
16    /// Lowercase text separated by underscores.
17    Snake,
18    /// Lowercase text separated by hyphens.
19    Kebab,
20    /// Upper camel case such as `RustUse`.
21    Pascal,
22    /// Lower camel case such as `rustUse`.
23    Camel,
24    /// Title case separated by whitespace.
25    Title,
26    /// Uppercase text separated by underscores.
27    Constant,
28    /// Text that does not match a supported case shape.
29    Mixed,
30}
31
32/// A reusable case-conversion descriptor.
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub struct CaseConversion {
35    source: TextCase,
36    target: TextCase,
37}
38
39impl CaseConversion {
40    /// Creates a new conversion descriptor.
41    pub const fn new(source: TextCase, target: TextCase) -> Self {
42        Self { source, target }
43    }
44
45    /// Returns the declared source case.
46    pub const fn source(self) -> TextCase {
47        self.source
48    }
49
50    /// Returns the target case.
51    pub const fn target(self) -> TextCase {
52        self.target
53    }
54
55    /// Applies the conversion to the provided input.
56    pub fn apply(self, input: &str) -> Result<String, CaseError> {
57        convert_to_case(input, self.target)
58    }
59}
60
61/// Errors returned by [`CaseConversion`].
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum CaseError {
64    /// The requested target case is descriptive rather than convertible.
65    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
80/// Converts input into `snake_case`.
81pub fn to_snake_case(input: &str) -> String {
82    join_words(&normalized_words(input), "_")
83}
84
85/// Converts input into `kebab-case`.
86pub fn to_kebab_case(input: &str) -> String {
87    join_words(&normalized_words(input), "-")
88}
89
90/// Converts input into `PascalCase`.
91pub fn to_pascal_case(input: &str) -> String {
92    split_words(input)
93        .into_iter()
94        .map(|word| titlecase_word(&word))
95        .collect()
96}
97
98/// Converts input into `camelCase`.
99pub 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
112/// Converts input into title case separated by spaces.
113pub 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
121/// Converts input into `CONSTANT_CASE`.
122pub 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
130/// Detects the most practical case shape for the input.
131pub 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}