Skip to main content

rustio_macros/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::TokenStream as TokenStream2;
3use quote::quote;
4use syn::{
5    parse_macro_input, Data, DeriveInput, Field, Fields, GenericArgument, Lit, PathArguments, Type,
6};
7
8#[derive(Clone, Copy)]
9enum FieldKind {
10    I32,
11    I64,
12    String,
13    Bool,
14    DateTime,
15}
16
17/// Parsed `#[rustio(belongs_to = "Patient", display = "full_name")]`
18/// declaration on a struct field. The macro emits both the runtime
19/// `AdminRelation` metadata and a companion `const _` block that makes
20/// the compiler verify the target type exists and the named display
21/// column is real — both failures surface as ordinary compile errors.
22#[derive(Clone)]
23struct RelationAttr {
24    /// Target model type name — must resolve as a type in scope (the
25    /// `const` verification block references `<Target as Model>`).
26    target: syn::Ident,
27    /// Optional column on the target whose value will be rendered in
28    /// the admin. `None` ⇒ the admin shows `#<id>`; no inference.
29    display: Option<String>,
30}
31
32struct FieldInfo {
33    ident: syn::Ident,
34    name_str: String,
35    kind: FieldKind,
36    editable: bool,
37    nullable: bool,
38    relation: Option<RelationAttr>,
39}
40
41#[proc_macro_derive(RustioAdmin, attributes(rustio))]
42pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
43    let input = parse_macro_input!(input as DeriveInput);
44    let name = &input.ident;
45
46    let data = match &input.data {
47        Data::Struct(d) => d,
48        _ => {
49            return syn::Error::new_spanned(
50                &input.ident,
51                "RustioAdmin only supports structs with named fields",
52            )
53            .to_compile_error()
54            .into();
55        }
56    };
57
58    let named = match &data.fields {
59        Fields::Named(n) => n,
60        _ => {
61            return syn::Error::new_spanned(&input.ident, "RustioAdmin requires named fields")
62                .to_compile_error()
63                .into();
64        }
65    };
66
67    let mut fields: Vec<FieldInfo> = Vec::new();
68    for f in &named.named {
69        let ident = f.ident.clone().expect("named field");
70        let name_str = ident.to_string();
71        let (kind, nullable) = match classify_type(&f.ty) {
72            Some(r) => r,
73            None => {
74                return syn::Error::new_spanned(
75                    &f.ty,
76                    "RustioAdmin: unsupported field type (supported: i32, i64, \
77                     String, bool, DateTime<Utc>, and Option<T> of any of those)",
78                )
79                .to_compile_error()
80                .into();
81            }
82        };
83        // `id` is always non-editable; `Option<i64>` ids are not supported
84        // (the ORM contract requires an `i64` id).
85        if name_str == "id" && nullable {
86            return syn::Error::new_spanned(
87                &f.ty,
88                "RustioAdmin: `id` must be `i64`, not `Option<i64>`",
89            )
90            .to_compile_error()
91            .into();
92        }
93        let editable = name_str != "id";
94
95        let relation = match parse_relation_attr(f) {
96            Ok(r) => r,
97            Err(e) => return e.to_compile_error().into(),
98        };
99
100        // Relations only make sense on integer foreign-key columns; the
101        // macro's job is to catch the nonsense case at compile time.
102        if relation.is_some() && !matches!(kind, FieldKind::I64 | FieldKind::I32) {
103            return syn::Error::new_spanned(
104                &f.ty,
105                "RustioAdmin: #[rustio(belongs_to = \"...\")] can only be applied to \
106                 `i32` or `i64` fields (the foreign-key column)",
107            )
108            .to_compile_error()
109            .into();
110        }
111
112        fields.push(FieldInfo {
113            ident,
114            name_str,
115            kind,
116            editable,
117            nullable,
118            relation,
119        });
120    }
121
122    let admin_name = pluralize(&name.to_string().to_lowercase());
123    let display_name = pluralize(&name.to_string());
124    let singular_name = singularize(&name.to_string());
125
126    let field_entries: Vec<TokenStream2> = fields
127        .iter()
128        .map(|f| {
129            let n = &f.name_str;
130            let kind_token = kind_token(f.kind);
131            let editable = f.editable;
132            let nullable = f.nullable;
133            let relation_token = relation_token(f.relation.as_ref());
134            quote! {
135                ::rustio_core::admin::AdminField {
136                    name: #n,
137                    ty: #kind_token,
138                    editable: #editable,
139                    nullable: #nullable,
140                    relation: #relation_token,
141                }
142            }
143        })
144        .collect();
145
146    let display_arms: Vec<TokenStream2> = fields.iter().map(display_arm).collect();
147
148    let from_form_assignments: Vec<TokenStream2> =
149        fields.iter().map(from_form_assignment).collect();
150
151    // Compile-time checks for every `#[rustio(belongs_to = "...")]`:
152    // target type must exist and impl `Model`; if `display = "..."` is
153    // set, the named column must appear in `Target::COLUMNS`. Both live
154    // in a single `const _: ()` block per struct so bad declarations
155    // fail the build with a readable message.
156    let relation_checks: Vec<TokenStream2> = fields
157        .iter()
158        .filter_map(|f| f.relation.as_ref().map(|r| relation_check(&f.name_str, r)))
159        .collect();
160
161    let expanded = quote! {
162        impl ::rustio_core::admin::AdminModel for #name {
163            const ADMIN_NAME: &'static str = #admin_name;
164            const DISPLAY_NAME: &'static str = #display_name;
165            const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
166                #( #field_entries ),*
167            ];
168
169            fn singular_name() -> &'static str {
170                #singular_name
171            }
172
173            fn field_display(&self, name: &str) -> Option<String> {
174                match name {
175                    #( #display_arms )*
176                    _ => None,
177                }
178            }
179
180            fn from_form(
181                form: &::rustio_core::admin::FormData,
182                id: Option<i64>,
183            ) -> Result<Self, ::rustio_core::Error> {
184                Ok(Self {
185                    #( #from_form_assignments )*
186                })
187            }
188        }
189
190        #( #relation_checks )*
191    };
192
193    expanded.into()
194}
195
196fn pluralize(name: &str) -> String {
197    if name.ends_with('s') {
198        name.to_string()
199    } else {
200        format!("{name}s")
201    }
202}
203
204fn singularize(name: &str) -> String {
205    if let Some(stripped) = name.strip_suffix('s') {
206        if !stripped.is_empty() {
207            return stripped.to_string();
208        }
209    }
210    name.to_string()
211}
212
213/// Parse `#[rustio(belongs_to = "Patient")]` or
214/// `#[rustio(belongs_to = "Patient", display = "full_name")]`.
215/// Returns `Ok(None)` if the field has no `#[rustio(...)]` attribute.
216/// Returns `Err` if the attribute is malformed — unknown keys, wrong
217/// value types, `display` without `belongs_to`, etc.
218fn parse_relation_attr(field: &Field) -> syn::Result<Option<RelationAttr>> {
219    let mut found: Option<RelationAttr> = None;
220    for attr in &field.attrs {
221        if !attr.path().is_ident("rustio") {
222            continue;
223        }
224
225        let mut belongs_to: Option<syn::Ident> = None;
226        let mut display: Option<String> = None;
227
228        attr.parse_nested_meta(|meta| {
229            if meta.path.is_ident("belongs_to") {
230                let value: Lit = meta.value()?.parse()?;
231                match value {
232                    Lit::Str(s) => {
233                        let ident = syn::parse_str::<syn::Ident>(&s.value()).map_err(|_| {
234                            meta.error(format!(
235                                "`belongs_to` value `{}` is not a valid Rust type name",
236                                s.value()
237                            ))
238                        })?;
239                        belongs_to = Some(ident);
240                        Ok(())
241                    }
242                    _ => Err(meta.error("`belongs_to` must be a string literal")),
243                }
244            } else if meta.path.is_ident("display") {
245                let value: Lit = meta.value()?.parse()?;
246                match value {
247                    Lit::Str(s) => {
248                        display = Some(s.value());
249                        Ok(())
250                    }
251                    _ => Err(meta.error("`display` must be a string literal")),
252                }
253            } else {
254                Err(meta.error(format!(
255                    "unknown #[rustio(...)] key: `{}` (expected `belongs_to` or `display`)",
256                    meta.path
257                        .get_ident()
258                        .map(|i| i.to_string())
259                        .unwrap_or_else(|| "<non-ident>".into())
260                )))
261            }
262        })?;
263
264        match (belongs_to, display) {
265            (Some(target), display) => {
266                if found.is_some() {
267                    return Err(syn::Error::new_spanned(
268                        attr,
269                        "#[rustio(...)] may appear at most once per field",
270                    ));
271                }
272                found = Some(RelationAttr { target, display });
273            }
274            (None, Some(_)) => {
275                return Err(syn::Error::new_spanned(
276                    attr,
277                    "#[rustio(display = \"...\")] requires `belongs_to = \"...\"` on the same field",
278                ));
279            }
280            (None, None) => {
281                return Err(syn::Error::new_spanned(
282                    attr,
283                    "empty #[rustio()]: expected `belongs_to = \"ModelName\"`",
284                ));
285            }
286        }
287    }
288    Ok(found)
289}
290
291fn relation_token(r: Option<&RelationAttr>) -> TokenStream2 {
292    let Some(r) = r else {
293        return quote! { None };
294    };
295    let target = r.target.to_string();
296    let display_token = match &r.display {
297        Some(s) => quote! { Some(#s) },
298        None => quote! { None },
299    };
300    quote! {
301        Some(::rustio_core::admin::AdminRelation {
302            kind: ::rustio_core::schema::RelationKind::BelongsTo,
303            model: #target,
304            display_field: #display_token,
305        })
306    }
307}
308
309/// Emit a `const _: ()` block that forces the compiler to:
310///   1. resolve `<Target as ::rustio_core::Model>` — fails if the type
311///      doesn't exist or doesn't implement `Model`;
312///   2. if `display = "col"` is set, verify that `col` appears in
313///      `<Target as Model>::COLUMNS` — fails with a readable panic
314///      message at const-eval time if it doesn't.
315///
316/// The field name is baked into the error message so the compiler's
317/// output points at the author's declaration without needing a span
318/// round-trip through the const block.
319fn relation_check(field_name: &str, r: &RelationAttr) -> TokenStream2 {
320    let target = &r.target;
321    let target_str = target.to_string();
322    match &r.display {
323        None => quote! {
324            const _: () = {
325                // Forces the target to exist and impl `Model`.
326                let _: &'static str = <#target as ::rustio_core::orm::Model>::TABLE;
327            };
328        },
329        Some(display) => {
330            let not_found_msg = format!(
331                "#[rustio(belongs_to = \"{target_str}\", display = \"{display}\")] on field `{field_name}`: \
332                 column `{display}` not found in `{target_str}::COLUMNS`. Declare the field on the target \
333                 model or drop the `display = ...` key."
334            );
335            quote! {
336                const _: () = {
337                    let _: &'static str = <#target as ::rustio_core::orm::Model>::TABLE;
338
339                    const fn __rustio_str_eq(a: &str, b: &str) -> bool {
340                        let a = a.as_bytes();
341                        let b = b.as_bytes();
342                        if a.len() != b.len() {
343                            return false;
344                        }
345                        let mut i = 0;
346                        while i < a.len() {
347                            if a[i] != b[i] {
348                                return false;
349                            }
350                            i += 1;
351                        }
352                        true
353                    }
354
355                    let cols = <#target as ::rustio_core::orm::Model>::COLUMNS;
356                    let mut i = 0;
357                    let mut found = false;
358                    while i < cols.len() {
359                        if __rustio_str_eq(cols[i], #display) {
360                            found = true;
361                        }
362                        i += 1;
363                    }
364                    if !found {
365                        panic!(#not_found_msg);
366                    }
367                };
368            }
369        }
370    }
371}
372
373/// Classify a struct field's type into `(FieldKind, nullable)`.
374///
375/// Peels a single layer of `Option<T>` and marks the field nullable;
376/// rejects unknown types and nested optionals.
377fn classify_type(ty: &Type) -> Option<(FieldKind, bool)> {
378    let Type::Path(syn::TypePath { path, .. }) = ty else {
379        return None;
380    };
381    let last = path.segments.last()?;
382
383    if last.ident == "Option" {
384        // Peel exactly one layer; `Option<Option<T>>` is not supported.
385        let PathArguments::AngleBracketed(args) = &last.arguments else {
386            return None;
387        };
388        let inner_ty = args.args.iter().find_map(|a| match a {
389            GenericArgument::Type(t) => Some(t),
390            _ => None,
391        })?;
392        let kind = base_kind(inner_ty)?;
393        return Some((kind, true));
394    }
395
396    base_kind(ty).map(|k| (k, false))
397}
398
399/// Classify a non-`Option` type into a `FieldKind`.
400fn base_kind(ty: &Type) -> Option<FieldKind> {
401    let Type::Path(syn::TypePath { path, .. }) = ty else {
402        return None;
403    };
404    let last = path.segments.last()?;
405    match last.ident.to_string().as_str() {
406        "i32" => Some(FieldKind::I32),
407        "i64" => Some(FieldKind::I64),
408        "String" => Some(FieldKind::String),
409        "bool" => Some(FieldKind::Bool),
410        // Accept both `DateTime` and the fully-qualified `DateTime<Utc>`.
411        // We don't verify the type parameter; if it isn't `Utc`, the trait
412        // bounds on `Row::get_datetime` will surface the error at the use
413        // site with a much better message than anything we could produce
414        // here.
415        "DateTime" => Some(FieldKind::DateTime),
416        _ => None,
417    }
418}
419
420fn kind_token(kind: FieldKind) -> TokenStream2 {
421    match kind {
422        FieldKind::I32 => quote! { ::rustio_core::admin::FieldType::I32 },
423        FieldKind::I64 => quote! { ::rustio_core::admin::FieldType::I64 },
424        FieldKind::String => quote! { ::rustio_core::admin::FieldType::String },
425        FieldKind::Bool => quote! { ::rustio_core::admin::FieldType::Bool },
426        FieldKind::DateTime => quote! { ::rustio_core::admin::FieldType::DateTime },
427    }
428}
429
430/// Format a `DateTime<Utc>` as `YYYY-MM-DDTHH:MM`, which is what the
431/// browser's `<input type="datetime-local">` emits and accepts. Seconds
432/// are dropped on purpose — admin forms don't need sub-minute precision
433/// and including them trips the widget in some browsers.
434const DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M";
435
436/// Produce the `match` arm that renders one field as a form-ready string.
437fn display_arm(f: &FieldInfo) -> TokenStream2 {
438    let ident = &f.ident;
439    let name_str = &f.name_str;
440
441    // Nullable: empty string when None, formatted value when Some.
442    if f.nullable {
443        return match f.kind {
444            FieldKind::DateTime => quote! {
445                #name_str => Some(match &self.#ident {
446                    Some(v) => v.format(#DATETIME_FORMAT).to_string(),
447                    None => String::new(),
448                }),
449            },
450            _ => quote! {
451                #name_str => Some(match &self.#ident {
452                    Some(v) => v.to_string(),
453                    None => String::new(),
454                }),
455            },
456        };
457    }
458
459    match f.kind {
460        FieldKind::DateTime => quote! {
461            #name_str => Some(self.#ident.format(#DATETIME_FORMAT).to_string()),
462        },
463        _ => quote! {
464            #name_str => Some(self.#ident.to_string()),
465        },
466    }
467}
468
469/// Produce the struct-field assignment inside the generated `from_form`.
470///
471/// The `id` field is always filled from the `id: Option<i64>` argument.
472/// Editable fields pull from the `form` by name and validate/parse.
473/// Nullable fields accept an empty string and produce `None`.
474fn from_form_assignment(f: &FieldInfo) -> TokenStream2 {
475    let ident = &f.ident;
476    let name_str = &f.name_str;
477    if !f.editable {
478        return quote! { #ident: id.unwrap_or(0), };
479    }
480
481    if f.nullable {
482        return nullable_assignment(ident, name_str, f.kind);
483    }
484
485    match f.kind {
486        FieldKind::String => quote! {
487            #ident: {
488                let v = form.get(#name_str).unwrap_or("").trim();
489                if v.is_empty() {
490                    return Err(::rustio_core::Error::BadRequest(
491                        format!("field `{}` is required", #name_str)
492                    ));
493                }
494                v.to_owned()
495            },
496        },
497        FieldKind::Bool => quote! {
498            #ident: matches!(form.get(#name_str), Some(v) if v == "on" || v == "true"),
499        },
500        FieldKind::I64 => quote! {
501            #ident: {
502                let raw = form.get(#name_str).unwrap_or("").trim();
503                if raw.is_empty() {
504                    return Err(::rustio_core::Error::BadRequest(
505                        format!("field `{}` is required", #name_str)
506                    ));
507                }
508                raw.parse::<i64>().map_err(|_| ::rustio_core::Error::BadRequest(
509                    format!("field `{}` must be a valid integer", #name_str)
510                ))?
511            },
512        },
513        FieldKind::I32 => quote! {
514            #ident: {
515                let raw = form.get(#name_str).unwrap_or("").trim();
516                if raw.is_empty() {
517                    return Err(::rustio_core::Error::BadRequest(
518                        format!("field `{}` is required", #name_str)
519                    ));
520                }
521                raw.parse::<i32>().map_err(|_| ::rustio_core::Error::BadRequest(
522                    format!("field `{}` must be a valid integer", #name_str)
523                ))?
524            },
525        },
526        FieldKind::DateTime => quote! {
527            #ident: {
528                let raw = form.get(#name_str).unwrap_or("").trim();
529                if raw.is_empty() {
530                    return Err(::rustio_core::Error::BadRequest(
531                        format!("field `{}` is required", #name_str)
532                    ));
533                }
534                ::rustio_core::admin::parse_datetime_local(raw).map_err(|e| {
535                    ::rustio_core::Error::BadRequest(
536                        format!("field `{}`: {}", #name_str, e)
537                    )
538                })?
539            },
540        },
541    }
542}
543
544/// Build the assignment for an `Option<T>` field: empty input → `None`.
545fn nullable_assignment(ident: &syn::Ident, name_str: &str, kind: FieldKind) -> TokenStream2 {
546    match kind {
547        FieldKind::String => quote! {
548            #ident: {
549                let v = form.get(#name_str).unwrap_or("").trim();
550                if v.is_empty() { None } else { Some(v.to_owned()) }
551            },
552        },
553        // Checkboxes don't support a tri-state "unset". For a nullable
554        // bool we treat "absent" as `None` and any present value ("on",
555        // "true") as `Some(true)`. A pair of radio buttons would be the
556        // correct widget here; we ship the checkbox form for now and
557        // will revisit when the admin gets a proper field-widget layer.
558        FieldKind::Bool => quote! {
559            #ident: match form.get(#name_str) {
560                Some(v) if v == "on" || v == "true" => Some(true),
561                Some(v) if v == "off" || v == "false" => Some(false),
562                Some(_) | None => None,
563            },
564        },
565        FieldKind::I64 => quote! {
566            #ident: {
567                let raw = form.get(#name_str).unwrap_or("").trim();
568                if raw.is_empty() {
569                    None
570                } else {
571                    Some(raw.parse::<i64>().map_err(|_| ::rustio_core::Error::BadRequest(
572                        format!("field `{}` must be a valid integer", #name_str)
573                    ))?)
574                }
575            },
576        },
577        FieldKind::I32 => quote! {
578            #ident: {
579                let raw = form.get(#name_str).unwrap_or("").trim();
580                if raw.is_empty() {
581                    None
582                } else {
583                    Some(raw.parse::<i32>().map_err(|_| ::rustio_core::Error::BadRequest(
584                        format!("field `{}` must be a valid integer", #name_str)
585                    ))?)
586                }
587            },
588        },
589        FieldKind::DateTime => quote! {
590            #ident: {
591                let raw = form.get(#name_str).unwrap_or("").trim();
592                if raw.is_empty() {
593                    None
594                } else {
595                    Some(::rustio_core::admin::parse_datetime_local(raw).map_err(|e| {
596                        ::rustio_core::Error::BadRequest(
597                            format!("field `{}`: {}", #name_str, e)
598                        )
599                    })?)
600                }
601            },
602        },
603    }
604}