Skip to main content

grounddb_codegen/
type_utils.rs

1use grounddb::schema::{FieldDefinition, FieldType, ItemType, RefTarget};
2use heck::{ToPascalCase, ToSnakeCase};
3use proc_macro2::TokenStream;
4use quote::{format_ident, quote};
5
6/// Convert a collection name to its singular PascalCase struct name.
7/// e.g. "users" -> "User", "posts" -> "Post", "comments" -> "Comment"
8pub fn collection_struct_name(collection_name: &str) -> String {
9    singularize(collection_name).to_pascal_case()
10}
11
12/// Convert a collection name to a snake_case method name.
13/// e.g. "users" -> "users", "post_feed" -> "post_feed"
14pub fn collection_method_name(collection_name: &str) -> String {
15    collection_name.to_snake_case()
16}
17
18/// Generate an enum name from collection singular + field name.
19/// e.g. ("users", "role") -> "UserRole"
20pub fn enum_type_name(collection_name: &str, field_name: &str) -> String {
21    let singular = singularize(collection_name);
22    format!(
23        "{}{}",
24        singular.to_pascal_case(),
25        field_name.to_pascal_case()
26    )
27}
28
29/// Generate a polymorphic ref enum name from field name.
30/// e.g. "parent" -> "ParentRef"
31pub fn ref_enum_name(field_name: &str) -> String {
32    format!("{}Ref", field_name.to_pascal_case())
33}
34
35/// Generate a view row struct name.
36/// e.g. "post_feed" -> "PostFeedRow"
37pub fn view_row_name(view_name: &str) -> String {
38    format!("{}Row", view_name.to_pascal_case())
39}
40
41/// Generate a view params struct name.
42/// e.g. "post_comments" -> "PostCommentsParams"
43pub fn view_params_name(view_name: &str) -> String {
44    format!("{}Params", view_name.to_pascal_case())
45}
46
47/// Generate a partial struct name.
48/// e.g. "User" -> "UserPartial"
49pub fn partial_struct_name(struct_name: &str) -> String {
50    format!("{}Partial", struct_name)
51}
52
53/// Map a schema field to its Rust type as a TokenStream.
54/// `collection_name` is used for naming generated enums.
55/// `known_types` is the set of reusable type names from the schema.
56pub fn field_to_rust_type(
57    field: &FieldDefinition,
58    collection_name: &str,
59    field_name: &str,
60    known_types: &[String],
61) -> TokenStream {
62    let base_type = field_base_type(field, collection_name, field_name, known_types);
63
64    // Wrap in Option if not required and no default
65    if !field.required && field.default.is_none() {
66        // Lists default to empty vec, objects default to empty value - don't wrap those
67        match &field.field_type {
68            FieldType::List => base_type,
69            FieldType::Object => {
70                quote! { Option<#base_type> }
71            }
72            _ => {
73                quote! { Option<#base_type> }
74            }
75        }
76    } else {
77        base_type
78    }
79}
80
81/// Get the base Rust type (without Option wrapping) for a field.
82pub fn field_base_type(
83    field: &FieldDefinition,
84    collection_name: &str,
85    field_name: &str,
86    known_types: &[String],
87) -> TokenStream {
88    // If field has enum values, use the generated enum type
89    if field.enum_values.is_some() {
90        let name = enum_type_name(collection_name, field_name);
91        let ident = format_ident!("{}", name);
92        return quote! { #ident };
93    }
94
95    match &field.field_type {
96        FieldType::String => quote! { String },
97        FieldType::Number => quote! { f64 },
98        FieldType::Boolean => quote! { bool },
99        FieldType::Date => quote! { chrono::NaiveDate },
100        FieldType::Datetime => quote! { chrono::DateTime<chrono::Utc> },
101        FieldType::Object => quote! { serde_json::Value },
102        FieldType::List => {
103            let item_type = list_item_type(field, collection_name, field_name, known_types);
104            quote! { Vec<#item_type> }
105        }
106        FieldType::Ref => ref_rust_type(field, field_name),
107        FieldType::Custom(type_name) => {
108            if known_types.contains(type_name) {
109                let ident = format_ident!("{}", type_name.to_pascal_case());
110                quote! { #ident }
111            } else {
112                // Fallback to serde_json::Value for unknown types
113                quote! { serde_json::Value }
114            }
115        }
116    }
117}
118
119/// Get the Rust type for a list's item type.
120fn list_item_type(
121    field: &FieldDefinition,
122    _collection_name: &str,
123    _field_name: &str,
124    known_types: &[String],
125) -> TokenStream {
126    match &field.items {
127        Some(ItemType::Simple(s)) => match s.as_str() {
128            "string" => quote! { String },
129            "number" => quote! { f64 },
130            "boolean" => quote! { bool },
131            "date" => quote! { chrono::NaiveDate },
132            "datetime" => quote! { chrono::DateTime<chrono::Utc> },
133            "object" => quote! { serde_json::Value },
134            other => {
135                if known_types.contains(&other.to_string()) {
136                    let ident = format_ident!("{}", other.to_pascal_case());
137                    quote! { #ident }
138                } else {
139                    quote! { serde_json::Value }
140                }
141            }
142        },
143        Some(ItemType::Complex(inner)) => {
144            // Complex item: check if it's a ref type
145            match &inner.field_type {
146                FieldType::Ref => {
147                    // List of refs - just use String for now
148                    quote! { String }
149                }
150                _ => quote! { serde_json::Value },
151            }
152        }
153        None => quote! { serde_json::Value },
154    }
155}
156
157/// Get the Rust type for a ref field.
158fn ref_rust_type(field: &FieldDefinition, field_name: &str) -> TokenStream {
159    match &field.target {
160        Some(RefTarget::Single(_)) => quote! { String },
161        Some(RefTarget::Multiple(_)) => {
162            let name = ref_enum_name(field_name);
163            let ident = format_ident!("{}", name);
164            quote! { #ident }
165        }
166        None => quote! { String },
167    }
168}
169
170/// Naive singularization of English words.
171pub fn singularize(word: &str) -> String {
172    let w = word.to_lowercase();
173    if w.ends_with("ies") {
174        format!("{}y", &w[..w.len() - 3])
175    } else if w.ends_with("ses") || w.ends_with("xes") || w.ends_with("zes") {
176        w[..w.len() - 2].to_string()
177    } else if w.ends_with("ves") {
178        format!("{}f", &w[..w.len() - 3])
179    } else if w.ends_with('s') && !w.ends_with("ss") {
180        w[..w.len() - 1].to_string()
181    } else {
182        w
183    }
184}
185
186/// Check if a field name is a Rust keyword and needs raw identifier syntax.
187pub fn safe_field_ident(name: &str) -> proc_macro2::Ident {
188    match name {
189        "type" | "struct" | "enum" | "fn" | "let" | "mut" | "ref" | "self" | "super" | "crate"
190        | "mod" | "use" | "pub" | "impl" | "trait" | "for" | "loop" | "while" | "if" | "else"
191        | "match" | "return" | "break" | "continue" | "as" | "in" | "where" | "async"
192        | "await" | "dyn" | "move" | "static" | "const" | "unsafe" | "extern" | "true"
193        | "false" | "abstract" | "become" | "box" | "do" | "final" | "macro" | "override"
194        | "priv" | "typeof" | "unsized" | "virtual" | "yield" | "try" => {
195            format_ident!("r#{}", name)
196        }
197        _ => format_ident!("{}", name.to_snake_case()),
198    }
199}
200
201/// Convert a string to a valid Rust identifier, preserving original casing for enum variants.
202pub fn enum_variant_ident(value: &str) -> proc_macro2::Ident {
203    format_ident!("{}", value.to_pascal_case())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_singularize() {
212        assert_eq!(singularize("users"), "user");
213        assert_eq!(singularize("posts"), "post");
214        assert_eq!(singularize("comments"), "comment");
215        assert_eq!(singularize("events"), "event");
216        assert_eq!(singularize("categories"), "category");
217        assert_eq!(singularize("addresses"), "address");
218    }
219
220    #[test]
221    fn test_collection_struct_name() {
222        assert_eq!(collection_struct_name("users"), "User");
223        assert_eq!(collection_struct_name("posts"), "Post");
224        assert_eq!(collection_struct_name("comments"), "Comment");
225        assert_eq!(collection_struct_name("events"), "Event");
226    }
227
228    #[test]
229    fn test_enum_type_name() {
230        assert_eq!(enum_type_name("users", "role"), "UserRole");
231        assert_eq!(enum_type_name("posts", "status"), "PostStatus");
232        assert_eq!(enum_type_name("events", "severity"), "EventSeverity");
233    }
234
235    #[test]
236    fn test_ref_enum_name() {
237        assert_eq!(ref_enum_name("parent"), "ParentRef");
238    }
239
240    #[test]
241    fn test_view_names() {
242        assert_eq!(view_row_name("post_feed"), "PostFeedRow");
243        assert_eq!(view_params_name("post_comments"), "PostCommentsParams");
244    }
245
246    #[test]
247    fn test_safe_field_ident() {
248        let ident = safe_field_ident("type");
249        assert_eq!(ident.to_string(), "r#type");
250
251        let ident = safe_field_ident("name");
252        assert_eq!(ident.to_string(), "name");
253    }
254}