Skip to main content

umbral_casing/
lib.rs

1//! Shared case-conversion helpers for the umbral workspace.
2//!
3//! This crate is intentionally dependency-free so it can be used from
4//! `umbral-macros` (a proc-macro crate), `umbral-core`, `umbral-cli`, and
5//! plugin crates without introducing a dependency cycle.
6//!
7//! # Functions
8//!
9//! | Function | Canonical call site | Notes |
10//! |---|---|---|
11//! | [`to_snake_case`] | `umbral-macros` (table-name derivation) | Full acronym-aware algorithm |
12//! | [`pascal_case_from_table`] | `umbral-core/inspect.rs` | SQL table → struct name; lowercases segments |
13//! | [`pascal_case_from_ident`] | `umbral-cli/scaffold.rs`, `umbral-openapi` | Ident / name → PascalCase; no lowercasing |
14
15/// Convert a `PascalCase` or `camelCase` Rust identifier into `snake_case`.
16///
17/// # Algorithm
18///
19/// Inserts `_` before an uppercase letter when:
20/// - the previous character was a lowercase letter or digit
21///   (`blogPost` → `blog_post`, `post2Tag` → `post2_tag`), OR
22/// - we are at the end of an uppercase run that is followed by a lowercase
23///   letter (`HTTPRequest` → `http_request`, not `h_t_t_p_request`).
24///
25/// All non-uppercase characters (including digits and existing underscores)
26/// pass through unchanged.  Non-ASCII characters pass through unchanged.
27///
28/// This is the **canonical** implementation — byte-identical to what
29/// `#[derive(Model)]` uses to compute a model's `TABLE` constant, so the
30/// output must never change without a coordinated migration.
31///
32/// # Examples
33///
34/// ```
35/// use umbral_casing::to_snake_case;
36///
37/// assert_eq!(to_snake_case("BlogPost"),    "blog_post");
38/// assert_eq!(to_snake_case("HTTPRequest"), "http_request");
39/// assert_eq!(to_snake_case("Post2"),       "post2");
40/// assert_eq!(to_snake_case("post_tag"),    "post_tag");
41/// assert_eq!(to_snake_case("S3Upload"),    "s3_upload");
42/// assert_eq!(to_snake_case("_Hidden"),     "_hidden");
43/// ```
44pub fn to_snake_case(camel: &str) -> String {
45    let chars: Vec<char> = camel.chars().collect();
46    let mut out = String::with_capacity(camel.len() + 4);
47    for (i, &c) in chars.iter().enumerate() {
48        if c.is_ascii_uppercase() {
49            let prev = if i == 0 { None } else { Some(chars[i - 1]) };
50            let next = chars.get(i + 1).copied();
51            let prev_lower_or_digit =
52                matches!(prev, Some(p) if p.is_ascii_lowercase() || p.is_ascii_digit());
53            let run_break = prev.map(|p| p.is_ascii_uppercase()).unwrap_or(false)
54                && matches!(next, Some(n) if n.is_ascii_lowercase());
55            if i != 0 && (prev_lower_or_digit || run_break) {
56                out.push('_');
57            }
58            out.push(c.to_ascii_lowercase());
59        } else {
60            out.push(c);
61        }
62    }
63    out
64}
65
66/// Convert a SQL table name (typically `snake_case`) into `PascalCase` for
67/// use as a Rust struct name.
68///
69/// Splits on `_`, ` `, and `-`.  Non-alphanumeric characters that are not
70/// separators are skipped.  The **first** character of each segment is
71/// uppercased; the rest are **lowercased** — this normalises table names
72/// that may have been created in ALLCAPS or mixed case.
73///
74/// Used by `umbral-core/inspect.rs` (the `inspectdb` command) to generate
75/// struct names from introspected table names.
76///
77/// # Examples
78///
79/// ```
80/// use umbral_casing::pascal_case_from_table;
81///
82/// assert_eq!(pascal_case_from_table("blog_post"),        "BlogPost");
83/// assert_eq!(pascal_case_from_table("auth_user_groups"), "AuthUserGroups");
84/// assert_eq!(pascal_case_from_table("post"),             "Post");
85/// assert_eq!(pascal_case_from_table("tag"),              "Tag");
86/// assert_eq!(pascal_case_from_table("BLOG_POST"),        "BlogPost");
87/// assert_eq!(pascal_case_from_table("blog-post"),        "BlogPost");
88/// ```
89pub fn pascal_case_from_table(input: &str) -> String {
90    let mut out = String::with_capacity(input.len());
91    let mut upper_next = true;
92    for ch in input.chars() {
93        if ch == '_' || ch == ' ' || ch == '-' {
94            upper_next = true;
95            continue;
96        }
97        if !ch.is_alphanumeric() {
98            continue;
99        }
100        if upper_next {
101            for u in ch.to_uppercase() {
102                out.push(u);
103            }
104            upper_next = false;
105        } else {
106            for l in ch.to_lowercase() {
107                out.push(l);
108            }
109        }
110    }
111    out
112}
113
114/// Convert a kebab/snake-case identifier or user-provided name into
115/// `PascalCase` for use as a Rust type name or plugin struct name.
116///
117/// Splits on `-` and `_`.  The **first** character of each segment is
118/// uppercased; the remaining characters are passed through **unchanged**.
119/// This preserves digits and already-correct casing in subsequent positions
120/// (`api2` → `Api2`, not `Api2` which lowercasing would give).
121///
122/// Used by `umbral-cli/scaffold.rs` (to produce `{Name}Plugin`) and
123/// `umbral-openapi` (to produce OpenAPI schema names from model names that
124/// are already PascalCase).
125///
126/// # Examples
127///
128/// ```
129/// use umbral_casing::pascal_case_from_ident;
130///
131/// assert_eq!(pascal_case_from_ident("posts"),       "Posts");
132/// assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
133/// assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
134/// assert_eq!(pascal_case_from_ident("api2"),        "Api2");
135/// assert_eq!(pascal_case_from_ident("BlogPost"),    "BlogPost");
136/// assert_eq!(pascal_case_from_ident("task_queue"),  "TaskQueue");
137/// ```
138pub fn pascal_case_from_ident(name: &str) -> String {
139    let mut out = String::with_capacity(name.len());
140    let mut next_upper = true;
141    for c in name.chars() {
142        if c == '-' || c == '_' {
143            next_upper = true;
144        } else if next_upper {
145            out.push(c.to_ascii_uppercase());
146            next_upper = false;
147        } else {
148            out.push(c);
149        }
150    }
151    out
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    // ── to_snake_case ──────────────────────────────────────────────────────
159
160    #[test]
161    fn snake_simple_pascal() {
162        assert_eq!(to_snake_case("BlogPost"), "blog_post");
163        assert_eq!(to_snake_case("UserProfile"), "user_profile");
164        assert_eq!(to_snake_case("Comment"), "comment");
165    }
166
167    #[test]
168    fn snake_acronym_handling() {
169        // The run_break rule collapses consecutive caps before a lowercase.
170        assert_eq!(to_snake_case("HTTPRequest"), "http_request");
171        assert_eq!(to_snake_case("URLParser"), "url_parser");
172        assert_eq!(to_snake_case("JSONBody"), "json_body");
173        assert_eq!(to_snake_case("APIKey"), "api_key");
174    }
175
176    #[test]
177    fn snake_trailing_acronym() {
178        // Trailing all-caps run has no following lowercase — no split inside.
179        assert_eq!(to_snake_case("ParseHTTP"), "parse_http");
180        assert_eq!(to_snake_case("RequestURL"), "request_url");
181    }
182
183    #[test]
184    fn snake_digits() {
185        assert_eq!(to_snake_case("Post2"), "post2");
186        assert_eq!(to_snake_case("S3Upload"), "s3_upload");
187        assert_eq!(to_snake_case("post2Tag"), "post2_tag");
188    }
189
190    #[test]
191    fn snake_already_snake() {
192        // Existing underscores and lowercase pass through unchanged.
193        assert_eq!(to_snake_case("post_tag"), "post_tag");
194        assert_eq!(to_snake_case("blog_post"), "blog_post");
195    }
196
197    #[test]
198    fn snake_leading_underscore() {
199        assert_eq!(to_snake_case("_Hidden"), "_hidden");
200    }
201
202    #[test]
203    fn snake_single_char() {
204        assert_eq!(to_snake_case("A"), "a");
205        assert_eq!(to_snake_case("a"), "a");
206    }
207
208    #[test]
209    fn snake_empty() {
210        assert_eq!(to_snake_case(""), "");
211    }
212
213    // ── pascal_case_from_table ─────────────────────────────────────────────
214
215    #[test]
216    fn table_simple() {
217        assert_eq!(pascal_case_from_table("post"), "Post");
218        assert_eq!(pascal_case_from_table("tag"), "Tag");
219        assert_eq!(pascal_case_from_table("blog_post"), "BlogPost");
220        assert_eq!(pascal_case_from_table("auth_user_groups"), "AuthUserGroups");
221    }
222
223    #[test]
224    fn table_normalises_allcaps() {
225        assert_eq!(pascal_case_from_table("BLOG_POST"), "BlogPost");
226        assert_eq!(pascal_case_from_table("USER"), "User");
227    }
228
229    #[test]
230    fn table_handles_hyphen_and_space() {
231        assert_eq!(pascal_case_from_table("blog-post"), "BlogPost");
232        assert_eq!(pascal_case_from_table("blog post"), "BlogPost");
233    }
234
235    #[test]
236    fn table_skips_non_alphanumeric() {
237        // A table name with a non-separator, non-alphanumeric char.
238        assert_eq!(pascal_case_from_table("foo!bar"), "Foobar");
239    }
240
241    #[test]
242    fn table_empty() {
243        assert_eq!(pascal_case_from_table(""), "");
244    }
245
246    // ── pascal_case_from_ident ─────────────────────────────────────────────
247
248    #[test]
249    fn ident_simple() {
250        assert_eq!(pascal_case_from_ident("posts"), "Posts");
251        assert_eq!(pascal_case_from_ident("blog_engine"), "BlogEngine");
252        assert_eq!(pascal_case_from_ident("blog-engine"), "BlogEngine");
253        assert_eq!(pascal_case_from_ident("task_queue"), "TaskQueue");
254    }
255
256    #[test]
257    fn ident_digits_preserved() {
258        assert_eq!(pascal_case_from_ident("api2"), "Api2");
259    }
260
261    #[test]
262    fn ident_already_pascal() {
263        // Model names are already PascalCase — must round-trip unchanged.
264        assert_eq!(pascal_case_from_ident("BlogPost"), "BlogPost");
265        assert_eq!(pascal_case_from_ident("UserProfile"), "UserProfile");
266    }
267
268    #[test]
269    fn ident_empty() {
270        assert_eq!(pascal_case_from_ident(""), "");
271    }
272
273    // ── cross-impl consistency check ───────────────────────────────────────
274
275    #[test]
276    fn snake_then_table_roundtrip() {
277        // A PascalCase name → snake → table should recover the original.
278        let cases = ["BlogPost", "UserProfile", "AuthGroup", "Comment"];
279        for &name in &cases {
280            let snake = to_snake_case(name);
281            let back = pascal_case_from_table(&snake);
282            assert_eq!(back, name, "roundtrip failed for {name}");
283        }
284    }
285}