Skip to main content

spikard_cli/codegen/common/
case_conversion.rs

1//! Case conversion utilities for codegen.
2//!
3//! Provides unified case conversion functions used across all code generators
4//! (Python, Ruby, PHP, TypeScript, Rust). Handles edge cases like consecutive
5//! uppercase letters (acronyms) and preserves leading/trailing underscores.
6
7/// Convert string to `snake_case`.
8///
9/// Converts camelCase, `PascalCase`, and other formats to `snake_case` by inserting
10/// underscores before uppercase letters and converting them to lowercase.
11///
12/// Edge cases:
13/// - Consecutive uppercase letters (acronyms) like "`HTTPServer`" → "`http_server`"
14/// - Leading/trailing underscores are preserved
15/// - Already `snake_case` strings pass through unchanged
16///
17/// # Examples
18///
19/// ```
20/// use spikard_cli::codegen::common::case_conversion::to_snake_case;
21/// assert_eq!(to_snake_case("user"), "user");
22/// assert_eq!(to_snake_case("getUser"), "get_user");
23/// assert_eq!(to_snake_case("createUserProfile"), "create_user_profile");
24/// assert_eq!(to_snake_case("HTTPServer"), "http_server");
25/// assert_eq!(to_snake_case("GraphQLType"), "graph_ql_type"); // Splits on each uppercase
26/// assert_eq!(to_snake_case("_id"), "_id");
27/// assert_eq!(to_snake_case("id_"), "id_");
28/// ```
29#[must_use]
30pub fn to_snake_case(s: &str) -> String {
31    if s.is_empty() {
32        return String::new();
33    }
34
35    let mut result = String::new();
36    let chars: Vec<char> = s.chars().collect();
37
38    for (i, &ch) in chars.iter().enumerate() {
39        if ch.is_uppercase() {
40            let should_add_underscore = if i == 0 {
41                false
42            } else if result.ends_with('_') {
43                false
44            } else {
45                let prev_is_lower = chars[i - 1].is_lowercase();
46                let prev_is_digit = chars[i - 1].is_numeric();
47                let next_is_lower = (i + 1 < chars.len()) && chars[i + 1].is_lowercase();
48
49                prev_is_lower || prev_is_digit || (i > 0 && chars[i - 1].is_uppercase() && next_is_lower)
50            };
51
52            if should_add_underscore {
53                result.push('_');
54            }
55            result.push_str(&ch.to_lowercase().to_string());
56        } else {
57            result.push(ch);
58        }
59    }
60
61    result
62}
63
64/// Convert string to camelCase.
65///
66/// Converts `snake_case` and other formats to camelCase by capitalizing the first
67/// letter of each word (except the first word) and removing separators.
68///
69/// Edge cases:
70/// - First word stays lowercase
71/// - Consecutive separators are treated as single separator
72/// - Leading/trailing separators produce leading/trailing underscores
73///
74/// # Examples
75///
76/// ```
77/// use spikard_cli::codegen::common::case_conversion::to_camel_case;
78/// assert_eq!(to_camel_case("user"), "user");
79/// assert_eq!(to_camel_case("get_user"), "getUser");
80/// assert_eq!(to_camel_case("create_user_profile"), "createUserProfile");
81/// assert_eq!(to_camel_case("_id"), "_id");
82/// assert_eq!(to_camel_case("id_"), "id_");
83/// ```
84#[must_use]
85pub fn to_camel_case(s: &str) -> String {
86    if s.is_empty() {
87        return String::new();
88    }
89
90    let parts: Vec<&str> = s.split('_').collect();
91    if parts.is_empty() {
92        return String::new();
93    }
94
95    let has_leading_underscore = s.starts_with('_');
96    let has_trailing_underscore = s.ends_with('_');
97
98    let mut result = if has_leading_underscore {
99        String::from("_")
100    } else {
101        String::new()
102    };
103
104    let non_empty_parts: Vec<&str> = parts.iter().filter(|p| !p.is_empty()).copied().collect();
105
106    if non_empty_parts.is_empty() {
107        if has_trailing_underscore {
108            result.push('_');
109        }
110        return result;
111    }
112
113    result.push_str(non_empty_parts[0]);
114
115    for part in &non_empty_parts[1..] {
116        if let Some(first_char) = part.chars().next() {
117            result.push_str(&first_char.to_uppercase().to_string());
118            result.push_str(&part[first_char.len_utf8()..]);
119        }
120    }
121
122    if has_trailing_underscore {
123        result.push('_');
124    }
125
126    result
127}
128
129/// Convert string to `PascalCase`.
130///
131/// Converts `snake_case` and other formats to `PascalCase` by capitalizing the first
132/// letter of every word and removing separators. First word is also capitalized.
133///
134/// Edge cases:
135/// - All words are capitalized (unlike camelCase)
136/// - Non-alphanumeric characters are treated as separators
137/// - Leading/trailing separators are removed
138///
139/// # Examples
140///
141/// ```
142/// use spikard_cli::codegen::common::case_conversion::to_pascal_case;
143/// assert_eq!(to_pascal_case("user"), "User");
144/// assert_eq!(to_pascal_case("get_user"), "GetUser");
145/// assert_eq!(to_pascal_case("create_user_profile"), "CreateUserProfile");
146/// assert_eq!(to_pascal_case("http_server"), "HttpServer");
147/// assert_eq!(to_pascal_case("graphql-type"), "GraphqlType");
148/// ```
149#[must_use]
150pub fn to_pascal_case(s: &str) -> String {
151    if s.is_empty() {
152        return String::new();
153    }
154
155    let parts: Vec<&str> = s.split(|c: char| !c.is_alphanumeric()).collect();
156
157    parts
158        .into_iter()
159        .filter(|p| !p.is_empty())
160        .map(|part| {
161            let mut chars = part.chars();
162            match chars.next() {
163                None => String::new(),
164                Some(first) => {
165                    let mut result = first.to_uppercase().collect::<String>();
166                    result.push_str(chars.as_str());
167                    result
168                }
169            }
170        })
171        .collect()
172}
173
174/// Convert string to kebab-case.
175///
176/// Converts camelCase, `PascalCase`, and other formats to kebab-case by inserting
177/// hyphens before uppercase letters and converting them to lowercase.
178///
179/// Edge cases:
180/// - Consecutive uppercase letters (acronyms) like "`HTTPServer`" → "http-server"
181/// - Leading/trailing hyphens are removed
182/// - Already kebab-case strings pass through unchanged
183///
184/// # Examples
185///
186/// ```
187/// use spikard_cli::codegen::common::case_conversion::to_kebab_case;
188/// assert_eq!(to_kebab_case("user"), "user");
189/// assert_eq!(to_kebab_case("getUser"), "get-user");
190/// assert_eq!(to_kebab_case("createUserProfile"), "create-user-profile");
191/// assert_eq!(to_kebab_case("HTTPServer"), "http-server");
192/// assert_eq!(to_kebab_case("GraphQLType"), "graph-ql-type"); // Splits on each uppercase
193/// ```
194#[must_use]
195pub fn to_kebab_case(s: &str) -> String {
196    if s.is_empty() {
197        return String::new();
198    }
199
200    let mut result = String::new();
201    let chars: Vec<char> = s.chars().collect();
202
203    for (i, &ch) in chars.iter().enumerate() {
204        if ch.is_uppercase() {
205            let should_add_hyphen = if i == 0 || result.ends_with('-') {
206                false
207            } else {
208                let prev_is_lower = chars[i - 1].is_lowercase();
209                let prev_is_digit = chars[i - 1].is_numeric();
210                let next_is_lower = (i + 1 < chars.len()) && chars[i + 1].is_lowercase();
211
212                prev_is_lower || prev_is_digit || (i > 0 && chars[i - 1].is_uppercase() && next_is_lower)
213            };
214
215            if should_add_hyphen {
216                result.push('-');
217            }
218            result.push_str(&ch.to_lowercase().to_string());
219        } else if ch == '_' {
220            if !result.ends_with('-') {
221                result.push('-');
222            }
223        } else {
224            result.push(ch);
225        }
226    }
227
228    result.trim_matches('-').to_string()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_to_snake_case_simple() {
237        assert_eq!(to_snake_case("user"), "user");
238        assert_eq!(to_snake_case("name"), "name");
239        assert_eq!(to_snake_case("id"), "id");
240    }
241
242    #[test]
243    fn test_to_snake_case_camel_case() {
244        assert_eq!(to_snake_case("getUser"), "get_user");
245        assert_eq!(to_snake_case("userName"), "user_name");
246        assert_eq!(to_snake_case("userId"), "user_id");
247    }
248
249    #[test]
250    fn test_to_snake_case_pascal_case() {
251        assert_eq!(to_snake_case("GetUser"), "get_user");
252        assert_eq!(to_snake_case("UserName"), "user_name");
253        assert_eq!(to_snake_case("CreateUserProfile"), "create_user_profile");
254    }
255
256    #[test]
257    fn test_to_snake_case_acronyms() {
258        assert_eq!(to_snake_case("HTTPServer"), "http_server");
259        assert_eq!(to_snake_case("GraphQLType"), "graph_ql_type");
260        assert_eq!(to_snake_case("XMLHttpRequest"), "xml_http_request");
261        assert_eq!(to_snake_case("IOError"), "io_error");
262        assert_eq!(to_snake_case("URLPath"), "url_path");
263    }
264
265    #[test]
266    fn test_to_snake_case_consecutive_caps() {
267        assert_eq!(to_snake_case("ID"), "id");
268        assert_eq!(to_snake_case("HTTPSConnection"), "https_connection");
269        assert_eq!(to_snake_case("JSONData"), "json_data");
270    }
271
272    #[test]
273    fn test_to_snake_case_leading_underscore() {
274        assert_eq!(to_snake_case("_id"), "_id");
275        assert_eq!(to_snake_case("_private"), "_private");
276        assert_eq!(to_snake_case("_getUser"), "_get_user");
277    }
278
279    #[test]
280    fn test_to_snake_case_trailing_underscore() {
281        assert_eq!(to_snake_case("id_"), "id_");
282        assert_eq!(to_snake_case("name_"), "name_");
283        assert_eq!(to_snake_case("getUserName_"), "get_user_name_");
284    }
285
286    #[test]
287    fn test_to_snake_case_already_snake_case() {
288        assert_eq!(to_snake_case("get_user"), "get_user");
289        assert_eq!(to_snake_case("create_user_profile"), "create_user_profile");
290        assert_eq!(to_snake_case("http_server"), "http_server");
291    }
292
293    #[test]
294    fn test_to_snake_case_mixed_separators() {
295        assert_eq!(to_snake_case("getUser_Name"), "get_user_name");
296        assert_eq!(to_snake_case("_private_field_"), "_private_field_");
297    }
298
299    #[test]
300    fn test_to_snake_case_numbers() {
301        assert_eq!(to_snake_case("user123"), "user123");
302        assert_eq!(to_snake_case("getUser123"), "get_user123");
303        assert_eq!(to_snake_case("User123Name"), "user123_name");
304    }
305
306    #[test]
307    fn test_to_snake_case_empty() {
308        assert_eq!(to_snake_case(""), "");
309    }
310
311    #[test]
312    fn test_to_snake_case_single_char() {
313        assert_eq!(to_snake_case("a"), "a");
314        assert_eq!(to_snake_case("A"), "a");
315        assert_eq!(to_snake_case("_"), "_");
316    }
317
318    #[test]
319    fn test_to_camel_case_simple() {
320        assert_eq!(to_camel_case("user"), "user");
321        assert_eq!(to_camel_case("name"), "name");
322        assert_eq!(to_camel_case("id"), "id");
323    }
324
325    #[test]
326    fn test_to_camel_case_snake_case() {
327        assert_eq!(to_camel_case("get_user"), "getUser");
328        assert_eq!(to_camel_case("user_name"), "userName");
329        assert_eq!(to_camel_case("user_id"), "userId");
330    }
331
332    #[test]
333    fn test_to_camel_case_multiple_words() {
334        assert_eq!(to_camel_case("create_user_profile"), "createUserProfile");
335        assert_eq!(to_camel_case("get_user_by_id"), "getUserById");
336        assert_eq!(to_camel_case("http_server_config"), "httpServerConfig");
337    }
338
339    #[test]
340    fn test_to_camel_case_pascal_case_input() {
341        assert_eq!(to_camel_case("GetUser"), "GetUser");
342        assert_eq!(to_camel_case("UserName"), "UserName");
343    }
344
345    #[test]
346    fn test_to_camel_case_leading_underscore() {
347        assert_eq!(to_camel_case("_id"), "_id");
348        assert_eq!(to_camel_case("_get_user"), "_getUser");
349        assert_eq!(to_camel_case("_private"), "_private");
350    }
351
352    #[test]
353    fn test_to_camel_case_trailing_underscore() {
354        assert_eq!(to_camel_case("id_"), "id_");
355        assert_eq!(to_camel_case("get_user_"), "getUser_");
356    }
357
358    #[test]
359    fn test_to_camel_case_consecutive_separators() {
360        assert_eq!(to_camel_case("get__user"), "getUser");
361        assert_eq!(to_camel_case("user___name"), "userName");
362    }
363
364    #[test]
365    fn test_to_camel_case_numbers() {
366        assert_eq!(to_camel_case("user_123"), "user123");
367        assert_eq!(to_camel_case("get_user_123"), "getUser123");
368    }
369
370    #[test]
371    fn test_to_camel_case_empty() {
372        assert_eq!(to_camel_case(""), "");
373    }
374
375    #[test]
376    fn test_to_camel_case_single_char() {
377        assert_eq!(to_camel_case("a"), "a");
378        assert_eq!(to_camel_case("_"), "__");
379    }
380
381    #[test]
382    fn test_to_pascal_case_simple() {
383        assert_eq!(to_pascal_case("user"), "User");
384        assert_eq!(to_pascal_case("name"), "Name");
385        assert_eq!(to_pascal_case("id"), "Id");
386    }
387
388    #[test]
389    fn test_to_pascal_case_snake_case() {
390        assert_eq!(to_pascal_case("get_user"), "GetUser");
391        assert_eq!(to_pascal_case("user_name"), "UserName");
392        assert_eq!(to_pascal_case("create_user_profile"), "CreateUserProfile");
393    }
394
395    #[test]
396    fn test_to_pascal_case_camel_case() {
397        assert_eq!(to_pascal_case("getUser"), "GetUser");
398        assert_eq!(to_pascal_case("userName"), "UserName");
399        assert_eq!(to_pascal_case("createUserProfile"), "CreateUserProfile");
400    }
401
402    #[test]
403    fn test_to_pascal_case_kebab_case() {
404        assert_eq!(to_pascal_case("get-user"), "GetUser");
405        assert_eq!(to_pascal_case("user-name"), "UserName");
406        assert_eq!(to_pascal_case("http-server"), "HttpServer");
407    }
408
409    #[test]
410    fn test_to_pascal_case_mixed_separators() {
411        assert_eq!(to_pascal_case("get_user-name"), "GetUserName");
412        assert_eq!(to_pascal_case("user-name_id"), "UserNameId");
413    }
414
415    #[test]
416    fn test_to_pascal_case_numbers() {
417        assert_eq!(to_pascal_case("user_123"), "User123");
418        assert_eq!(to_pascal_case("get_user_123"), "GetUser123");
419        assert_eq!(to_pascal_case("user123name"), "User123name");
420    }
421
422    #[test]
423    fn test_to_pascal_case_leading_trailing_separators() {
424        assert_eq!(to_pascal_case("_user"), "User");
425        assert_eq!(to_pascal_case("user_"), "User");
426        assert_eq!(to_pascal_case("_user_"), "User");
427    }
428
429    #[test]
430    fn test_to_pascal_case_empty() {
431        assert_eq!(to_pascal_case(""), "");
432    }
433
434    #[test]
435    fn test_to_pascal_case_single_char() {
436        assert_eq!(to_pascal_case("a"), "A");
437        assert_eq!(to_pascal_case("_"), "");
438    }
439
440    #[test]
441    fn test_to_pascal_case_already_pascal() {
442        assert_eq!(to_pascal_case("GetUser"), "GetUser");
443        assert_eq!(to_pascal_case("UserName"), "UserName");
444        assert_eq!(to_pascal_case("CreateUserProfile"), "CreateUserProfile");
445    }
446
447    #[test]
448    fn test_to_kebab_case_simple() {
449        assert_eq!(to_kebab_case("user"), "user");
450        assert_eq!(to_kebab_case("name"), "name");
451        assert_eq!(to_kebab_case("id"), "id");
452    }
453
454    #[test]
455    fn test_to_kebab_case_camel_case() {
456        assert_eq!(to_kebab_case("getUser"), "get-user");
457        assert_eq!(to_kebab_case("userName"), "user-name");
458        assert_eq!(to_kebab_case("createUserProfile"), "create-user-profile");
459    }
460
461    #[test]
462    fn test_to_kebab_case_pascal_case() {
463        assert_eq!(to_kebab_case("GetUser"), "get-user");
464        assert_eq!(to_kebab_case("UserName"), "user-name");
465        assert_eq!(to_kebab_case("CreateUserProfile"), "create-user-profile");
466    }
467
468    #[test]
469    fn test_to_kebab_case_snake_case() {
470        assert_eq!(to_kebab_case("get_user"), "get-user");
471        assert_eq!(to_kebab_case("user_name"), "user-name");
472        assert_eq!(to_kebab_case("http_server"), "http-server");
473    }
474
475    #[test]
476    fn test_to_kebab_case_acronyms() {
477        assert_eq!(to_kebab_case("HTTPServer"), "http-server");
478        assert_eq!(to_kebab_case("GraphQLType"), "graph-ql-type");
479        assert_eq!(to_kebab_case("XMLHttpRequest"), "xml-http-request");
480    }
481
482    #[test]
483    fn test_to_kebab_case_already_kebab_case() {
484        assert_eq!(to_kebab_case("get-user"), "get-user");
485        assert_eq!(to_kebab_case("user-name"), "user-name");
486        assert_eq!(to_kebab_case("http-server"), "http-server");
487    }
488
489    #[test]
490    fn test_to_kebab_case_numbers() {
491        assert_eq!(to_kebab_case("user123"), "user123");
492        assert_eq!(to_kebab_case("getUser123"), "get-user123");
493        assert_eq!(to_kebab_case("User123Name"), "user123-name");
494    }
495
496    #[test]
497    fn test_to_kebab_case_leading_trailing_hyphens() {
498        assert_eq!(to_kebab_case("getUser-"), "get-user");
499        assert_eq!(to_kebab_case("-getUser"), "get-user");
500        assert_eq!(to_kebab_case("-getUser-"), "get-user");
501    }
502
503    #[test]
504    fn test_to_kebab_case_empty() {
505        assert_eq!(to_kebab_case(""), "");
506    }
507
508    #[test]
509    fn test_to_kebab_case_single_char() {
510        assert_eq!(to_kebab_case("a"), "a");
511        assert_eq!(to_kebab_case("A"), "a");
512    }
513
514    #[test]
515    fn test_round_trip_snake_to_camel_to_snake() {
516        let original = "get_user_profile";
517        let camel = to_camel_case(original);
518        let back = to_snake_case(&camel);
519        assert_eq!(back, original);
520    }
521
522    #[test]
523    fn test_round_trip_snake_to_pascal_to_snake() {
524        let original = "get_user_profile";
525        let pascal = to_pascal_case(original);
526        let back = to_snake_case(&pascal);
527        assert_eq!(back, original);
528    }
529
530    #[test]
531    fn test_acronym_consistency() {
532        assert_eq!(to_snake_case("HTTPServer"), "http_server");
533        assert_eq!(to_camel_case("http_server"), "httpServer");
534        assert_eq!(to_pascal_case("http_server"), "HttpServer");
535        assert_eq!(to_kebab_case("HTTPServer"), "http-server");
536    }
537
538    #[test]
539    fn test_graphql_type_consistency() {
540        let graphql = "GraphQLType";
541        assert_eq!(to_snake_case(graphql), "graph_ql_type");
542        assert_eq!(to_camel_case("graph_ql_type"), "graphQlType");
543        assert_eq!(to_pascal_case("graph_ql_type"), "GraphQlType");
544        assert_eq!(to_kebab_case(graphql), "graph-ql-type");
545    }
546
547    #[test]
548    fn test_real_world_field_names() {
549        assert_eq!(to_snake_case("userId"), "user_id");
550        assert_eq!(to_snake_case("firstName"), "first_name");
551        assert_eq!(to_snake_case("lastName"), "last_name");
552        assert_eq!(to_snake_case("createdAt"), "created_at");
553        assert_eq!(to_snake_case("updatedAt"), "updated_at");
554
555        assert_eq!(to_pascal_case("user_id"), "UserId");
556        assert_eq!(to_pascal_case("first_name"), "FirstName");
557        assert_eq!(to_pascal_case("created_at"), "CreatedAt");
558    }
559
560    #[test]
561    fn test_edge_case_empty_parts() {
562        assert_eq!(to_camel_case("__"), "__");
563        assert_eq!(to_pascal_case("__"), "");
564        assert_eq!(to_snake_case("__"), "__");
565        assert_eq!(to_kebab_case("__"), "");
566    }
567}