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