Skip to main content

gize_core/
naming.rs

1//! Naming conventions shared across generators.
2//!
3//! Centralised so every crate agrees on how a model name maps to a module name, a table
4//! name, etc. Kept dependency-free (no heavy inflection crate) for the MVP.
5
6/// Convert a `PascalCase` or arbitrary identifier to `snake_case`.
7pub fn snake_case(input: &str) -> String {
8    let mut out = String::with_capacity(input.len() + 4);
9    for (i, ch) in input.chars().enumerate() {
10        if ch.is_uppercase() {
11            if i != 0 {
12                out.push('_');
13            }
14            for lower in ch.to_lowercase() {
15                out.push(lower);
16            }
17        } else {
18            out.push(ch);
19        }
20    }
21    out
22}
23
24/// Naive pluralisation good enough for table names in the MVP.
25pub fn pluralize(word: &str) -> String {
26    if word.ends_with('y')
27        && !word.ends_with("ay")
28        && !word.ends_with("ey")
29        && !word.ends_with("oy")
30        && !word.ends_with("uy")
31    {
32        format!("{}ies", &word[..word.len() - 1])
33    } else if word.ends_with('s')
34        || word.ends_with("x")
35        || word.ends_with("ch")
36        || word.ends_with("sh")
37    {
38        format!("{word}es")
39    } else {
40        format!("{word}s")
41    }
42}
43
44/// The table name for a model (snake_case + pluralized): `User` -> `users`.
45pub fn table_name(model: &str) -> String {
46    pluralize(&snake_case(model))
47}
48
49/// Convert a `snake_case` identifier back to `PascalCase`: `blog_post` -> `BlogPost`.
50///
51/// The inverse of [`snake_case`] for the identifiers Gize generates, used to recover a
52/// model's struct name from its module/table name during `gize sync` (ADR-009 revision).
53pub fn pascal_case(input: &str) -> String {
54    let mut out = String::with_capacity(input.len());
55    for word in input.split('_').filter(|w| !w.is_empty()) {
56        let mut chars = word.chars();
57        if let Some(first) = chars.next() {
58            out.extend(first.to_uppercase());
59            out.push_str(chars.as_str());
60        }
61    }
62    out
63}
64
65/// Singularize a table name, inverting [`pluralize`] for the forms Gize produces:
66/// `users` -> `user`, `categories` -> `category`, `boxes` -> `box`.
67///
68/// This only needs to invert what [`pluralize`] generates (not English at large), so
69/// `table_name` round-trips: `singularize(&table_name("User")) == snake_case("User")`.
70pub fn singularize(word: &str) -> String {
71    if let Some(stem) = word.strip_suffix("ies") {
72        // categories -> category (pluralize turned a trailing `y` into `ies`)
73        return format!("{stem}y");
74    }
75    if let Some(stem) = word.strip_suffix("es") {
76        // `es` is only added by pluralize to stems ending in s/x/ch/sh (box -> boxes).
77        // Otherwise the trailing `s` is a plain plural over a word ending in `e` (house).
78        if stem.ends_with('s')
79            || stem.ends_with('x')
80            || stem.ends_with("ch")
81            || stem.ends_with("sh")
82        {
83            return stem.to_string();
84        }
85    }
86    if let Some(stem) = word.strip_suffix('s') {
87        return stem.to_string();
88    }
89    word.to_string()
90}
91
92/// Recover a model's `PascalCase` struct name from its module/table name:
93/// `users` -> `User`, `blog_posts` -> `BlogPost`.
94pub fn model_name(module: &str) -> String {
95    pascal_case(&singularize(module))
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn snake_cases() {
104        assert_eq!(snake_case("User"), "user");
105        assert_eq!(snake_case("BlogPost"), "blog_post");
106        assert_eq!(snake_case("OAuthToken"), "o_auth_token");
107    }
108
109    #[test]
110    fn pluralizes() {
111        assert_eq!(pluralize("user"), "users");
112        assert_eq!(pluralize("category"), "categories");
113        assert_eq!(pluralize("box"), "boxes");
114        assert_eq!(pluralize("day"), "days");
115    }
116
117    #[test]
118    fn builds_table_names() {
119        assert_eq!(table_name("User"), "users");
120        assert_eq!(table_name("BlogPost"), "blog_posts");
121        assert_eq!(table_name("Category"), "categories");
122    }
123
124    #[test]
125    fn pascal_cases() {
126        assert_eq!(pascal_case("user"), "User");
127        assert_eq!(pascal_case("blog_post"), "BlogPost");
128        assert_eq!(pascal_case("o_auth_token"), "OAuthToken");
129    }
130
131    #[test]
132    fn singularizes() {
133        assert_eq!(singularize("users"), "user");
134        assert_eq!(singularize("categories"), "category");
135        assert_eq!(singularize("boxes"), "box");
136        assert_eq!(singularize("dishes"), "dish");
137        assert_eq!(singularize("days"), "day");
138        assert_eq!(singularize("posts"), "post");
139    }
140
141    #[test]
142    fn model_name_inverts_table_name() {
143        // The round-trip `gize sync` relies on: a generated table name recovers its model.
144        for model in ["User", "BlogPost", "Category", "OAuthToken", "Product"] {
145            assert_eq!(model_name(&table_name(model)), model, "round-trip {model}");
146        }
147    }
148}