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