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 => quote! {
102                out.push((#fname_str.to_string(), self.#fname.clone()));
103            },
104            FieldKind::OptionalString => quote! {
105                // Stress-test fix (v1.4.x) — `Option<String>` does not
106                // implement `Display`, so the previous shared
107                // `String | OptionalString` arm that called
108                // `self.#fname.clone().to_string()` would not compile
109                // for any model that declared an `Option<String>`
110                // field. Mirrors the `OptionalI64` arm: None →
111                // empty string, Some(v) → v.
112                out.push((#fname_str.to_string(), match &self.#fname {
113                    Some(v) => v.clone(),
114                    None => String::new(),
115                }));
116            },
117            FieldKind::I32 | FieldKind::I64 => quote! {
118                out.push((#fname_str.to_string(), self.#fname.to_string()));
119            },
120            FieldKind::OptionalI64 => quote! {
121                out.push((#fname_str.to_string(), match &self.#fname {
122                    Some(v) => v.to_string(),
123                    None => String::new(),
124                }));
125            },
126            FieldKind::Bool => quote! {
127                out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
128            },
129            FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
130                // Phase v1.4.x — ISO-8601 form with `T` separator. This is the
131                // exact wire format `<input type="datetime-local">` expects
132                // (`%Y-%m-%dT%H:%M`); the form-render path puts this string
133                // straight into the input's `value=` attribute. The list path
134                // detects the same shape (16 chars, `T` at index 10) and
135                // splits it into the two-line time-on-top / date-below cell
136                // layout in admin/list.html.
137                //
138                // NOTE: `datetime-local` input cannot encode timezone. We
139                // currently surface UTC values directly. v1.5.0 will add
140                // user-locale conversion.
141                out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
142            },
143        };
144        display_value_arms.push(display_arm);
145
146        // `from_form`: read the HTML form body into a struct field.
147        if fname_str == "id" {
148            from_form_fields.push(quote! { #fname: 0 });
149            continue;
150        }
151
152        // Phase 1/b — precompute human-readable validation messages
153        // at expansion time so the runtime error path doesn't repeat
154        // the same `format!` work per request and so every model
155        // emits identically-styled copy ("Title is required.").
156        let humanised_label = humanise_field(&fname_str);
157        let required_msg = format!("{humanised_label} is required.");
158        let number_msg = format!("{humanised_label} must be a number.");
159        let date_invalid_msg = format!("{humanised_label} is not a valid date.");
160
161        match kind {
162            FieldKind::String => {
163                // Phase 7.6 — trim incoming whitespace so a `"   "`
164                // submission is treated as empty (and triggers the
165                // required-field error) instead of silently saving a
166                // whitespace-only string.
167                from_form_parses.push(quote! {
168                    let #fname = match form.get(#fname_str).map(str::trim) {
169                        Some(v) if !v.is_empty() => v.to_string(),
170                        _ => { errors.push(#required_msg.to_string()); String::new() }
171                    };
172                });
173                from_form_fields.push(quote! { #fname });
174            }
175            FieldKind::OptionalString => {
176                // Phase 7.6 — trim, then collapse trimmed-empty to None
177                // so the column stores NULL instead of `""`.
178                from_form_parses.push(quote! {
179                    let #fname: Option<String> = form
180                        .get(#fname_str)
181                        .map(|s| s.trim().to_string())
182                        .filter(|s| !s.is_empty());
183                });
184                from_form_fields.push(quote! { #fname });
185            }
186            FieldKind::I32 => {
187                from_form_parses.push(quote! {
188                    let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
189                        Some(v) => v,
190                        None => { errors.push(#number_msg.to_string()); 0 }
191                    };
192                });
193                from_form_fields.push(quote! { #fname });
194            }
195            FieldKind::I64 => {
196                from_form_parses.push(quote! {
197                    let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
198                        Some(v) => v,
199                        None => { errors.push(#number_msg.to_string()); 0 }
200                    };
201                });
202                from_form_fields.push(quote! { #fname });
203            }
204            FieldKind::OptionalI64 => {
205                // Phase 7.6 — distinguish "user left it blank" (None,
206                // legitimate) from "user typed garbage" (validation
207                // error, NOT silently dropped). Pre-7.6 used
208                // `.and_then(|v| v.parse().ok())` which collapsed both
209                // cases to None.
210                from_form_parses.push(quote! {
211                    let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
212                        None | Some("") => None,
213                        Some(raw) => match raw.parse::<i64>() {
214                            Ok(n) => Some(n),
215                            Err(_) => {
216                                errors.push(#number_msg.to_string());
217                                None
218                            }
219                        },
220                    };
221                });
222                from_form_fields.push(quote! { #fname });
223            }
224            FieldKind::Bool => {
225                from_form_parses.push(quote! {
226                    let #fname: bool = form.bool_flag(#fname_str);
227                });
228                from_form_fields.push(quote! { #fname });
229            }
230            FieldKind::DateTime => {
231                from_form_parses.push(quote! {
232                    let #fname = match form.get(#fname_str) {
233                        Some(raw) if !raw.is_empty() => {
234                            match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
235                                Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
236                                Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
237                            }
238                        }
239                        _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
240                    };
241                });
242                from_form_fields.push(quote! { #fname });
243            }
244            FieldKind::DateTimeAuto => {
245                // created_at-style fields default to now().
246                from_form_parses.push(quote! {
247                    let #fname = ::chrono::Utc::now();
248                });
249                from_form_fields.push(quote! { #fname });
250            }
251        }
252
253        update_tuples.push(quote! {
254            (#fname_str, self.#fname.clone().into())
255        });
256    }
257
258    let object_label_expr = find_label_field(fields)
259        .map(|n| {
260            let id = format_ident!("{n}");
261            quote! { self.#id.clone().to_string() }
262        })
263        .unwrap_or_else(|| quote! { format!("#{}", self.id) });
264
265    Ok(quote! {
266        impl ::rustio_core::admin::AdminModel for #struct_name {
267            const ADMIN_NAME: &'static str = #admin_name;
268            const DISPLAY_NAME: &'static str = #display_name;
269            const SINGULAR_NAME: &'static str = #singular;
270            const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
271                #(#field_metas),*
272            ];
273
274            fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
275                let mut out = ::std::vec::Vec::new();
276                #(#display_value_arms)*
277                out
278            }
279
280            fn from_form(form: &::rustio_core::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
281            where
282                Self: Sized,
283            {
284                let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
285                #(#from_form_parses)*
286                if !errors.is_empty() {
287                    return Err(errors);
288                }
289                Ok(Self { #(#from_form_fields),* })
290            }
291
292            fn object_label(&self) -> ::std::string::String {
293                #object_label_expr
294            }
295
296            fn id(&self) -> i64 {
297                self.id
298            }
299
300            fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_core::orm::Value)> {
301                ::std::vec![#(#update_tuples),*]
302            }
303        }
304    })
305}
306
307fn struct_fields(input: &DeriveInput) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
308    let data = match &input.data {
309        Data::Struct(s) => s,
310        _ => {
311            return Err(syn::Error::new_spanned(
312                &input.ident,
313                "RustioAdmin can only derive on structs",
314            ))
315        }
316    };
317    match &data.fields {
318        Fields::Named(named) => Ok(&named.named),
319        _ => Err(syn::Error::new_spanned(
320            &input.ident,
321            "RustioAdmin requires a struct with named fields",
322        )),
323    }
324}
325
326#[derive(Debug, PartialEq, Clone, Copy)]
327enum FieldKind {
328    I32,
329    I64,
330    Bool,
331    String,
332    DateTime,
333    DateTimeAuto,
334    OptionalString,
335    OptionalI64,
336}
337
338impl FieldKind {
339    fn field_type_ident(&self) -> proc_macro2::Ident {
340        match self {
341            FieldKind::I32 => format_ident!("I32"),
342            FieldKind::I64 => format_ident!("I64"),
343            FieldKind::Bool => format_ident!("Bool"),
344            FieldKind::String => format_ident!("String"),
345            FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
346            FieldKind::OptionalString => format_ident!("OptionalString"),
347            FieldKind::OptionalI64 => format_ident!("OptionalI64"),
348        }
349    }
350}
351
352/// Phase 1/a — names treated as framework-managed timestamps. These
353/// fields are auto-promoted to `FieldKind::DateTimeAuto` regardless of
354/// declared type so the admin UI doesn't render them and `from_form`
355/// fills them with `Utc::now()`. Conservative list; expand only when a
356/// real model needs another conventionally-named timestamp.
357fn is_auto_timestamp_name(name: &str) -> bool {
358    matches!(name, "created_at" | "updated_at")
359}
360
361/// Phase 1/b — turn a snake_case column name into a Title-Case label
362/// for human-readable validation errors emitted by `from_form`. Mirrors
363/// `rustio_core::admin::intelligence::humanise` so the error message
364/// label and the rendered form label use identical capitalisation.
365fn humanise_field(s: &str) -> String {
366    let mut out = String::with_capacity(s.len());
367    let mut next_upper = true;
368    for ch in s.chars() {
369        if ch == '_' {
370            out.push(' ');
371            next_upper = true;
372        } else if next_upper {
373            out.push(ch.to_ascii_uppercase());
374            next_upper = false;
375        } else {
376            out.push(ch);
377        }
378    }
379    out
380}
381
382fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
383    let as_string = quote! { #ty }.to_string().replace(' ', "");
384    let kind = match as_string.as_str() {
385        "i32" => FieldKind::I32,
386        "i64" => FieldKind::I64,
387        "bool" => FieldKind::Bool,
388        "String" => FieldKind::String,
389        "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
390        "Option<String>" => FieldKind::OptionalString,
391        "Option<i64>" => FieldKind::OptionalI64,
392        other => {
393            return Err(syn::Error::new_spanned(
394                ty,
395                format!("unsupported field type for RustioAdmin: {other}"),
396            ))
397        }
398    };
399    Ok(kind)
400}
401
402fn parse_relation_attr(
403    attrs: &[syn::Attribute],
404    field_name: &str,
405) -> syn::Result<Option<(String, Option<String>)>> {
406    for attr in attrs {
407        if !attr.path().is_ident("rustio") {
408            continue;
409        }
410        let mut target: Option<String> = None;
411        let mut display: Option<String> = None;
412        attr.parse_nested_meta(|m| {
413            if m.path.is_ident("belongs_to") {
414                let value = m.value()?;
415                let lit: Lit = value.parse()?;
416                if let Lit::Str(s) = lit {
417                    target = Some(s.value());
418                }
419                Ok(())
420            } else if m.path.is_ident("display") {
421                let value = m.value()?;
422                let lit: Lit = value.parse()?;
423                if let Lit::Str(s) = lit {
424                    display = Some(s.value());
425                }
426                Ok(())
427            } else {
428                Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
429            }
430        })?;
431        if let Some(t) = target {
432            return Ok(Some((t, display)));
433        }
434        if display.is_some() {
435            return Err(syn::Error::new_spanned(
436                attr,
437                "`display` requires `belongs_to` alongside it",
438            ));
439        }
440    }
441    // Just suppress the unused warning for `Meta`.
442    let _ = std::marker::PhantomData::<Meta>;
443    Ok(None)
444}
445
446fn plural_snake(camel: &str) -> String {
447    let snake = camel_to_snake(camel);
448    if snake.ends_with('s') {
449        snake
450    } else {
451        format!("{snake}s")
452    }
453}
454
455fn camel_to_snake(s: &str) -> String {
456    let mut out = String::new();
457    for (i, c) in s.chars().enumerate() {
458        if c.is_ascii_uppercase() && i > 0 {
459            out.push('_');
460        }
461        out.push(c.to_ascii_lowercase());
462    }
463    out
464}
465
466fn humanise(snake: &str) -> String {
467    // "blog_posts" → "Blog posts"
468    let mut chars = snake.chars();
469    let mut out = String::new();
470    if let Some(first) = chars.next() {
471        out.push(first.to_ascii_uppercase());
472    }
473    for c in chars {
474        if c == '_' {
475            out.push(' ');
476        } else {
477            out.push(c);
478        }
479    }
480    out
481}
482
483fn find_label_field(fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>) -> Option<String> {
484    // Heuristic: prefer `name`, then `title`, then `full_name`, then
485    // fall through to #id. Keeps object_label() useful without forcing
486    // users to implement anything.
487    //
488    // Future evolution — explicit `label_field` plug-in point:
489    // when a struct-level attribute is added (likely
490    // `#[rustio(label_field = "summary")]`), this function becomes
491    // the override site: parse the attribute off `input.attrs` in
492    // `expand`, pass the chosen ident in, and return it here before
493    // running the heuristic. The trait layer (`AdminModel::object_label`
494    // in `rustio-core/src/admin/types.rs`) and the FK rendering layer
495    // (`AdminRelation::display_field` in the same file) already accept
496    // a per-model label without further plumbing — this is the only
497    // file the new attribute needs to touch.
498    let names = ["name", "title", "full_name", "label", "email"];
499    for candidate in names {
500        if fields
501            .iter()
502            .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
503        {
504            return Some(candidate.to_string());
505        }
506    }
507    None
508}