Skip to main content

aperture_cli/
utils.rs

1/// Converts a string to kebab-case
2///
3/// Handles multiple input formats:
4/// - `camelCase`: `"getUserById"` -> "get-user-by-id"
5/// - `PascalCase`: `"GetUser"` -> "get-user"
6/// - `snake_case`: `"get_user_by_id"` -> "get-user-by-id"
7/// - Spaces: "List an Organization's Issues" -> "list-an-organizations-issues"
8/// - Mixed: `"XMLHttpRequest"` -> "xml-http-request"
9/// - Unicode: `"CAFÉ"` -> "café"
10///
11/// Special handling:
12/// - Apostrophes are removed entirely: "Organization's" -> "organizations"
13/// - Special characters become hyphens: "hello!world" -> "hello-world"
14/// - Consecutive non-alphanumeric characters are collapsed: "a---b" -> "a-b"
15/// - Leading/trailing hyphens are trimmed
16/// - Unicode characters are properly lowercased
17#[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            '\'' => {} // Skip apostrophes
27            c if c.is_alphanumeric() => {
28                let is_upper = c.is_uppercase();
29
30                // Determine if we need to insert a hyphen at word boundaries
31                let needs_simple_boundary_hyphen = !last_was_sep && is_upper && last_was_lower;
32
33                // Check if this is an acronym followed by a word (e.g., "HTTPSConnection")
34                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                // Only compute acronym pattern if we didn't already add simple boundary hyphen
42                let needs_acronym_hyphen = needs_acronym_boundary_check && {
43                    // Check if we're not in the middle of an all-caps word ending with 's' (APIs)
44                    let remaining: Vec<char> = chars.clone().collect();
45                    matches!(remaining.as_slice(),
46                        // If next char is lowercase and there are more chars after it, add hyphen
47                        [next, _, ..] if next.is_lowercase()
48                    )
49                };
50
51                // Add hyphen if either condition is true (but not both, due to mutual exclusion above)
52                if needs_simple_boundary_hyphen || needs_acronym_hyphen {
53                    result.push('-');
54                }
55
56                // Use proper Unicode lowercase conversion
57                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                // Convert other chars to hyphen, but avoid consecutive hyphens
66                let should_add_separator = !last_was_sep && !result.is_empty();
67                if should_add_separator {
68                    result.push('-');
69                }
70                // Update state only if we added a separator
71                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        // Test cases from the requirements
89        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        // Additional test cases
101        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        // Edge cases with apostrophes
117        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        // Complex acronym cases
122        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        // Numeric cases
127        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        // More edge cases
132        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        // Unicode and special cases
146        assert_eq!(to_kebab_case("café"), "café"); // Non-ASCII preserved if alphanumeric
147        assert_eq!(to_kebab_case("CAFÉ"), "café"); // Unicode uppercase properly lowercased
148        assert_eq!(to_kebab_case("ÑOÑO"), "ñoño"); // Spanish characters
149        assert_eq!(to_kebab_case("ÄÖÜ"), "äöü"); // German umlauts
150        assert_eq!(to_kebab_case("МОСКВА"), "москва"); // Cyrillic
151        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        // Consecutive uppercase handling
156        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        // Real-world OpenAPI operation IDs
162        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}