Skip to main content

rustio_macros/
lib.rs

1//! Procedural macros for RustIO.
2//!
3//! The big one: `#[derive(RustioAdmin)]`. Given a user-written struct,
4//! the derive emits:
5//!
6//!   - `impl AdminModel for TheStruct` with `ADMIN_NAME`, `DISPLAY_NAME`,
7//!     `SINGULAR_NAME`, `FIELDS`, and the row/form/update helpers.
8//!
9//! The macro deliberately stays dumb: all runtime behaviour lives in
10//! `rustio_core`. Keeping the macro small makes it easier to debug —
11//! if something feels wrong, you can read the generated code with
12//! `cargo expand`.
13
14use proc_macro::TokenStream;
15use proc_macro2::TokenStream as TokenStream2;
16use quote::{format_ident, quote};
17use syn::{parse_macro_input, Data, DeriveInput, Fields, Lit, Meta};
18
19#[proc_macro_derive(RustioAdmin, attributes(rustio))]
20pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
21    let input = parse_macro_input!(input as DeriveInput);
22    expand(input)
23        .unwrap_or_else(|e| e.to_compile_error())
24        .into()
25}
26
27fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
28    let struct_name = &input.ident;
29    let fields = struct_fields(&input)?;
30
31    let admin_name = plural_snake(&struct_name.to_string());
32    let display_name = humanise(&plural_snake(&struct_name.to_string()));
33    let singular = struct_name.to_string();
34
35    let mut field_metas = Vec::new();
36    let mut display_value_arms = Vec::new();
37    let mut from_form_parses = Vec::new();
38    let mut from_form_fields = Vec::new();
39    let mut update_tuples = Vec::new();
40
41    for f in fields {
42        let fname = f.ident.as_ref().unwrap();
43        let fname_str = fname.to_string();
44        let kind = classify_type(&f.ty)?;
45        // Phase 1/a — fields named `created_at` / `updated_at` are
46        // managed by the framework: hidden from forms, defaulted to
47        // `Utc::now()` in `from_form`. The macro already wires that
48        // behaviour through `FieldKind::DateTimeAuto`; this promotion
49        // is the missing trigger that makes the variant reachable for
50        // the conventionally named timestamp columns.
51        let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
52            FieldKind::DateTimeAuto
53        } else {
54            kind
55        };
56        let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
57
58        let type_variant = kind.field_type_ident();
59        let relation = parse_relation_attr(&f.attrs, &fname_str)?;
60        let relation_tokens = match &relation {
61            Some((target, display)) => {
62                let display_tok = match display {
63                    Some(d) => quote! { ::std::option::Option::Some(#d) },
64                    None => quote! { ::std::option::Option::None },
65                };
66                quote! {
67                    ::std::option::Option::Some(::rustio_core::admin::AdminRelation {
68                        target_model: #target,
69                        display_field: #display_tok,
70                        // Phase 5/d — single belongs_to relations default to
71                        // single `<select>`. Many-to-many is opt-in via a
72                        // future `#[rustio(many_to_many)]` attribute; the
73                        // macro emits `false` for now so consumers that want
74                        // multi-select must hand-set the field on the
75                        // generated AdminRelation.
76                        multi: false,
77                    })
78                }
79            }
80            None => quote! { ::std::option::Option::None },
81        };
82
83        field_metas.push(quote! {
84            ::rustio_core::admin::AdminField {
85                name: #fname_str,
86                label: #fname_str,
87                field_type: ::rustio_core::admin::FieldType::#type_variant,
88                editable: #editable,
89                relation: #relation_tokens,
90                // Phase 5/d — derived models don't carry enum choices yet.
91                // A future macro pass will accept `#[rustio(choices = [...])]`
92                // and populate this; today consumers that want a `<select>`
93                // backed by a static value list set this on the generated
94                // AdminField directly.
95                choices: ::std::option::Option::None,
96            }
97        });
98
99        // `display_values`: stringify the field for the list page.
100        let display_arm = match kind {
101            FieldKind::String => quote! {
102                out.push((#fname_str.to_string(), self.#fname.clone()));
103            },
104            FieldKind::OptionalString => quote! {
105                // Stress-test fix (v1.4.x) — `Option<String>` does not
106                // implement `Display`, so the previous shared
107                // `String | OptionalString` arm that called
108                // `self.#fname.clone().to_string()` would not compile
109                // for any model that declared an `Option<String>`
110                // field. Mirrors the `OptionalI64` arm: None →
111                // empty string, Some(v) → v.
112                out.push((#fname_str.to_string(), match &self.#fname {
113                    Some(v) => v.clone(),
114                    None => String::new(),
115                }));
116            },
117            FieldKind::I32 | FieldKind::I64 => quote! {
118                out.push((#fname_str.to_string(), self.#fname.to_string()));
119            },
120            FieldKind::OptionalI64 => quote! {
121                out.push((#fname_str.to_string(), match &self.#fname {
122                    Some(v) => v.to_string(),
123                    None => String::new(),
124                }));
125            },
126            FieldKind::Bool => quote! {
127                out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
128            },
129            FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
130                // Phase v1.4.x — ISO-8601 form with `T` separator. This is the
131                // exact wire format `<input type="datetime-local">` expects
132                // (`%Y-%m-%dT%H:%M`); the form-render path puts this string
133                // straight into the input's `value=` attribute. The list path
134                // detects the same shape (16 chars, `T` at index 10) and
135                // splits it into the two-line time-on-top / date-below cell
136                // layout in admin/list.html.
137                //
138                // NOTE: `datetime-local` input cannot encode timezone. We
139                // currently surface UTC values directly. v1.5.0 will add
140                // user-locale conversion.
141                out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
142            },
143        };
144        display_value_arms.push(display_arm);
145
146        // `from_form`: read the HTML form body into a struct field.
147        if fname_str == "id" {
148            from_form_fields.push(quote! { #fname: 0 });
149            continue;
150        }
151
152        // Phase 1/b — precompute human-readable validation messages
153        // at expansion time so the runtime error path doesn't repeat
154        // the same `format!` work per request and so every model
155        // emits identically-styled copy ("Title is required.").
156        let humanised_label = humanise_field(&fname_str);
157        let required_msg = format!("{humanised_label} is required.");
158        let number_msg = format!("{humanised_label} must be a number.");
159        let date_invalid_msg = format!("{humanised_label} is not a valid date.");
160
161        match kind {
162            FieldKind::String => {
163                // Phase 7.6 — trim incoming whitespace so a `"   "`
164                // submission is treated as empty (and triggers the
165                // required-field error) instead of silently saving a
166                // whitespace-only string.
167                from_form_parses.push(quote! {
168                    let #fname = match form.get(#fname_str).map(str::trim) {
169                        Some(v) if !v.is_empty() => v.to_string(),
170                        _ => { errors.push(#required_msg.to_string()); String::new() }
171                    };
172                });
173                from_form_fields.push(quote! { #fname });
174            }
175            FieldKind::OptionalString => {
176                // Phase 7.6 — trim, then collapse trimmed-empty to None
177                // so the column stores NULL instead of `""`.
178                from_form_parses.push(quote! {
179                    let #fname: Option<String> = form
180                        .get(#fname_str)
181                        .map(|s| s.trim().to_string())
182                        .filter(|s| !s.is_empty());
183                });
184                from_form_fields.push(quote! { #fname });
185            }
186            FieldKind::I32 => {
187                from_form_parses.push(quote! {
188                    let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
189                        Some(v) => v,
190                        None => { errors.push(#number_msg.to_string()); 0 }
191                    };
192                });
193                from_form_fields.push(quote! { #fname });
194            }
195            FieldKind::I64 => {
196                from_form_parses.push(quote! {
197                    let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
198                        Some(v) => v,
199                        None => { errors.push(#number_msg.to_string()); 0 }
200                    };
201                });
202                from_form_fields.push(quote! { #fname });
203            }
204            FieldKind::OptionalI64 => {
205                // Phase 7.6 — distinguish "user left it blank" (None,
206                // legitimate) from "user typed garbage" (validation
207                // error, NOT silently dropped). Pre-7.6 used
208                // `.and_then(|v| v.parse().ok())` which collapsed both
209                // cases to None.
210                from_form_parses.push(quote! {
211                    let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
212                        None | Some("") => None,
213                        Some(raw) => match raw.parse::<i64>() {
214                            Ok(n) => Some(n),
215                            Err(_) => {
216                                errors.push(#number_msg.to_string());
217                                None
218                            }
219                        },
220                    };
221                });
222                from_form_fields.push(quote! { #fname });
223            }
224            FieldKind::Bool => {
225                from_form_parses.push(quote! {
226                    let #fname: bool = form.bool_flag(#fname_str);
227                });
228                from_form_fields.push(quote! { #fname });
229            }
230            FieldKind::DateTime => {
231                from_form_parses.push(quote! {
232                    let #fname = match form.get(#fname_str) {
233                        Some(raw) if !raw.is_empty() => {
234                            match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
235                                Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
236                                Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
237                            }
238                        }
239                        _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
240                    };
241                });
242                from_form_fields.push(quote! { #fname });
243            }
244            FieldKind::DateTimeAuto => {
245                // created_at-style fields default to now().
246                from_form_parses.push(quote! {
247                    let #fname = ::chrono::Utc::now();
248                });
249                from_form_fields.push(quote! { #fname });
250            }
251        }
252
253        update_tuples.push(quote! {
254            (#fname_str, self.#fname.clone().into())
255        });
256    }
257
258    let object_label_expr = find_label_field(fields)
259        .map(|n| {
260            let id = format_ident!("{n}");
261            quote! { self.#id.clone().to_string() }
262        })
263        .unwrap_or_else(|| quote! { format!("#{}", self.id) });
264
265    Ok(quote! {
266        impl ::rustio_core::admin::AdminModel for #struct_name {
267            const ADMIN_NAME: &'static str = #admin_name;
268            const DISPLAY_NAME: &'static str = #display_name;
269            const SINGULAR_NAME: &'static str = #singular;
270            const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
271                #(#field_metas),*
272            ];
273
274            fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
275                let mut out = ::std::vec::Vec::new();
276                #(#display_value_arms)*
277                out
278            }
279
280            fn from_form(form: &::rustio_core::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
281            where
282                Self: Sized,
283            {
284                let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
285                #(#from_form_parses)*
286                if !errors.is_empty() {
287                    return Err(errors);
288                }
289                Ok(Self { #(#from_form_fields),* })
290            }
291
292            fn object_label(&self) -> ::std::string::String {
293                #object_label_expr
294            }
295
296            fn id(&self) -> i64 {
297                self.id
298            }
299
300            fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_core::orm::Value)> {
301                ::std::vec![#(#update_tuples),*]
302            }
303        }
304    })
305}
306
307fn struct_fields(input: &DeriveInput) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
308    let data = match &input.data {
309        Data::Struct(s) => s,
310        _ => {
311            return Err(syn::Error::new_spanned(
312                &input.ident,
313                "RustioAdmin can only derive on structs",
314            ))
315        }
316    };
317    match &data.fields {
318        Fields::Named(named) => Ok(&named.named),
319        _ => Err(syn::Error::new_spanned(
320            &input.ident,
321            "RustioAdmin requires a struct with named fields",
322        )),
323    }
324}
325
326#[derive(Debug, PartialEq, Clone, Copy)]
327enum FieldKind {
328    I32,
329    I64,
330    Bool,
331    String,
332    DateTime,
333    DateTimeAuto,
334    OptionalString,
335    OptionalI64,
336}
337
338impl FieldKind {
339    fn field_type_ident(&self) -> proc_macro2::Ident {
340        match self {
341            FieldKind::I32 => format_ident!("I32"),
342            FieldKind::I64 => format_ident!("I64"),
343            FieldKind::Bool => format_ident!("Bool"),
344            FieldKind::String => format_ident!("String"),
345            FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
346            FieldKind::OptionalString => format_ident!("OptionalString"),
347            FieldKind::OptionalI64 => format_ident!("OptionalI64"),
348        }
349    }
350}
351
352/// Phase 1/a — names treated as framework-managed timestamps. These
353/// fields are auto-promoted to `FieldKind::DateTimeAuto` regardless of
354/// declared type so the admin UI doesn't render them and `from_form`
355/// fills them with `Utc::now()`. Conservative list; expand only when a
356/// real model needs another conventionally-named timestamp.
357fn is_auto_timestamp_name(name: &str) -> bool {
358    matches!(name, "created_at" | "updated_at")
359}
360
361/// Phase 1/b — turn a snake_case column name into a Title-Case label
362/// for human-readable validation errors emitted by `from_form`. Mirrors
363/// `rustio_core::admin::intelligence::humanise` so the error message
364/// label and the rendered form label use identical capitalisation.
365fn humanise_field(s: &str) -> String {
366    let mut out = String::with_capacity(s.len());
367    let mut next_upper = true;
368    for ch in s.chars() {
369        if ch == '_' {
370            out.push(' ');
371            next_upper = true;
372        } else if next_upper {
373            out.push(ch.to_ascii_uppercase());
374            next_upper = false;
375        } else {
376            out.push(ch);
377        }
378    }
379    out
380}
381
382fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
383    let as_string = quote! { #ty }.to_string().replace(' ', "");
384    let kind = match as_string.as_str() {
385        "i32" => FieldKind::I32,
386        "i64" => FieldKind::I64,
387        "bool" => FieldKind::Bool,
388        "String" => FieldKind::String,
389        "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
390        "Option<String>" => FieldKind::OptionalString,
391        "Option<i64>" => FieldKind::OptionalI64,
392        other => {
393            return Err(syn::Error::new_spanned(
394                ty,
395                format!("unsupported field type for RustioAdmin: {other}"),
396            ))
397        }
398    };
399    Ok(kind)
400}
401
402fn parse_relation_attr(
403    attrs: &[syn::Attribute],
404    field_name: &str,
405) -> syn::Result<Option<(String, Option<String>)>> {
406    for attr in attrs {
407        if !attr.path().is_ident("rustio") {
408            continue;
409        }
410        let mut target: Option<String> = None;
411        let mut display: Option<String> = None;
412        attr.parse_nested_meta(|m| {
413            if m.path.is_ident("belongs_to") {
414                let value = m.value()?;
415                let lit: Lit = value.parse()?;
416                if let Lit::Str(s) = lit {
417                    target = Some(s.value());
418                }
419                Ok(())
420            } else if m.path.is_ident("display") {
421                let value = m.value()?;
422                let lit: Lit = value.parse()?;
423                if let Lit::Str(s) = lit {
424                    display = Some(s.value());
425                }
426                Ok(())
427            } else {
428                Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
429            }
430        })?;
431        if let Some(t) = target {
432            return Ok(Some((t, display)));
433        }
434        if display.is_some() {
435            return Err(syn::Error::new_spanned(
436                attr,
437                "`display` requires `belongs_to` alongside it",
438            ));
439        }
440    }
441    // Just suppress the unused warning for `Meta`.
442    let _ = std::marker::PhantomData::<Meta>;
443    Ok(None)
444}
445
446fn plural_snake(camel: &str) -> String {
447    let snake = camel_to_snake(camel);
448    if snake.ends_with('s') {
449        snake
450    } else {
451        format!("{snake}s")
452    }
453}
454
455fn camel_to_snake(s: &str) -> String {
456    let mut out = String::new();
457    for (i, c) in s.chars().enumerate() {
458        if c.is_ascii_uppercase() && i > 0 {
459            out.push('_');
460        }
461        out.push(c.to_ascii_lowercase());
462    }
463    out
464}
465
466fn humanise(snake: &str) -> String {
467    // "blog_posts" → "Blog posts"
468    let mut chars = snake.chars();
469    let mut out = String::new();
470    if let Some(first) = chars.next() {
471        out.push(first.to_ascii_uppercase());
472    }
473    for c in chars {
474        if c == '_' {
475            out.push(' ');
476        } else {
477            out.push(c);
478        }
479    }
480    out
481}
482
483fn find_label_field(fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>) -> Option<String> {
484    // Heuristic: prefer `name`, then `title`, then `full_name`, then
485    // fall through to #id. Keeps object_label() useful without forcing
486    // users to implement anything.
487    //
488    // Future evolution — explicit `label_field` plug-in point:
489    // when a struct-level attribute is added (likely
490    // `#[rustio(label_field = "summary")]`), this function becomes
491    // the override site: parse the attribute off `input.attrs` in
492    // `expand`, pass the chosen ident in, and return it here before
493    // running the heuristic. The trait layer (`AdminModel::object_label`
494    // in `rustio-core/src/admin/types.rs`) and the FK rendering layer
495    // (`AdminRelation::display_field` in the same file) already accept
496    // a per-model label without further plumbing — this is the only
497    // file the new attribute needs to touch.
498    let names = ["name", "title", "full_name", "label", "email"];
499    for candidate in names {
500        if fields
501            .iter()
502            .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
503        {
504            return Some(candidate.to_string());
505        }
506    }
507    None
508}
509
510// ============================================================================
511// RustioModel — Phase 14, commit 2.
512// ============================================================================
513//
514// Generates ONLY:
515//
516//     impl ::rustio_core::contract::HasSchema for T {
517//         const SCHEMA: ::rustio_core::contract::ModelSchema = ...;
518//     }
519//
520// Does NOT generate `impl Model`, `impl AdminModel`, or `impl Searchable` —
521// those are the responsibility of separate derives / commits. This macro is
522// pure schema metadata.
523//
524// Attribute surface (initial set):
525//
526//     #[rustio(table = "...")]                            on the struct
527//     #[rustio(sql = "...", searchable, filterable,       on each field
528//              sortable, readonly,
529//              widget = "...", label = "...",
530//              references = "table(column)")]
531//
532// Compile-time validations (errors):
533//   1. field name `id` MUST be `i64` (Type Rule #1).
534//   2. SQL containing NUMERIC/DECIMAL MUST pair with `Decimal` (Type Rule #3).
535//   3. `NaiveDateTime` is forbidden — use `DateTime<Utc>` (Type Rule #2).
536//   4. The `sql = "..."` attribute is required on every field.
537//   5. The `#[rustio(table = "...")]` attribute is required on the struct.
538//   6. At least one field's SQL must declare `PRIMARY KEY`.
539//
540// Warnings (deferred — proc-macro warnings on stable Rust are awkward):
541//   - VARCHAR usage on a String column.
542//   - JSON (without B) on a serde_json::Value column.
543// These are listed in the contract layer's docs and will become validator
544// warnings in commit 3 instead.
545
546#[proc_macro_derive(RustioModel, attributes(rustio))]
547pub fn derive_rustio_model(input: TokenStream) -> TokenStream {
548    let input = parse_macro_input!(input as DeriveInput);
549    rustio_model::expand(input)
550        .unwrap_or_else(|e| e.to_compile_error())
551        .into()
552}
553
554mod rustio_model {
555    use super::*;
556    use syn::{
557        parse::{Parse, ParseStream},
558        Attribute, GenericArgument, LitStr, PathArguments, Token, Type,
559    };
560
561    /// Internal classification of a Rust field type. One variant per
562    /// `RustType` enum the contract layer recognises. Computed by
563    /// `classify` from a `syn::Type`; converted to its
564    /// `::rustio_core::contract::RustType` token form for emission.
565    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
566    enum RustTypeKind {
567        I32,
568        I64,
569        F64,
570        Bool,
571        String,
572        DateTimeUtc,
573        JsonValue,
574        Decimal,
575        Uuid,
576    }
577
578    impl RustTypeKind {
579        fn to_token(self) -> TokenStream2 {
580            match self {
581                RustTypeKind::I32 => quote! { ::rustio_core::contract::RustType::I32 },
582                RustTypeKind::I64 => quote! { ::rustio_core::contract::RustType::I64 },
583                RustTypeKind::F64 => quote! { ::rustio_core::contract::RustType::F64 },
584                RustTypeKind::Bool => quote! { ::rustio_core::contract::RustType::Bool },
585                RustTypeKind::String => quote! { ::rustio_core::contract::RustType::String },
586                RustTypeKind::DateTimeUtc => {
587                    quote! { ::rustio_core::contract::RustType::DateTimeUtc }
588                }
589                RustTypeKind::JsonValue => {
590                    quote! { ::rustio_core::contract::RustType::JsonValue }
591                }
592                RustTypeKind::Decimal => quote! { ::rustio_core::contract::RustType::Decimal },
593                RustTypeKind::Uuid => quote! { ::rustio_core::contract::RustType::Uuid },
594            }
595        }
596    }
597
598    /// Per-field attributes parsed from `#[rustio(...)]`.
599    #[derive(Default)]
600    struct FieldAttr {
601        sql: String,
602        searchable: bool,
603        filterable: bool,
604        sortable: bool,
605        readonly: bool,
606        widget: Option<String>,
607        label: Option<String>,
608        references: Option<String>,
609    }
610
611    /// Top-level entry point — emits the `impl HasSchema` block or
612    /// a compile error.
613    pub(super) fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
614        // Reject anything that isn't a named struct. Generics, tuple
615        // structs, and enums all want different expansions; this
616        // commit handles the common case only.
617        if !input.generics.params.is_empty() {
618            return Err(syn::Error::new_spanned(
619                &input.ident,
620                "RustioModel does not support generic structs (yet)",
621            ));
622        }
623        let struct_name = &input.ident;
624        let fields = match &input.data {
625            Data::Struct(ds) => match &ds.fields {
626                Fields::Named(f) => &f.named,
627                _ => {
628                    return Err(syn::Error::new_spanned(
629                        struct_name,
630                        "RustioModel requires a named-field struct (no tuple structs)",
631                    ));
632                }
633            },
634            _ => {
635                return Err(syn::Error::new_spanned(
636                    struct_name,
637                    "RustioModel can only be derived on structs",
638                ));
639            }
640        };
641
642        // Struct-level: `#[rustio(table = "...")]`
643        let table = parse_table_attr(&input.attrs)?;
644
645        // Per-field: build column expressions + locate primary key.
646        let mut column_exprs = Vec::new();
647        let mut primary_key: Option<String> = None;
648        // Phase 14, commit 8 — track whether any column declares
649        // `searchable` so the emitted ModelSchema can carry the
650        // matching `search_index` automatically. Pre-commit-8
651        // models had to do this manually via
652        // `T::SCHEMA.with_search_index(table)`; auto-deriving
653        // makes the schema → search bridge truly zero-config.
654        let mut has_searchable = false;
655
656        for field in fields {
657            let field_name = field
658                .ident
659                .as_ref()
660                .expect("named struct fields have idents")
661                .to_string();
662            let field_attr = parse_field_attr(&field.attrs)?;
663            if field_attr.sql.is_empty() {
664                return Err(syn::Error::new_spanned(
665                    field,
666                    format!("field `{field_name}` is missing the required `#[rustio(sql = \"...\")]` attribute"),
667                ));
668            }
669
670            let (kind, nullable) = classify(&field.ty)?;
671            validate_field_rules(&field_name, &field_attr.sql, kind, &field.ty)?;
672
673            let sql_upper = field_attr.sql.to_uppercase();
674            let is_pk = sql_upper.contains("PRIMARY KEY");
675            if is_pk {
676                if let Some(prev) = &primary_key {
677                    return Err(syn::Error::new_spanned(
678                        field,
679                        format!(
680                            "more than one field declares PRIMARY KEY: `{prev}` and `{field_name}`"
681                        ),
682                    ));
683                }
684                primary_key = Some(field_name.clone());
685            }
686
687            if field_attr.searchable {
688                has_searchable = true;
689            }
690
691            column_exprs.push(build_column_expr(&field_name, &field_attr, kind, nullable, is_pk));
692        }
693
694        let pk = primary_key.ok_or_else(|| {
695            syn::Error::new_spanned(
696                struct_name,
697                "RustioModel requires at least one field whose `sql = \"...\"` declares PRIMARY KEY",
698            )
699        })?;
700
701        // Trait impl. The contract is defined by the `HasSchema`
702        // trait, not by an inherent const — this is what lets future
703        // commits (validator, admin, search) constrain generics on
704        // `<T: HasSchema>` without each consumer knowing the
705        // emission shape.
706        //
707        // Nested `const __COLS: &[ModelColumn] = &[...]` is required
708        // here, not a direct `&[...]` literal in the outer
709        // ModelSchema::new(...) call. The inner column expressions
710        // contain block expressions with mutable bindings (the
711        // SchemaFlags construction pattern, see `build_column_expr`
712        // above), and Rust's "const promotion" rules don't promote
713        // array literals containing non-trivially-const expressions
714        // to `'static`. Wrapping the array in its own `const`
715        // item forces compile-time evaluation explicitly, after
716        // which the resulting `&'static [ModelColumn]` flows cleanly
717        // into `ModelSchema::new`'s `&'static`-bound parameter.
718        // Phase 14, commit 8 — auto-derive `search_index`. When
719        // the model declares any `#[rustio(... searchable ...)]`
720        // column, default the index name to the table name —
721        // `search::from_schema::enable_search` then enables the
722        // bridge end-to-end with no per-call override. When no
723        // column is searchable, leave `search_index = None` so
724        // the bridge correctly returns `NotSearchable`.
725        let schema_init = if has_searchable {
726            quote! {
727                ::rustio_core::contract::ModelSchema::new(#table, __COLS, #pk)
728                    .with_search_index(#table)
729            }
730        } else {
731            quote! {
732                ::rustio_core::contract::ModelSchema::new(#table, __COLS, #pk)
733            }
734        };
735
736        Ok(quote! {
737            impl ::rustio_core::contract::HasSchema for #struct_name {
738                const SCHEMA: ::rustio_core::contract::ModelSchema = {
739                    const __COLS: &[::rustio_core::contract::ModelColumn] = &[
740                        #(#column_exprs),*
741                    ];
742                    #schema_init
743                };
744            }
745        })
746    }
747
748    /// Classify a `syn::Type` into a (RustTypeKind, nullable) pair.
749    /// Errors on `NaiveDateTime` (Type Rule #2) and any unsupported
750    /// type. Recurses on `Option<T>` to peel one layer.
751    fn classify(ty: &Type) -> syn::Result<(RustTypeKind, bool)> {
752        if let Some(inner) = unwrap_option(ty) {
753            let (k, _) = classify(inner)?;
754            return Ok((k, true));
755        }
756
757        let path = match ty {
758            Type::Path(tp) => tp,
759            _ => {
760                return Err(syn::Error::new_spanned(
761                    ty,
762                    "RustioModel: unsupported type shape (need a simple path type)",
763                ));
764            }
765        };
766
767        let last = path
768            .path
769            .segments
770            .last()
771            .ok_or_else(|| syn::Error::new_spanned(ty, "RustioModel: empty type path"))?;
772        let name = last.ident.to_string();
773
774        // Handle DateTime<Utc> specifically — must have <Utc> arg.
775        if name == "DateTime" {
776            if let PathArguments::AngleBracketed(args) = &last.arguments {
777                let mut got_utc = false;
778                for arg in &args.args {
779                    if let GenericArgument::Type(Type::Path(tp)) = arg {
780                        if tp
781                            .path
782                            .segments
783                            .last()
784                            .map(|s| s.ident == "Utc")
785                            .unwrap_or(false)
786                        {
787                            got_utc = true;
788                        }
789                    }
790                }
791                if got_utc {
792                    return Ok((RustTypeKind::DateTimeUtc, false));
793                }
794            }
795            return Err(syn::Error::new_spanned(
796                ty,
797                "RustioModel: only `DateTime<Utc>` is supported (Type Rule #2). Other timezone parameters are not accepted.",
798            ));
799        }
800
801        // NaiveDateTime — explicitly rejected per Type Rule #2.
802        if name == "NaiveDateTime" {
803            return Err(syn::Error::new_spanned(
804                ty,
805                "RustioModel: `NaiveDateTime` is forbidden (Type Rule #2) — use `chrono::DateTime<chrono::Utc>` for all timestamp columns",
806            ));
807        }
808
809        // Plain type names. Path prefixes are allowed
810        // (`serde_json::Value`, `rust_decimal::Decimal`, `uuid::Uuid`)
811        // because we only inspect the last path segment.
812        let kind = match name.as_str() {
813            "i32" => RustTypeKind::I32,
814            "i64" => RustTypeKind::I64,
815            "f64" => RustTypeKind::F64,
816            "bool" => RustTypeKind::Bool,
817            "String" => RustTypeKind::String,
818            "Value" => RustTypeKind::JsonValue,    // serde_json::Value
819            "Decimal" => RustTypeKind::Decimal,    // rust_decimal::Decimal
820            "Uuid" => RustTypeKind::Uuid,          // uuid::Uuid
821            other => {
822                return Err(syn::Error::new_spanned(
823                    ty,
824                    format!(
825                        "RustioModel: unsupported field type `{other}`. \
826                         Supported: i32, i64, f64, bool, String, \
827                         DateTime<Utc>, serde_json::Value, \
828                         rust_decimal::Decimal, uuid::Uuid \
829                         (and Option<T> for any of the above)."
830                    ),
831                ));
832            }
833        };
834        Ok((kind, false))
835    }
836
837    /// Peel one `Option<T>` layer if present. Returns `None` for
838    /// non-Option types.
839    fn unwrap_option(ty: &Type) -> Option<&Type> {
840        let path = match ty {
841            Type::Path(tp) => &tp.path,
842            _ => return None,
843        };
844        let last = path.segments.last()?;
845        if last.ident != "Option" {
846            return None;
847        }
848        let args = match &last.arguments {
849            PathArguments::AngleBracketed(a) => a,
850            _ => return None,
851        };
852        for arg in &args.args {
853            if let GenericArgument::Type(t) = arg {
854                return Some(t);
855            }
856        }
857        None
858    }
859
860    /// Apply the compile-time type rules. Run after classification —
861    /// every error message names the rule it enforces.
862    fn validate_field_rules(
863        name: &str,
864        sql: &str,
865        kind: RustTypeKind,
866        ty: &Type,
867    ) -> syn::Result<()> {
868        let sql_upper = sql.to_uppercase();
869
870        // Type Rule #1 — id must be i64.
871        if name == "id" && kind != RustTypeKind::I64 {
872            return Err(syn::Error::new_spanned(
873                ty,
874                "Type Rule #1: field `id` must be `i64` (mapped to BIGINT/BIGSERIAL). \
875                 Using a smaller integer type for IDs silently truncates at 2.1B rows.",
876            ));
877        }
878
879        // Type Rule #3 — NUMERIC requires Decimal.
880        // Match whole tokens so a column called "numericality" doesn't
881        // false-positive. We split on non-alphanumeric chars and look
882        // for the exact tokens.
883        let has_numeric_token = sql_upper
884            .split(|c: char| !c.is_alphanumeric())
885            .any(|t| t == "NUMERIC" || t == "DECIMAL");
886        if has_numeric_token && kind != RustTypeKind::Decimal {
887            return Err(syn::Error::new_spanned(
888                ty,
889                "Type Rule #3: NUMERIC/DECIMAL columns must pair with \
890                 `rust_decimal::Decimal`. Using `f64` (or any other type) \
891                 for money loses precision under arithmetic.",
892            ));
893        }
894
895        Ok(())
896    }
897
898    /// Build the `ModelColumn::new(...).chain()...` expression for one
899    /// field. Always emits `with_flags(...)` so the generated code is
900    /// uniform regardless of which flags are set.
901    fn build_column_expr(
902        name: &str,
903        attr: &FieldAttr,
904        kind: RustTypeKind,
905        nullable: bool,
906        is_pk: bool,
907    ) -> TokenStream2 {
908        let name_lit = LitStr::new(name, proc_macro2::Span::call_site());
909        let sql_lit = LitStr::new(&attr.sql, proc_macro2::Span::call_site());
910        let kind_token = kind.to_token();
911
912        let mut expr = quote! {
913            ::rustio_core::contract::ModelColumn::new(#name_lit, #sql_lit, #kind_token)
914        };
915        if nullable {
916            expr = quote! { #expr.nullable() };
917        }
918        if is_pk {
919            expr = quote! { #expr.primary_key() };
920        }
921
922        // Flags — `.with_flags(...)` always emitted, even when no
923        // flags are set. The shape inside depends on whether any
924        // flag is on:
925        //
926        //   * No flags:  `SchemaFlags::empty()`
927        //   * Any flags: `{ let mut __f = SchemaFlags::empty();
928        //                   __f.searchable = true; ...; __f }`
929        //
930        // The block form is required because cc25125's `SchemaFlags`
931        // is `#[non_exhaustive]` (blocking cross-crate struct
932        // literals) and exposes only `empty()` + `searchable()` —
933        // there's no per-flag setter. Field ASSIGNMENT is allowed on
934        // a `#[non_exhaustive]` struct cross-crate even when struct
935        // literals are blocked, so this `let mut … ; __f.x = true; __f`
936        // pattern works in any consumer crate. It also evaluates in
937        // const context (mut bindings + field mutation in const fn /
938        // const initialisers have been stable since Rust 1.46), so
939        // the whole `static SCHEMA: ModelSchema = …` initialiser
940        // remains const-correct.
941        let s = attr.searchable;
942        let f = attr.filterable;
943        let so = attr.sortable;
944        let r = attr.readonly;
945        let flags_expr = if !s && !f && !so && !r {
946            quote! { ::rustio_core::contract::SchemaFlags::empty() }
947        } else {
948            let mut mutations = Vec::new();
949            if s {
950                mutations.push(quote! { __f.searchable = true; });
951            }
952            if f {
953                mutations.push(quote! { __f.filterable = true; });
954            }
955            if so {
956                mutations.push(quote! { __f.sortable = true; });
957            }
958            if r {
959                mutations.push(quote! { __f.readonly = true; });
960            }
961            quote! {
962                {
963                    let mut __f = ::rustio_core::contract::SchemaFlags::empty();
964                    #(#mutations)*
965                    __f
966                }
967            }
968        };
969        expr = quote! { #expr.with_flags(#flags_expr) };
970
971        if let Some(label) = &attr.label {
972            let l = LitStr::new(label, proc_macro2::Span::call_site());
973            expr = quote! { #expr.with_label(#l) };
974        }
975        if let Some(widget) = &attr.widget {
976            let w = LitStr::new(widget, proc_macro2::Span::call_site());
977            expr = quote! { #expr.with_widget(#w) };
978        }
979        // `references` is parsed (so the attribute doesn't error)
980        // but NOT emitted as code: cc25125's `ModelColumn` has no
981        // `references` field. Per the strict-isolation spec for
982        // commit 2 ("DO NOT modify ModelColumn ... ignore or store
983        // only if already supported"), we silently drop the value
984        // here. When commit 3+ extends `ModelColumn` to carry FK
985        // metadata, a one-line addition restores emission.
986        let _ = &attr.references;
987
988        expr
989    }
990
991    /// Parse the struct-level `#[rustio(table = "...")]`. Required.
992    fn parse_table_attr(attrs: &[Attribute]) -> syn::Result<String> {
993        for attr in attrs {
994            if !attr.path().is_ident("rustio") {
995                continue;
996            }
997            let parsed: TableAttr = attr.parse_args()?;
998            return Ok(parsed.table);
999        }
1000        Err(syn::Error::new(
1001            proc_macro2::Span::call_site(),
1002            "RustioModel requires a `#[rustio(table = \"...\")]` attribute on the struct",
1003        ))
1004    }
1005
1006    /// Parse all `#[rustio(...)]` attributes on a single field,
1007    /// merging into one `FieldAttr`.
1008    fn parse_field_attr(attrs: &[Attribute]) -> syn::Result<FieldAttr> {
1009        let mut out = FieldAttr::default();
1010        for attr in attrs {
1011            if !attr.path().is_ident("rustio") {
1012                continue;
1013            }
1014            let parsed: FieldAttrTokens = attr.parse_args()?;
1015            // Last write wins per key — duplicates across multiple
1016            // `#[rustio(...)]` attributes overwrite. Rare enough we
1017            // don't bother detecting; loud parser errors would
1018            // produce noisier compiler output without helping anyone.
1019            for entry in parsed.entries {
1020                match entry {
1021                    AttrEntry::Sql(s) => out.sql = s,
1022                    AttrEntry::Searchable => out.searchable = true,
1023                    AttrEntry::Filterable => out.filterable = true,
1024                    AttrEntry::Sortable => out.sortable = true,
1025                    AttrEntry::Readonly => out.readonly = true,
1026                    AttrEntry::Widget(s) => out.widget = Some(s),
1027                    AttrEntry::Label(s) => out.label = Some(s),
1028                    AttrEntry::References(s) => out.references = Some(s),
1029                }
1030            }
1031        }
1032        Ok(out)
1033    }
1034
1035    // ---- Attribute parsers (syn::parse plumbing) ---------------------------
1036
1037    struct TableAttr {
1038        table: String,
1039    }
1040    impl Parse for TableAttr {
1041        fn parse(input: ParseStream) -> syn::Result<Self> {
1042            let key: syn::Ident = input.parse()?;
1043            if key != "table" {
1044                return Err(syn::Error::new(
1045                    key.span(),
1046                    "expected `table = \"...\"` on the struct",
1047                ));
1048            }
1049            input.parse::<Token![=]>()?;
1050            let value: LitStr = input.parse()?;
1051            // Tolerate trailing tokens (commas etc) silently — only
1052            // error on extras that aren't whitespace.
1053            Ok(Self { table: value.value() })
1054        }
1055    }
1056
1057    enum AttrEntry {
1058        Sql(String),
1059        Searchable,
1060        Filterable,
1061        Sortable,
1062        Readonly,
1063        Widget(String),
1064        Label(String),
1065        References(String),
1066    }
1067
1068    struct FieldAttrTokens {
1069        entries: Vec<AttrEntry>,
1070    }
1071    impl Parse for FieldAttrTokens {
1072        fn parse(input: ParseStream) -> syn::Result<Self> {
1073            let mut entries = Vec::new();
1074            loop {
1075                if input.is_empty() {
1076                    break;
1077                }
1078                let key: syn::Ident = input.parse()?;
1079                let key_str = key.to_string();
1080                let entry = match key_str.as_str() {
1081                    "sql" => {
1082                        input.parse::<Token![=]>()?;
1083                        AttrEntry::Sql(input.parse::<LitStr>()?.value())
1084                    }
1085                    "searchable" => AttrEntry::Searchable,
1086                    "filterable" => AttrEntry::Filterable,
1087                    "sortable" => AttrEntry::Sortable,
1088                    "readonly" => AttrEntry::Readonly,
1089                    "widget" => {
1090                        input.parse::<Token![=]>()?;
1091                        AttrEntry::Widget(input.parse::<LitStr>()?.value())
1092                    }
1093                    "label" => {
1094                        input.parse::<Token![=]>()?;
1095                        AttrEntry::Label(input.parse::<LitStr>()?.value())
1096                    }
1097                    "references" => {
1098                        input.parse::<Token![=]>()?;
1099                        AttrEntry::References(input.parse::<LitStr>()?.value())
1100                    }
1101                    other => {
1102                        return Err(syn::Error::new(
1103                            key.span(),
1104                            format!(
1105                                "unknown rustio attribute `{other}`. \
1106                                 Known: sql, searchable, filterable, sortable, \
1107                                 readonly, widget, label, references."
1108                            ),
1109                        ));
1110                    }
1111                };
1112                entries.push(entry);
1113                if input.is_empty() {
1114                    break;
1115                }
1116                input.parse::<Token![,]>()?;
1117            }
1118            Ok(Self { entries })
1119        }
1120    }
1121}