Skip to main content

rustio_macros/
lib.rs

1//! Procedural macros for RustIO.
2//!
3//! The big one: `#[derive(RustioAdmin)]`. Given a user-written struct,
4//! the derive emits:
5//!
6//!   - `impl AdminModel for TheStruct` with `ADMIN_NAME`, `DISPLAY_NAME`,
7//!     `SINGULAR_NAME`, `FIELDS`, and the row/form/update helpers.
8//!
9//! The macro deliberately stays dumb: all runtime behaviour lives in
10//! `rustio_core`. Keeping the macro small makes it easier to debug —
11//! if something feels wrong, you can read the generated code with
12//! `cargo expand`.
13
14use proc_macro::TokenStream;
15use proc_macro2::TokenStream as TokenStream2;
16use quote::{format_ident, quote};
17use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta};
18
19#[proc_macro_derive(RustioAdmin, attributes(rustio))]
20pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
21    let input = parse_macro_input!(input as DeriveInput);
22    expand(input)
23        .unwrap_or_else(|e| e.to_compile_error())
24        .into()
25}
26
27fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
28    let struct_name = &input.ident;
29    let fields = struct_fields(&input)?;
30
31    let admin_name = plural_snake(&struct_name.to_string());
32    let display_name = humanise(&plural_snake(&struct_name.to_string()));
33    let singular = struct_name.to_string();
34
35    let mut field_metas = Vec::new();
36    let mut display_value_arms = Vec::new();
37    let mut from_form_parses = Vec::new();
38    let mut from_form_fields = Vec::new();
39    let mut update_tuples = Vec::new();
40
41    for f in fields {
42        let fname = f.ident.as_ref().unwrap();
43        let fname_str = fname.to_string();
44        let kind = classify_type(&f.ty)?;
45        // Phase 1/a — fields named `created_at` / `updated_at` are
46        // managed by the framework: hidden from forms, defaulted to
47        // `Utc::now()` in `from_form`. The macro already wires that
48        // behaviour through `FieldKind::DateTimeAuto`; this promotion
49        // is the missing trigger that makes the variant reachable for
50        // the conventionally named timestamp columns.
51        let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
52            FieldKind::DateTimeAuto
53        } else {
54            kind
55        };
56        let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
57
58        let type_variant = kind.field_type_ident();
59        let relation = parse_relation_attr(&f.attrs, &fname_str)?;
60        let relation_tokens = match &relation {
61            Some((target, display)) => {
62                let display_tok = match display {
63                    Some(d) => quote! { ::std::option::Option::Some(#d) },
64                    None => quote! { ::std::option::Option::None },
65                };
66                quote! {
67                    ::std::option::Option::Some(::rustio_core::admin::AdminRelation {
68                        target_model: #target,
69                        display_field: #display_tok,
70                        // Phase 5/d — single belongs_to relations default to
71                        // single `<select>`. Many-to-many is opt-in via a
72                        // future `#[rustio(many_to_many)]` attribute; the
73                        // macro emits `false` for now so consumers that want
74                        // multi-select must hand-set the field on the
75                        // generated AdminRelation.
76                        multi: false,
77                    })
78                }
79            }
80            None => quote! { ::std::option::Option::None },
81        };
82
83        field_metas.push(quote! {
84            ::rustio_core::admin::AdminField {
85                name: #fname_str,
86                label: #fname_str,
87                field_type: ::rustio_core::admin::FieldType::#type_variant,
88                editable: #editable,
89                relation: #relation_tokens,
90                // Phase 5/d — derived models don't carry enum choices yet.
91                // A future macro pass will accept `#[rustio(choices = [...])]`
92                // and populate this; today consumers that want a `<select>`
93                // backed by a static value list set this on the generated
94                // AdminField directly.
95                choices: ::std::option::Option::None,
96            }
97        });
98
99        // `display_values`: stringify the field for the list page.
100        let display_arm = match kind {
101            FieldKind::String | FieldKind::OptionalString => quote! {
102                out.push((#fname_str.to_string(), self.#fname.clone().to_string()));
103            },
104            FieldKind::I32 | FieldKind::I64 => quote! {
105                out.push((#fname_str.to_string(), self.#fname.to_string()));
106            },
107            FieldKind::OptionalI64 => quote! {
108                out.push((#fname_str.to_string(), match &self.#fname {
109                    Some(v) => v.to_string(),
110                    None => String::new(),
111                }));
112            },
113            FieldKind::Bool => quote! {
114                out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
115            },
116            FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
117                out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%d %H:%M").to_string()));
118            },
119        };
120        display_value_arms.push(display_arm);
121
122        // `from_form`: read the HTML form body into a struct field.
123        if fname_str == "id" {
124            from_form_fields.push(quote! { #fname: 0 });
125            continue;
126        }
127
128        // Phase 1/b — precompute human-readable validation messages
129        // at expansion time so the runtime error path doesn't repeat
130        // the same `format!` work per request and so every model
131        // emits identically-styled copy ("Title is required.").
132        let humanised_label = humanise_field(&fname_str);
133        let required_msg = format!("{humanised_label} is required.");
134        let number_msg = format!("{humanised_label} must be a number.");
135        let date_invalid_msg = format!("{humanised_label} is not a valid date.");
136
137        match kind {
138            FieldKind::String => {
139                // Phase 7.6 — trim incoming whitespace so a `"   "`
140                // submission is treated as empty (and triggers the
141                // required-field error) instead of silently saving a
142                // whitespace-only string.
143                from_form_parses.push(quote! {
144                    let #fname = match form.get(#fname_str).map(str::trim) {
145                        Some(v) if !v.is_empty() => v.to_string(),
146                        _ => { errors.push(#required_msg.to_string()); String::new() }
147                    };
148                });
149                from_form_fields.push(quote! { #fname });
150            }
151            FieldKind::OptionalString => {
152                // Phase 7.6 — trim, then collapse trimmed-empty to None
153                // so the column stores NULL instead of `""`.
154                from_form_parses.push(quote! {
155                    let #fname: Option<String> = form
156                        .get(#fname_str)
157                        .map(|s| s.trim().to_string())
158                        .filter(|s| !s.is_empty());
159                });
160                from_form_fields.push(quote! { #fname });
161            }
162            FieldKind::I32 => {
163                from_form_parses.push(quote! {
164                    let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
165                        Some(v) => v,
166                        None => { errors.push(#number_msg.to_string()); 0 }
167                    };
168                });
169                from_form_fields.push(quote! { #fname });
170            }
171            FieldKind::I64 => {
172                from_form_parses.push(quote! {
173                    let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
174                        Some(v) => v,
175                        None => { errors.push(#number_msg.to_string()); 0 }
176                    };
177                });
178                from_form_fields.push(quote! { #fname });
179            }
180            FieldKind::OptionalI64 => {
181                // Phase 7.6 — distinguish "user left it blank" (None,
182                // legitimate) from "user typed garbage" (validation
183                // error, NOT silently dropped). Pre-7.6 used
184                // `.and_then(|v| v.parse().ok())` which collapsed both
185                // cases to None.
186                from_form_parses.push(quote! {
187                    let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
188                        None | Some("") => None,
189                        Some(raw) => match raw.parse::<i64>() {
190                            Ok(n) => Some(n),
191                            Err(_) => {
192                                errors.push(#number_msg.to_string());
193                                None
194                            }
195                        },
196                    };
197                });
198                from_form_fields.push(quote! { #fname });
199            }
200            FieldKind::Bool => {
201                from_form_parses.push(quote! {
202                    let #fname: bool = form.bool_flag(#fname_str);
203                });
204                from_form_fields.push(quote! { #fname });
205            }
206            FieldKind::DateTime => {
207                from_form_parses.push(quote! {
208                    let #fname = match form.get(#fname_str) {
209                        Some(raw) if !raw.is_empty() => {
210                            match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
211                                Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
212                                Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
213                            }
214                        }
215                        _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
216                    };
217                });
218                from_form_fields.push(quote! { #fname });
219            }
220            FieldKind::DateTimeAuto => {
221                // created_at-style fields default to now().
222                from_form_parses.push(quote! {
223                    let #fname = ::chrono::Utc::now();
224                });
225                from_form_fields.push(quote! { #fname });
226            }
227        }
228
229        update_tuples.push(quote! {
230            (#fname_str, self.#fname.clone().into())
231        });
232    }
233
234    let object_label_expr = find_label_field(fields)
235        .map(|n| {
236            let id = format_ident!("{n}");
237            quote! { self.#id.clone().to_string() }
238        })
239        .unwrap_or_else(|| quote! { format!("#{}", self.id) });
240
241    Ok(quote! {
242        impl ::rustio_core::admin::AdminModel for #struct_name {
243            const ADMIN_NAME: &'static str = #admin_name;
244            const DISPLAY_NAME: &'static str = #display_name;
245            const SINGULAR_NAME: &'static str = #singular;
246            const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
247                #(#field_metas),*
248            ];
249
250            fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
251                let mut out = ::std::vec::Vec::new();
252                #(#display_value_arms)*
253                out
254            }
255
256            fn from_form(form: &::rustio_core::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
257            where
258                Self: Sized,
259            {
260                let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
261                #(#from_form_parses)*
262                if !errors.is_empty() {
263                    return Err(errors);
264                }
265                Ok(Self { #(#from_form_fields),* })
266            }
267
268            fn object_label(&self) -> ::std::string::String {
269                #object_label_expr
270            }
271
272            fn id(&self) -> i64 {
273                self.id
274            }
275
276            fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_core::orm::Value)> {
277                ::std::vec![#(#update_tuples),*]
278            }
279        }
280    })
281}
282
283fn struct_fields(input: &DeriveInput) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
284    let data = match &input.data {
285        Data::Struct(s) => s,
286        _ => {
287            return Err(syn::Error::new_spanned(
288                &input.ident,
289                "RustioAdmin can only derive on structs",
290            ))
291        }
292    };
293    match &data.fields {
294        Fields::Named(named) => Ok(&named.named),
295        _ => Err(syn::Error::new_spanned(
296            &input.ident,
297            "RustioAdmin requires a struct with named fields",
298        )),
299    }
300}
301
302#[derive(Debug, PartialEq, Clone, Copy)]
303enum FieldKind {
304    I32,
305    I64,
306    Bool,
307    String,
308    DateTime,
309    DateTimeAuto,
310    OptionalString,
311    OptionalI64,
312}
313
314impl FieldKind {
315    fn field_type_ident(&self) -> proc_macro2::Ident {
316        match self {
317            FieldKind::I32 => format_ident!("I32"),
318            FieldKind::I64 => format_ident!("I64"),
319            FieldKind::Bool => format_ident!("Bool"),
320            FieldKind::String => format_ident!("String"),
321            FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
322            FieldKind::OptionalString => format_ident!("OptionalString"),
323            FieldKind::OptionalI64 => format_ident!("OptionalI64"),
324        }
325    }
326}
327
328/// Phase 1/a — names treated as framework-managed timestamps. These
329/// fields are auto-promoted to `FieldKind::DateTimeAuto` regardless of
330/// declared type so the admin UI doesn't render them and `from_form`
331/// fills them with `Utc::now()`. Conservative list; expand only when a
332/// real model needs another conventionally-named timestamp.
333fn is_auto_timestamp_name(name: &str) -> bool {
334    matches!(name, "created_at" | "updated_at")
335}
336
337/// Phase 1/b — turn a snake_case column name into a Title-Case label
338/// for human-readable validation errors emitted by `from_form`. Mirrors
339/// `rustio_core::admin::intelligence::humanise` so the error message
340/// label and the rendered form label use identical capitalisation.
341fn humanise_field(s: &str) -> String {
342    let mut out = String::with_capacity(s.len());
343    let mut next_upper = true;
344    for ch in s.chars() {
345        if ch == '_' {
346            out.push(' ');
347            next_upper = true;
348        } else if next_upper {
349            out.push(ch.to_ascii_uppercase());
350            next_upper = false;
351        } else {
352            out.push(ch);
353        }
354    }
355    out
356}
357
358fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
359    let as_string = quote! { #ty }.to_string().replace(' ', "");
360    let kind = match as_string.as_str() {
361        "i32" => FieldKind::I32,
362        "i64" => FieldKind::I64,
363        "bool" => FieldKind::Bool,
364        "String" => FieldKind::String,
365        "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
366        "Option<String>" => FieldKind::OptionalString,
367        "Option<i64>" => FieldKind::OptionalI64,
368        other => {
369            return Err(syn::Error::new_spanned(
370                ty,
371                format!("unsupported field type for RustioAdmin: {other}"),
372            ))
373        }
374    };
375    Ok(kind)
376}
377
378fn parse_relation_attr(
379    attrs: &[syn::Attribute],
380    field_name: &str,
381) -> syn::Result<Option<(String, Option<String>)>> {
382    for attr in attrs {
383        if !attr.path().is_ident("rustio") {
384            continue;
385        }
386        let mut target: Option<String> = None;
387        let mut display: Option<String> = None;
388        attr.parse_nested_meta(|m| {
389            if m.path.is_ident("belongs_to") {
390                let value = m.value()?;
391                let lit: Lit = value.parse()?;
392                if let Lit::Str(s) = lit {
393                    target = Some(s.value());
394                }
395                Ok(())
396            } else if m.path.is_ident("display") {
397                let value = m.value()?;
398                let lit: Lit = value.parse()?;
399                if let Lit::Str(s) = lit {
400                    display = Some(s.value());
401                }
402                Ok(())
403            } else {
404                Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
405            }
406        })?;
407        if let Some(t) = target {
408            return Ok(Some((t, display)));
409        }
410        if display.is_some() {
411            return Err(syn::Error::new_spanned(
412                attr,
413                "`display` requires `belongs_to` alongside it",
414            ));
415        }
416    }
417    // Just suppress the unused warning for `Meta`.
418    let _ = std::marker::PhantomData::<Meta>;
419    Ok(None)
420}
421
422fn plural_snake(camel: &str) -> String {
423    let snake = camel_to_snake(camel);
424    if snake.ends_with('s') {
425        snake
426    } else {
427        format!("{snake}s")
428    }
429}
430
431fn camel_to_snake(s: &str) -> String {
432    let mut out = String::new();
433    for (i, c) in s.chars().enumerate() {
434        if c.is_ascii_uppercase() && i > 0 {
435            out.push('_');
436        }
437        out.push(c.to_ascii_lowercase());
438    }
439    out
440}
441
442fn humanise(snake: &str) -> String {
443    // "blog_posts" → "Blog posts"
444    let mut chars = snake.chars();
445    let mut out = String::new();
446    if let Some(first) = chars.next() {
447        out.push(first.to_ascii_uppercase());
448    }
449    for c in chars {
450        if c == '_' {
451            out.push(' ');
452        } else {
453            out.push(c);
454        }
455    }
456    out
457}
458
459fn find_label_field(fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>) -> Option<String> {
460    // Heuristic: prefer `name`, then `title`, then `full_name`, then
461    // fall through to #id. Keeps object_label() useful without forcing
462    // users to implement anything.
463    let names = ["name", "title", "full_name", "label", "email"];
464    for candidate in names {
465        if fields
466            .iter()
467            .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
468        {
469            return Some(candidate.to_string());
470        }
471    }
472    None
473}