Skip to main content

rustio_admin_macros/
lib.rs

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