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                // Insert hyphen at word boundaries
31                if !last_was_sep && is_upper && last_was_lower {
32                    result.push('-');
33                } else if !last_was_sep
34                    && is_upper
35                    && chars.peek().is_some_and(|&next| next.is_lowercase())
36                    && !result.is_empty()
37                    && !result.chars().last().unwrap_or(' ').is_numeric()
38                {
39                    // Handle acronym followed by word (e.g., "HTTPSConnection" -> "https-connection")
40                    // But check if we're not in the middle of an all-caps word ending with 's' (APIs)
41                    // Collect the next few characters to check the pattern
42                    let remaining: Vec<char> = chars.clone().collect();
43                    let should_add_hyphen = match remaining.as_slice() {
44                        // If next char is lowercase and there are more chars after it, add hyphen
45                        [next, _, ..] if next.is_lowercase() => true,
46                        // If next char is lowercase and it's the last char, don't add hyphen
47                        // This prevents "APIs" from becoming "api-s"
48                        [next] if next.is_lowercase() => false,
49                        // No more characters
50                        _ => false,
51                    };
52
53                    if should_add_hyphen {
54                        result.push('-');
55                    }
56                }
57
58                // Use proper Unicode lowercase conversion
59                for lower_ch in c.to_lowercase() {
60                    result.push(lower_ch);
61                }
62
63                last_was_sep = false;
64                last_was_lower = c.is_lowercase() || c.is_numeric();
65            }
66            _ => {
67                // Convert other chars to hyphen, but avoid consecutive hyphens
68                if !last_was_sep && !result.is_empty() {
69                    result.push('-');
70                    last_was_sep = true;
71                    last_was_lower = false;
72                }
73            }
74        }
75    }
76
77    result.trim_end_matches('-').to_string()
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn test_to_kebab_case() {
86        // Test cases from the requirements
87        assert_eq!(
88            to_kebab_case("List an Organization's Issues"),
89            "list-an-organizations-issues"
90        );
91        assert_eq!(to_kebab_case("getUser"), "get-user");
92        assert_eq!(to_kebab_case("get_user_by_id"), "get-user-by-id");
93        assert_eq!(
94            to_kebab_case("Some---Multiple   Spaces"),
95            "some-multiple-spaces"
96        );
97
98        // Additional test cases
99        assert_eq!(to_kebab_case("getUserByID"), "get-user-by-id");
100        assert_eq!(to_kebab_case("XMLHttpRequest"), "xml-http-request");
101        assert_eq!(to_kebab_case("Simple"), "simple");
102        assert_eq!(to_kebab_case("ALLCAPS"), "allcaps");
103        assert_eq!(
104            to_kebab_case("spaces between words"),
105            "spaces-between-words"
106        );
107        assert_eq!(to_kebab_case("special!@#$%^&*()chars"), "special-chars");
108        assert_eq!(to_kebab_case("trailing---"), "trailing");
109        assert_eq!(to_kebab_case("---leading"), "leading");
110        assert_eq!(to_kebab_case(""), "");
111        assert_eq!(to_kebab_case("a"), "a");
112        assert_eq!(to_kebab_case("A"), "a");
113
114        // Edge cases with apostrophes
115        assert_eq!(to_kebab_case("don't"), "dont");
116        assert_eq!(to_kebab_case("it's"), "its");
117        assert_eq!(to_kebab_case("users'"), "users");
118
119        // Complex acronym cases
120        assert_eq!(to_kebab_case("IOError"), "io-error");
121        assert_eq!(to_kebab_case("HTTPSConnection"), "https-connection");
122        assert_eq!(to_kebab_case("getHTTPSConnection"), "get-https-connection");
123
124        // Numeric cases
125        assert_eq!(to_kebab_case("base64Encode"), "base64-encode");
126        assert_eq!(to_kebab_case("getV2API"), "get-v2-api");
127        assert_eq!(to_kebab_case("v2APIResponse"), "v2-api-response");
128
129        // More edge cases
130        assert_eq!(
131            to_kebab_case("_startWithUnderscore"),
132            "start-with-underscore"
133        );
134        assert_eq!(to_kebab_case("endWithUnderscore_"), "end-with-underscore");
135        assert_eq!(
136            to_kebab_case("multiple___underscores"),
137            "multiple-underscores"
138        );
139        assert_eq!(to_kebab_case("mixedUP_down_CASE"), "mixed-up-down-case");
140        assert_eq!(to_kebab_case("123StartWithNumber"), "123-start-with-number");
141        assert_eq!(to_kebab_case("has123Numbers456"), "has123-numbers456");
142
143        // Unicode and special cases
144        assert_eq!(to_kebab_case("café"), "café"); // Non-ASCII preserved if alphanumeric
145        assert_eq!(to_kebab_case("CAFÉ"), "café"); // Unicode uppercase properly lowercased
146        assert_eq!(to_kebab_case("ÑOÑO"), "ñoño"); // Spanish characters
147        assert_eq!(to_kebab_case("ÄÖÜ"), "äöü"); // German umlauts
148        assert_eq!(to_kebab_case("МОСКВА"), "москва"); // Cyrillic
149        assert_eq!(to_kebab_case("hello@world.com"), "hello-world-com");
150        assert_eq!(to_kebab_case("price$99"), "price-99");
151        assert_eq!(to_kebab_case("100%Complete"), "100-complete");
152
153        // Consecutive uppercase handling
154        assert_eq!(to_kebab_case("ABCDefg"), "abc-defg");
155        assert_eq!(to_kebab_case("HTTPSProxy"), "https-proxy");
156        assert_eq!(to_kebab_case("HTTPAPI"), "httpapi");
157        assert_eq!(to_kebab_case("HTTPAPIs"), "httpapis");
158
159        // Real-world OpenAPI operation IDs
160        assert_eq!(
161            to_kebab_case("List an Organization's Projects"),
162            "list-an-organizations-projects"
163        );
164        assert_eq!(to_kebab_case("Update User's Avatar"), "update-users-avatar");
165        assert_eq!(
166            to_kebab_case("Delete Team's Repository Access"),
167            "delete-teams-repository-access"
168        );
169    }
170}