Skip to main content

ontogen_ts/
rename.rs

1//! Case-transform implementation for serde's eight `rename_all` modes.
2//!
3//! Mirrors `serde_derive_internals::case` so a property test that round-trips
4//! a fixture value through `serde_json::to_string` and compares the resulting
5//! JSON keys / discriminants to what ontogen-ts emits is a tight check, not a
6//! best-effort approximation.
7//!
8//! Serde has two entry points and the rules differ:
9//!
10//! - [`RenameAll::apply_to_field`] assumes the input is **snake_case** (Rust
11//!   field idents are snake_case by convention). It splits on `_` to recover
12//!   words, then re-emits them in the target case.
13//! - [`RenameAll::apply_to_variant`] assumes the input is **PascalCase**
14//!   (Rust variant idents are PascalCase by convention). It splits on
15//!   uppercase-letter boundaries (one letter per word, naively — there's no
16//!   acronym detection, so `HTMLParser` becomes `h_t_m_l_parser` under
17//!   `snake_case`, exactly as serde does it).
18//!
19//! `heck` is deliberately NOT used here: its acronym handling diverges from
20//! serde's (`heck` smart-splits `HTMLParser` into `html_parser` for
21//! `snake_case`, which differs from serde's `h_t_m_l_parser` literal output).
22//! Mirroring serde means our emitted TS field names match what `serde_json`
23//! actually puts on the wire — which is what the consumers reading our
24//! generated `.d.ts`-equivalents need.
25
26use crate::types::RenameAll;
27
28impl RenameAll {
29    /// Apply this `rename_all` mode to a **field** ident (assumed snake_case).
30    ///
31    /// Examples (mirroring serde's `RenameRule::apply_to_field`):
32    ///
33    /// ```text
34    /// "parse_url_v2"  → camelCase           → "parseUrlV2"
35    /// "parse_url_v2"  → PascalCase          → "ParseUrlV2"
36    /// "parse_url_v2"  → SCREAMING_SNAKE_CASE → "PARSE_URL_V2"
37    /// "parse_url_v2"  → kebab-case          → "parse-url-v2"
38    /// ```
39    pub fn apply_to_field(self, field: &str) -> String {
40        match self {
41            // For snake-case-ish inputs, these are identity (snake_case) or
42            // simple ascii-case (Lowercase = lowercase the whole thing,
43            // which leaves snake-case unchanged because `_` is unchanged
44            // and ascii letters are already lower).
45            Self::Lowercase | Self::SnakeCase => field.to_owned(),
46            Self::Uppercase => field.to_ascii_uppercase(),
47            Self::PascalCase => snake_to_pascal(field),
48            Self::CamelCase => {
49                let pascal = snake_to_pascal(field);
50                lowercase_first_char(&pascal)
51            }
52            Self::ScreamingSnakeCase => field.to_ascii_uppercase(),
53            Self::KebabCase => field.replace('_', "-"),
54            Self::ScreamingKebabCase => field.to_ascii_uppercase().replace('_', "-"),
55        }
56    }
57
58    /// Apply this `rename_all` mode to a **variant** ident (assumed PascalCase).
59    ///
60    /// Examples (mirroring serde's `RenameRule::apply_to_variant`):
61    ///
62    /// ```text
63    /// "HTMLParser"  → snake_case  → "h_t_m_l_parser"  (no acronym detection!)
64    /// "HTMLParser"  → camelCase   → "hTMLParser"      (just lowercase first ch)
65    /// "ApiClient"   → snake_case  → "api_client"
66    /// "ApiClient"   → kebab-case  → "api-client"
67    /// ```
68    pub fn apply_to_variant(self, variant: &str) -> String {
69        match self {
70            // PascalCase is the assumed input form — identity.
71            Self::PascalCase => variant.to_owned(),
72            Self::Lowercase => variant.to_ascii_lowercase(),
73            Self::Uppercase => variant.to_ascii_uppercase(),
74            // serde's camelCase on a variant just lowercases the first char.
75            Self::CamelCase => lowercase_first_char(variant),
76            Self::SnakeCase => pascal_to_snake(variant),
77            Self::ScreamingSnakeCase => pascal_to_snake(variant).to_ascii_uppercase(),
78            Self::KebabCase => pascal_to_snake(variant).replace('_', "-"),
79            Self::ScreamingKebabCase => pascal_to_snake(variant).to_ascii_uppercase().replace('_', "-"),
80        }
81    }
82}
83
84/// `parse_url_v2` → `ParseUrlV2`.
85///
86/// Treats `_` as a word separator and uppercases the first letter of each
87/// word; everything else passes through unchanged.
88fn snake_to_pascal(snake: &str) -> String {
89    let mut out = String::with_capacity(snake.len());
90    let mut capitalize_next = true;
91    for ch in snake.chars() {
92        if ch == '_' {
93            capitalize_next = true;
94        } else if capitalize_next {
95            out.push(ch.to_ascii_uppercase());
96            capitalize_next = false;
97        } else {
98            out.push(ch);
99        }
100    }
101    out
102}
103
104/// `HTMLParser` → `h_t_m_l_parser`. Inserts `_` before each non-leading
105/// uppercase letter and lowercases the whole string. No acronym detection —
106/// this is intentional, matching serde's literal output so round-trip tests
107/// against `serde_json::to_string` line up exactly.
108fn pascal_to_snake(pascal: &str) -> String {
109    let mut out = String::with_capacity(pascal.len() + 4);
110    for (i, ch) in pascal.char_indices() {
111        if i > 0 && ch.is_ascii_uppercase() {
112            out.push('_');
113        }
114        out.push(ch.to_ascii_lowercase());
115    }
116    out
117}
118
119/// Lowercase the first character of `s`; pass the rest through.
120fn lowercase_first_char(s: &str) -> String {
121    let mut chars = s.chars();
122    match chars.next() {
123        Some(first) => {
124            let mut out = String::with_capacity(s.len());
125            out.push(first.to_ascii_lowercase());
126            out.extend(chars);
127            out
128        }
129        None => String::new(),
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use crate::types::RenameAll;
136
137    // ── apply_to_field ────────────────────────────────────────────────────
138
139    #[test]
140    fn field_lowercase_is_identity() {
141        assert_eq!(RenameAll::Lowercase.apply_to_field("parse_url_v2"), "parse_url_v2");
142    }
143
144    #[test]
145    fn field_snake_case_is_identity() {
146        assert_eq!(RenameAll::SnakeCase.apply_to_field("parse_url_v2"), "parse_url_v2");
147    }
148
149    #[test]
150    fn field_uppercase() {
151        assert_eq!(RenameAll::Uppercase.apply_to_field("parse_url_v2"), "PARSE_URL_V2");
152    }
153
154    #[test]
155    fn field_pascal_case() {
156        assert_eq!(RenameAll::PascalCase.apply_to_field("parse_url_v2"), "ParseUrlV2");
157        assert_eq!(RenameAll::PascalCase.apply_to_field("html_parser"), "HtmlParser");
158        assert_eq!(RenameAll::PascalCase.apply_to_field("single"), "Single");
159    }
160
161    #[test]
162    fn field_camel_case() {
163        assert_eq!(RenameAll::CamelCase.apply_to_field("parse_url_v2"), "parseUrlV2");
164        assert_eq!(RenameAll::CamelCase.apply_to_field("html_parser"), "htmlParser");
165        assert_eq!(RenameAll::CamelCase.apply_to_field("single"), "single");
166    }
167
168    #[test]
169    fn field_screaming_snake_case() {
170        assert_eq!(RenameAll::ScreamingSnakeCase.apply_to_field("parse_url_v2"), "PARSE_URL_V2");
171    }
172
173    #[test]
174    fn field_kebab_case() {
175        assert_eq!(RenameAll::KebabCase.apply_to_field("parse_url_v2"), "parse-url-v2");
176    }
177
178    #[test]
179    fn field_screaming_kebab_case() {
180        assert_eq!(RenameAll::ScreamingKebabCase.apply_to_field("parse_url_v2"), "PARSE-URL-V2");
181    }
182
183    // ── apply_to_variant ──────────────────────────────────────────────────
184
185    #[test]
186    fn variant_pascal_case_is_identity() {
187        assert_eq!(RenameAll::PascalCase.apply_to_variant("ApiClient"), "ApiClient");
188    }
189
190    #[test]
191    fn variant_lowercase() {
192        assert_eq!(RenameAll::Lowercase.apply_to_variant("ApiClient"), "apiclient");
193    }
194
195    #[test]
196    fn variant_uppercase() {
197        assert_eq!(RenameAll::Uppercase.apply_to_variant("ApiClient"), "APICLIENT");
198    }
199
200    #[test]
201    fn variant_camel_case() {
202        // Just lowercase the first character — matches serde.
203        assert_eq!(RenameAll::CamelCase.apply_to_variant("ApiClient"), "apiClient");
204        assert_eq!(RenameAll::CamelCase.apply_to_variant("HTMLParser"), "hTMLParser");
205    }
206
207    #[test]
208    fn variant_snake_case() {
209        assert_eq!(RenameAll::SnakeCase.apply_to_variant("ApiClient"), "api_client");
210        // Naive split — no acronym detection. Matches serde.
211        assert_eq!(RenameAll::SnakeCase.apply_to_variant("HTMLParser"), "h_t_m_l_parser");
212        assert_eq!(RenameAll::SnakeCase.apply_to_variant("Single"), "single");
213    }
214
215    #[test]
216    fn variant_screaming_snake_case() {
217        assert_eq!(RenameAll::ScreamingSnakeCase.apply_to_variant("ApiClient"), "API_CLIENT");
218        assert_eq!(RenameAll::ScreamingSnakeCase.apply_to_variant("HTMLParser"), "H_T_M_L_PARSER");
219    }
220
221    #[test]
222    fn variant_kebab_case() {
223        assert_eq!(RenameAll::KebabCase.apply_to_variant("ApiClient"), "api-client");
224    }
225
226    #[test]
227    fn variant_screaming_kebab_case() {
228        assert_eq!(RenameAll::ScreamingKebabCase.apply_to_variant("ApiClient"), "API-CLIENT");
229    }
230
231    // ── edge cases ────────────────────────────────────────────────────────
232
233    #[test]
234    fn empty_input_pass_through() {
235        // Every mode must handle empty input without panicking.
236        for mode in [
237            RenameAll::Lowercase,
238            RenameAll::Uppercase,
239            RenameAll::PascalCase,
240            RenameAll::CamelCase,
241            RenameAll::SnakeCase,
242            RenameAll::ScreamingSnakeCase,
243            RenameAll::KebabCase,
244            RenameAll::ScreamingKebabCase,
245        ] {
246            assert_eq!(mode.apply_to_field(""), "");
247            assert_eq!(mode.apply_to_variant(""), "");
248        }
249    }
250
251    #[test]
252    fn field_with_digit_segment_camel_case() {
253        // Field `parse_v2` — snake-case input with a digit-only word.
254        // PascalCase: "parse" → "Parse", "v2" → "V2" → "ParseV2"
255        // camelCase: lowercase the first char → "parseV2"
256        assert_eq!(RenameAll::PascalCase.apply_to_field("parse_v2"), "ParseV2");
257        assert_eq!(RenameAll::CamelCase.apply_to_field("parse_v2"), "parseV2");
258    }
259}