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}