1const IRREGULAR: &[(&str, &str)] = &[
12 ("person", "people"),
13 ("child", "children"),
14 ("man", "men"),
15 ("woman", "women"),
16 ("tooth", "teeth"),
17 ("foot", "feet"),
18 ("mouse", "mice"),
19 ("goose", "geese"),
20 ("datum", "data"),
21 ("medium", "media"),
22 ("analysis", "analyses"),
23 ("index", "indexes"),
24];
25
26const UNCOUNTABLE: &[&str] = &[
28 "equipment",
29 "information",
30 "series",
31 "species",
32 "news",
33 "data",
34 "metadata",
35 "staff",
36 "sheep",
37 "fish",
38 "money",
39 "audio",
40 "settings",
41];
42
43#[must_use]
49pub fn snake_case(input: &str) -> String {
50 let chars: Vec<char> = input.chars().collect();
51 let mut out = String::with_capacity(input.len() + 4);
52
53 for (i, &c) in chars.iter().enumerate() {
54 if c == '_' || c == '-' || c == ' ' {
55 if !out.ends_with('_') && !out.is_empty() {
56 out.push('_');
57 }
58 continue;
59 }
60 if c.is_ascii_uppercase() && i > 0 {
61 let prev = chars[i - 1];
62 let next_is_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
63 let boundary = prev.is_ascii_lowercase()
66 || prev.is_ascii_digit()
67 || (prev.is_ascii_uppercase() && next_is_lower);
68 if boundary && !out.ends_with('_') && !out.is_empty() {
69 out.push('_');
70 }
71 }
72 out.extend(c.to_lowercase());
73 }
74 out
75}
76
77#[must_use]
79pub fn pluralize(word: &str) -> String {
80 if word.is_empty() {
81 return String::new();
82 }
83 let lower = word.to_lowercase();
84
85 if UNCOUNTABLE.contains(&lower.as_str()) {
86 return lower;
87 }
88 if let Some((_, plural)) = IRREGULAR.iter().find(|(s, _)| *s == lower) {
89 return (*plural).to_owned();
90 }
91 if let Some((head, tail)) = lower.rsplit_once('_') {
93 return format!("{head}_{}", pluralize(tail));
94 }
95
96 let sibilant = ["s", "x", "z", "ch", "sh"]
97 .iter()
98 .any(|suffix| lower.ends_with(suffix));
99 let consonant_y = lower.ends_with('y')
100 && lower
101 .chars()
102 .rev()
103 .nth(1)
104 .is_some_and(|p| !"aeiou".contains(p));
105
106 if sibilant {
107 format!("{lower}es")
108 } else if consonant_y {
109 format!("{}ies", &lower[..lower.len() - 1])
110 } else {
111 format!("{lower}s")
112 }
113}
114
115#[must_use]
117pub fn table_name(model: &str) -> String {
118 pluralize(&snake_case(model))
119}
120
121#[must_use]
123pub fn column_name(field: &str) -> String {
124 snake_case(field)
125}
126
127#[must_use]
129pub fn enum_type_name(name: &str) -> String {
130 snake_case(name)
131}
132
133#[must_use]
135pub fn index_name(table: &str, columns: &[String]) -> String {
136 format!("{table}_{}_idx", columns.join("_"))
137}
138
139#[must_use]
141pub fn unique_name(table: &str, columns: &[String]) -> String {
142 format!("{table}_{}_key", columns.join("_"))
143}
144
145#[must_use]
147pub fn foreign_key_name(table: &str, columns: &[String]) -> String {
148 format!("{table}_{}_fkey", columns.join("_"))
149}
150
151const RUST_KEYWORDS: &[&str] = &[
157 "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", "fn",
158 "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
159 "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", "unsafe",
160 "use", "where", "while", "async", "await", "box", "become", "do", "final", "macro", "override",
161 "priv", "try", "typeof", "unsized", "virtual", "yield",
162];
163
164#[must_use]
166pub fn is_rust_keyword(name: &str) -> bool {
167 RUST_KEYWORDS.contains(&name)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn snake_case_handles_the_shapes_we_actually_see() {
176 assert_eq!(snake_case("User"), "user");
177 assert_eq!(snake_case("createdAt"), "created_at");
178 assert_eq!(snake_case("BlogPost"), "blog_post");
179 assert_eq!(snake_case("already_snake"), "already_snake");
180 assert_eq!(snake_case("HTTPHeader"), "http_header");
181 assert_eq!(snake_case("APIKey"), "api_key");
182 assert_eq!(snake_case("v2Endpoint"), "v2_endpoint");
183 assert_eq!(snake_case("id"), "id");
184 }
185
186 #[test]
187 fn pluralize_follows_the_documented_rules() {
188 assert_eq!(pluralize("user"), "users");
189 assert_eq!(pluralize("post"), "posts");
190 assert_eq!(pluralize("category"), "categories");
191 assert_eq!(pluralize("address"), "addresses");
192 assert_eq!(pluralize("box"), "boxes");
193 assert_eq!(pluralize("dish"), "dishes");
194 assert_eq!(pluralize("day"), "days");
195 assert_eq!(pluralize("person"), "people");
196 assert_eq!(pluralize("series"), "series");
197 assert_eq!(pluralize("blog_post"), "blog_posts");
198 }
199
200 #[test]
201 fn table_and_column_names() {
202 assert_eq!(table_name("User"), "users");
203 assert_eq!(table_name("BlogPost"), "blog_posts");
204 assert_eq!(table_name("Category"), "categories");
205 assert_eq!(column_name("createdAt"), "created_at");
206 assert_eq!(enum_type_name("Role"), "role");
207 }
208
209 #[test]
210 fn keyword_detection_covers_reserved_words() {
211 assert!(is_rust_keyword("type"));
212 assert!(is_rust_keyword("become"));
213 assert!(!is_rust_keyword("email"));
214 }
215}