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