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        // `#[rustio(file)]` promotes String / Option<String> to the
71        // file-upload variants. Other base types reject the marker —
72        // the macro emits a compile error so a typo'd attribute on
73        // an i64 column doesn't silently render as a text input.
74        let kind = if parse_file_attr(&f.attrs)? {
75            match kind {
76                FieldKind::String => FieldKind::FilePath,
77                FieldKind::OptionalString => FieldKind::OptionalFilePath,
78                other => {
79                    return Err(syn::Error::new_spanned(
80                        f,
81                        format!(
82                            "#[rustio(file)] is only valid on String or Option<String> fields; \
83                             got {other:?} for `{fname_str}`"
84                        ),
85                    ));
86                }
87            }
88        } else {
89            kind
90        };
91        // `#[rustio(format = "email" | "phone")]` promotes a String
92        // column to the validated-string variants. Same discipline as
93        // the file marker: it's only valid on String, and a typo'd
94        // value or a non-String target is a compile error.
95        let kind = match parse_format_attr(&f.attrs)? {
96            Some(fmt) => match kind {
97                FieldKind::String if fmt == "email" => FieldKind::Email,
98                FieldKind::String if fmt == "phone" => FieldKind::Phone,
99                other => {
100                    return Err(syn::Error::new_spanned(
101                        f,
102                        format!(
103                            "#[rustio(format = \"...\")] is only valid on String fields; \
104                             got {other:?} for `{fname_str}`"
105                        ),
106                    ));
107                }
108            },
109            None => kind,
110        };
111        // `#[rustio(choices = ["a", "b"])]` promotes a String column to
112        // a dropdown. The values ride in `field_choices`; the `Choice`
113        // kind is just the marker the from_form / display arms and the
114        // `AdminField.choices` slice switch on.
115        let field_choices = parse_choices_attr(&f.attrs)?;
116        let kind = match &field_choices {
117            Some(values) if !values.is_empty() => match kind {
118                FieldKind::String => FieldKind::Choice,
119                other => {
120                    return Err(syn::Error::new_spanned(
121                        f,
122                        format!(
123                            "#[rustio(choices = [...])] is only valid on String fields; \
124                             got {other:?} for `{fname_str}`"
125                        ),
126                    ));
127                }
128            },
129            _ => kind,
130        };
131        let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
132
133        let type_variant = kind.field_type_ident();
134        let relation = parse_relation_attr(&f.attrs, &fname_str)?;
135        let relation_tokens = match &relation {
136            Some((target, display)) => {
137                let display_tok = match display {
138                    Some(d) => quote! { ::std::option::Option::Some(#d) },
139                    None => quote! { ::std::option::Option::None },
140                };
141                quote! {
142                    ::std::option::Option::Some(::rustio_admin::admin::AdminRelation {
143                        target_model: #target,
144                        display_field: #display_tok,
145                        // Single belongs_to relations default to
146                        // single `<select>`. Many-to-many is opt-in via
147                        // a future `#[rustio(many_to_many)]` attribute;
148                        // the macro emits `false` for now so consumers
149                        // that want multi-select must hand-set the
150                        // field on the generated AdminRelation.
151                        multi: false,
152                    })
153                }
154            }
155            None => quote! { ::std::option::Option::None },
156        };
157
158        // Humanised display label, computed once at expansion time:
159        // `performed_by_technician` → `"Performed by technician"`. The
160        // list page renders this through CSS uppercase+tracking as
161        // `PERFORMED BY TECHNICIAN` with real word boundaries, so the
162        // header can wrap on narrow rows instead of dictating a wide
163        // column floor. Also reused below for validation messages.
164        let humanised_label = humanise_field(&fname_str);
165        // `#[rustio(choices = [...])]` → a `&'static [&'static str]`
166        // the form layer renders as a `<select>`. Absent → `None`.
167        let choices_tokens = match &field_choices {
168            Some(values) => {
169                let lits = values.iter().map(|v| v.as_str());
170                quote! { ::std::option::Option::Some(&[ #(#lits),* ]) }
171            }
172            None => quote! { ::std::option::Option::None },
173        };
174        field_metas.push(quote! {
175            ::rustio_admin::admin::AdminField {
176                name: #fname_str,
177                label: #humanised_label,
178                field_type: ::rustio_admin::admin::FieldType::#type_variant,
179                editable: #editable,
180                relation: #relation_tokens,
181                choices: #choices_tokens,
182            }
183        });
184
185        // `display_values`: stringify the field for the list page.
186        let display_arm = match kind {
187            // FilePath / OptionalFilePath live in `String` /
188            // `Option<String>` Rust types but render in the form
189            // as `<input type="file">`. The display path is
190            // identical to the string variants — the stored value
191            // IS the relative path, surfaced as plain text on the
192            // list page.
193            FieldKind::String
194            | FieldKind::FilePath
195            | FieldKind::Email
196            | FieldKind::Phone
197            | FieldKind::Choice => {
198                quote! {
199                    out.push((#fname_str.to_string(), self.#fname.clone()));
200                }
201            }
202            FieldKind::OptionalString | FieldKind::OptionalFilePath => quote! {
203                // `Option<String>` does not implement `Display`, so we
204                // can't share the String arm. None → empty string,
205                // Some(v) → v.
206                out.push((#fname_str.to_string(), match &self.#fname {
207                    Some(v) => v.clone(),
208                    None => String::new(),
209                }));
210            },
211            FieldKind::I32
212            | FieldKind::I64
213            | FieldKind::F64
214            | FieldKind::Decimal
215            | FieldKind::Uuid => quote! {
216                // Decimal and Uuid both round-trip through their
217                // canonical `Display` form (`19.99`, hyphenated UUID).
218                out.push((#fname_str.to_string(), self.#fname.to_string()));
219            },
220            FieldKind::OptionalI64 => quote! {
221                out.push((#fname_str.to_string(), match &self.#fname {
222                    Some(v) => v.to_string(),
223                    None => String::new(),
224                }));
225            },
226            FieldKind::Bool => quote! {
227                out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
228            },
229            FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
230                // ISO-8601 form with `T` separator. This is the exact
231                // wire format `<input type="datetime-local">` expects
232                // (`%Y-%m-%dT%H:%M`); the form-render path puts this
233                // string straight into the input's `value=` attribute.
234                // The list path detects the same shape (16 chars, `T`
235                // at index 10) and splits it into the two-line cell
236                // layout. NOTE: `datetime-local` cannot encode timezone;
237                // we surface UTC values directly.
238                out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
239            },
240            FieldKind::OptionalDateTime => quote! {
241                // Symmetric to `OptionalString` / `OptionalI64`: None →
242                // empty string, Some(v) → same ISO-8601 form as the
243                // non-optional `DateTime` arm.
244                out.push((#fname_str.to_string(), match &self.#fname {
245                    Some(v) => v.format("%Y-%m-%dT%H:%M").to_string(),
246                    None => String::new(),
247                }));
248            },
249            FieldKind::Date => quote! {
250                // `%Y-%m-%d` is exactly what `<input type="date">`
251                // round-trips, so the rendered value drops straight
252                // back into the input's `value=` attribute.
253                out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%d").to_string()));
254            },
255            FieldKind::Time => quote! {
256                // `%H:%M` matches `<input type="time">` (no seconds).
257                out.push((#fname_str.to_string(), self.#fname.format("%H:%M").to_string()));
258            },
259        };
260        display_value_arms.push(display_arm);
261
262        // `from_form`: read the HTML form body into a struct field.
263        if fname_str == "id" {
264            from_form_fields.push(quote! { #fname: 0 });
265            continue;
266        }
267
268        // Precompute human-readable validation messages at expansion
269        // time so the runtime error path doesn't repeat the same
270        // `format!` work per request and so every model emits
271        // identically-styled copy. `humanised_label` was already
272        // computed above for `AdminField.label`.
273        let required_msg = format!("{humanised_label} is required.");
274        let number_msg = format!("{humanised_label} must be a number.");
275        let date_invalid_msg = format!("{humanised_label} is not a valid date.");
276        let time_invalid_msg = format!("{humanised_label} is not a valid time.");
277        let uuid_invalid_msg = format!("{humanised_label} is not a valid UUID.");
278        let email_invalid_msg = format!("{humanised_label} is not a valid email address.");
279        let phone_invalid_msg = format!("{humanised_label} is not a valid phone number.");
280
281        match kind {
282            FieldKind::String | FieldKind::FilePath => {
283                // Trim incoming whitespace so a `"   "` submission is
284                // treated as empty (and triggers the required-field
285                // error) instead of silently saving a whitespace-only
286                // string. FilePath uses the same trimming path: the
287                // multipart-form handler injects the saved relative
288                // path string into the form before `from_form` sees
289                // it, so the value lands here as a normal String.
290                from_form_parses.push(quote! {
291                    let #fname = match form.get(#fname_str).map(str::trim) {
292                        Some(v) if !v.is_empty() => v.to_string(),
293                        _ => { errors.push(#required_msg.to_string()); String::new() }
294                    };
295                });
296                from_form_fields.push(quote! { #fname });
297            }
298            FieldKind::Email => {
299                // Required, trimmed, then format-checked. The column is
300                // plain TEXT so the trimmed value is stored verbatim
301                // even when the format check fails (the error is what
302                // blocks the save, not a value rewrite).
303                from_form_parses.push(quote! {
304                    let #fname = match form.get(#fname_str).map(str::trim) {
305                        Some(v) if !v.is_empty() => {
306                            if !::rustio_admin::admin::is_valid_email(v) {
307                                errors.push(#email_invalid_msg.to_string());
308                            }
309                            v.to_string()
310                        }
311                        _ => { errors.push(#required_msg.to_string()); String::new() }
312                    };
313                });
314                from_form_fields.push(quote! { #fname });
315            }
316            FieldKind::Phone => {
317                from_form_parses.push(quote! {
318                    let #fname = match form.get(#fname_str).map(str::trim) {
319                        Some(v) if !v.is_empty() => {
320                            if !::rustio_admin::admin::is_valid_phone(v) {
321                                errors.push(#phone_invalid_msg.to_string());
322                            }
323                            v.to_string()
324                        }
325                        _ => { errors.push(#required_msg.to_string()); String::new() }
326                    };
327                });
328                from_form_fields.push(quote! { #fname });
329            }
330            FieldKind::Choice => {
331                // Required, trimmed, then checked against the declared
332                // set. The DB also carries a `CHECK (... IN (...))`
333                // constraint, but validating here yields a calm form
334                // error instead of a 409 from a constraint violation.
335                let values = field_choices
336                    .as_ref()
337                    .expect("Choice kind is only set when choices are present");
338                let choice_lits = values.iter().map(|v| v.as_str());
339                let choice_invalid_msg =
340                    format!("{humanised_label} must be one of: {}.", values.join(", "));
341                from_form_parses.push(quote! {
342                    let #fname = match form.get(#fname_str).map(str::trim) {
343                        Some(v) if !v.is_empty() => {
344                            const CHOICES: &[&str] = &[ #(#choice_lits),* ];
345                            if !CHOICES.contains(&v) {
346                                errors.push(#choice_invalid_msg.to_string());
347                            }
348                            v.to_string()
349                        }
350                        _ => { errors.push(#required_msg.to_string()); String::new() }
351                    };
352                });
353                from_form_fields.push(quote! { #fname });
354            }
355            FieldKind::OptionalString | FieldKind::OptionalFilePath => {
356                // Trim, then collapse trimmed-empty to None so the
357                // column stores NULL instead of `""`. Optional
358                // FilePath shares the same path — the file-input
359                // widget can submit an empty string when the
360                // operator clears the field.
361                from_form_parses.push(quote! {
362                    let #fname: Option<String> = form
363                        .get(#fname_str)
364                        .map(|s| s.trim().to_string())
365                        .filter(|s| !s.is_empty());
366                });
367                from_form_fields.push(quote! { #fname });
368            }
369            FieldKind::I32 => {
370                from_form_parses.push(quote! {
371                    let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
372                        Some(v) => v,
373                        None => { errors.push(#number_msg.to_string()); 0 }
374                    };
375                });
376                from_form_fields.push(quote! { #fname });
377            }
378            FieldKind::I64 => {
379                from_form_parses.push(quote! {
380                    let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
381                        Some(v) => v,
382                        None => { errors.push(#number_msg.to_string()); 0 }
383                    };
384                });
385                from_form_fields.push(quote! { #fname });
386            }
387            FieldKind::F64 => {
388                from_form_parses.push(quote! {
389                    let #fname: f64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
390                        Some(v) => v,
391                        None => { errors.push(#number_msg.to_string()); 0.0 }
392                    };
393                });
394                from_form_fields.push(quote! { #fname });
395            }
396            FieldKind::Decimal => {
397                from_form_parses.push(quote! {
398                    let #fname: ::rust_decimal::Decimal =
399                        match form.get(#fname_str).map(str::trim) {
400                            Some(raw) if !raw.is_empty() => match raw.parse() {
401                                Ok(v) => v,
402                                Err(_) => {
403                                    errors.push(#number_msg.to_string());
404                                    ::rust_decimal::Decimal::ZERO
405                                }
406                            },
407                            _ => {
408                                errors.push(#required_msg.to_string());
409                                ::rust_decimal::Decimal::ZERO
410                            }
411                        };
412                });
413                from_form_fields.push(quote! { #fname });
414            }
415            FieldKind::OptionalI64 => {
416                // Distinguish "user left it blank" (None, legitimate)
417                // from "user typed garbage" (validation error, NOT
418                // silently dropped).
419                from_form_parses.push(quote! {
420                    let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
421                        None | Some("") => None,
422                        Some(raw) => match raw.parse::<i64>() {
423                            Ok(n) => Some(n),
424                            Err(_) => {
425                                errors.push(#number_msg.to_string());
426                                None
427                            }
428                        },
429                    };
430                });
431                from_form_fields.push(quote! { #fname });
432            }
433            FieldKind::Bool => {
434                from_form_parses.push(quote! {
435                    let #fname: bool = form.bool_flag(#fname_str);
436                });
437                from_form_fields.push(quote! { #fname });
438            }
439            FieldKind::DateTime => {
440                from_form_parses.push(quote! {
441                    let #fname = match form.get(#fname_str) {
442                        Some(raw) if !raw.is_empty() => {
443                            match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
444                                Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
445                                Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
446                            }
447                        }
448                        _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
449                    };
450                });
451                from_form_fields.push(quote! { #fname });
452            }
453            FieldKind::Date => {
454                from_form_parses.push(quote! {
455                    let #fname = match form.get(#fname_str) {
456                        Some(raw) if !raw.is_empty() => {
457                            match ::chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
458                                Ok(d) => d,
459                                Err(_) => {
460                                    errors.push(#date_invalid_msg.to_string());
461                                    ::chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()
462                                }
463                            }
464                        }
465                        _ => {
466                            errors.push(#required_msg.to_string());
467                            ::chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()
468                        }
469                    };
470                });
471                from_form_fields.push(quote! { #fname });
472            }
473            FieldKind::Time => {
474                from_form_parses.push(quote! {
475                    let #fname = match form.get(#fname_str) {
476                        Some(raw) if !raw.is_empty() => {
477                            match ::chrono::NaiveTime::parse_from_str(raw, "%H:%M") {
478                                Ok(t) => t,
479                                Err(_) => {
480                                    errors.push(#time_invalid_msg.to_string());
481                                    ::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()
482                                }
483                            }
484                        }
485                        _ => {
486                            errors.push(#required_msg.to_string());
487                            ::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()
488                        }
489                    };
490                });
491                from_form_fields.push(quote! { #fname });
492            }
493            FieldKind::Uuid => {
494                from_form_parses.push(quote! {
495                    let #fname = match form.get(#fname_str).map(str::trim) {
496                        Some(raw) if !raw.is_empty() => match ::uuid::Uuid::parse_str(raw) {
497                            Ok(u) => u,
498                            Err(_) => {
499                                errors.push(#uuid_invalid_msg.to_string());
500                                ::uuid::Uuid::nil()
501                            }
502                        },
503                        _ => {
504                            errors.push(#required_msg.to_string());
505                            ::uuid::Uuid::nil()
506                        }
507                    };
508                });
509                from_form_fields.push(quote! { #fname });
510            }
511            FieldKind::DateTimeAuto => {
512                // created_at-style fields default to now().
513                from_form_parses.push(quote! {
514                    let #fname = ::chrono::Utc::now();
515                });
516                from_form_fields.push(quote! { #fname });
517            }
518            FieldKind::OptionalDateTime => {
519                // Symmetric to `OptionalI64`: blank → None (legitimate),
520                // garbage → validation error + None (NOT silently
521                // defaulted to `Utc::now()` like the non-optional arm).
522                from_form_parses.push(quote! {
523                    let #fname: ::std::option::Option<::chrono::DateTime<::chrono::Utc>> =
524                        match form.get(#fname_str).map(str::trim) {
525                            None | Some("") => ::std::option::Option::None,
526                            Some(raw) => match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
527                                Ok(dt) => ::std::option::Option::Some(
528                                    ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
529                                ),
530                                Err(_) => {
531                                    errors.push(#date_invalid_msg.to_string());
532                                    ::std::option::Option::None
533                                }
534                            },
535                        };
536                });
537                from_form_fields.push(quote! { #fname });
538            }
539        }
540
541        update_tuples.push(quote! {
542            (#fname_str, self.#fname.clone().into())
543        });
544    }
545
546    let object_label_expr = find_label_field(fields)
547        .map(|n| {
548            let id = format_ident!("{n}");
549            quote! { self.#id.clone().to_string() }
550        })
551        .unwrap_or_else(|| quote! { format!("#{}", self.id) });
552
553    Ok(quote! {
554        impl ::rustio_admin::admin::AdminModel for #struct_name {
555            const ADMIN_NAME: &'static str = #admin_name;
556            const DISPLAY_NAME: &'static str = #display_name;
557            const SINGULAR_NAME: &'static str = #singular;
558            const FIELDS: &'static [::rustio_admin::admin::AdminField] = &[
559                #(#field_metas),*
560            ];
561
562            fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
563                let mut out = ::std::vec::Vec::new();
564                #(#display_value_arms)*
565                out
566            }
567
568            fn from_form(form: &::rustio_admin::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
569            where
570                Self: Sized,
571            {
572                let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
573                #(#from_form_parses)*
574                if !errors.is_empty() {
575                    return Err(errors);
576                }
577                Ok(Self { #(#from_form_fields),* })
578            }
579
580            fn object_label(&self) -> ::std::string::String {
581                #object_label_expr
582            }
583
584            fn id(&self) -> i64 {
585                self.id
586            }
587
588            fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_admin::orm::Value)> {
589                ::std::vec![#(#update_tuples),*]
590            }
591        }
592    })
593}
594
595fn struct_fields(
596    input: &DeriveInput,
597) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
598    let data = match &input.data {
599        Data::Struct(s) => s,
600        _ => {
601            return Err(syn::Error::new_spanned(
602                &input.ident,
603                "RustioAdmin can only derive on structs",
604            ))
605        }
606    };
607    match &data.fields {
608        Fields::Named(named) => Ok(&named.named),
609        _ => Err(syn::Error::new_spanned(
610            &input.ident,
611            "RustioAdmin requires a struct with named fields",
612        )),
613    }
614}
615
616#[derive(Debug, PartialEq, Clone, Copy)]
617enum FieldKind {
618    I32,
619    I64,
620    F64,
621    Decimal,
622    Bool,
623    String,
624    /// `String` column flagged with `#[rustio(format = "email")]`.
625    /// Stored as `TEXT`; renders as `<input type="email">` and runs
626    /// [`is_valid_email`](rustio_admin::admin::is_valid_email) in
627    /// `from_form`.
628    Email,
629    /// `String` column flagged with `#[rustio(format = "phone")]`.
630    Phone,
631    /// `String` column flagged with `#[rustio(choices = [...])]`.
632    /// Stored as `TEXT`; renders as a `<select>` (driven by
633    /// `AdminField.choices`, not the `FieldType`) and `from_form`
634    /// rejects values outside the declared set. The values
635    /// themselves ride in a side variable, not this `Copy` enum.
636    Choice,
637    DateTime,
638    Date,
639    Time,
640    Uuid,
641    DateTimeAuto,
642    OptionalString,
643    OptionalI64,
644    OptionalDateTime,
645    /// `String` column flagged with `#[rustio(file)]`. Renders as
646    /// `<input type="file">`; the multipart-form handler writes
647    /// the uploaded bytes under `Admin::uploads_dir` and injects
648    /// the relative path string back into the form before
649    /// `from_form` parses it as a normal String.
650    FilePath,
651    /// `Option<String>` counterpart.
652    OptionalFilePath,
653}
654
655impl FieldKind {
656    fn field_type_ident(&self) -> proc_macro2::Ident {
657        match self {
658            FieldKind::I32 => format_ident!("I32"),
659            FieldKind::I64 => format_ident!("I64"),
660            FieldKind::F64 => format_ident!("F64"),
661            FieldKind::Decimal => format_ident!("Decimal"),
662            FieldKind::Bool => format_ident!("Bool"),
663            FieldKind::String => format_ident!("String"),
664            FieldKind::Email => format_ident!("Email"),
665            FieldKind::Phone => format_ident!("Phone"),
666            // A choice column is a String at the type level; the
667            // `<select>` comes from `AdminField.choices`.
668            FieldKind::Choice => format_ident!("String"),
669            FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
670            FieldKind::Date => format_ident!("Date"),
671            FieldKind::Time => format_ident!("Time"),
672            FieldKind::Uuid => format_ident!("Uuid"),
673            FieldKind::OptionalString => format_ident!("OptionalString"),
674            FieldKind::OptionalI64 => format_ident!("OptionalI64"),
675            FieldKind::OptionalDateTime => format_ident!("OptionalDateTime"),
676            FieldKind::FilePath => format_ident!("FilePath"),
677            FieldKind::OptionalFilePath => format_ident!("OptionalFilePath"),
678        }
679    }
680}
681
682/// Names treated as framework-managed timestamps. These fields are
683/// auto-promoted to `FieldKind::DateTimeAuto` regardless of declared
684/// type so the admin UI doesn't render them and `from_form` fills
685/// them with `Utc::now()`. Conservative list; expand only when a real
686/// model needs another conventionally-named timestamp.
687fn is_auto_timestamp_name(name: &str) -> bool {
688    matches!(name, "created_at" | "updated_at")
689}
690
691/// Turn a snake_case column name into a Title-Case label for human-
692/// readable validation errors emitted by `from_form`. Mirrors the
693/// runtime humanise helper so error labels and rendered form labels
694/// use identical capitalisation.
695///
696/// Whole-word acronym recognition: each underscore-separated segment
697/// is checked against [`HUMANISE_ACRONYMS`] before being
698/// title-cased, so `id` → `ID`, `email_id` → `Email ID`,
699/// `mfa_secret_key_id` → `MFA Secret Key ID`. Words *containing* but
700/// not *being* an acronym (`video` is not `vIDeo`) are left to the
701/// default first-letter-uppercase rule.
702fn humanise_field(s: &str) -> String {
703    if s.is_empty() {
704        return String::new();
705    }
706    let mut out = String::with_capacity(s.len());
707    let mut first_segment = true;
708    for segment in s.split('_') {
709        if !first_segment {
710            out.push(' ');
711        }
712        first_segment = false;
713        let lower = segment.to_ascii_lowercase();
714        if HUMANISE_ACRONYMS.contains(&lower.as_str()) {
715            out.push_str(&lower.to_ascii_uppercase());
716        } else {
717            let mut chars = segment.chars();
718            if let Some(first) = chars.next() {
719                out.push(first.to_ascii_uppercase());
720                for c in chars {
721                    out.push(c);
722                }
723            }
724        }
725    }
726    out
727}
728
729/// Acronyms that should be fully uppercase in humanised labels.
730///
731/// Byte-for-byte mirror of
732/// `rustio_admin::admin::render::HUMANISE_ACRONYMS` — the macros
733/// crate cannot depend on the main crate (proc-macro cycle), so
734/// the two lists are intentionally duplicated. Update both
735/// together.
736const HUMANISE_ACRONYMS: &[&str] = &[
737    "id", "ip", "url", "uri", "api", "uuid", "mfa", "csv", "sql", "html", "http", "https", "json",
738    "tls", "ssl", "smtp", "xml",
739];
740
741fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
742    let as_string = quote! { #ty }.to_string().replace(' ', "");
743    let kind = match as_string.as_str() {
744        "i32" => FieldKind::I32,
745        "i64" => FieldKind::I64,
746        "f64" => FieldKind::F64,
747        "Decimal" | "rust_decimal::Decimal" => FieldKind::Decimal,
748        "bool" => FieldKind::Bool,
749        "String" => FieldKind::String,
750        "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
751        "NaiveDate" | "chrono::NaiveDate" => FieldKind::Date,
752        "NaiveTime" | "chrono::NaiveTime" => FieldKind::Time,
753        "Uuid" | "uuid::Uuid" => FieldKind::Uuid,
754        "Option<String>" => FieldKind::OptionalString,
755        "Option<i64>" => FieldKind::OptionalI64,
756        "Option<DateTime<Utc>>" | "Option<chrono::DateTime<chrono::Utc>>" => {
757            FieldKind::OptionalDateTime
758        }
759        other => {
760            return Err(syn::Error::new_spanned(
761                ty,
762                format!("unsupported field type for RustioAdmin: {other}"),
763            ))
764        }
765    };
766    Ok(kind)
767}
768
769/// Project-side struct-level overrides parsed from
770/// `#[rustio(...)]` on the deriving struct. Adds a polish escape
771/// hatch for the otherwise-correct auto-derived defaults — see
772/// `VISIBILITY_AUDIT.md` F3.
773///
774/// Example:
775///
776/// ```ignore
777/// #[derive(RustioAdmin)]
778/// #[rustio(
779///     admin_name = "case-actions",
780///     display_name = "Case events"
781/// )]
782/// pub struct CaseAction { … }
783/// ```
784///
785/// Both fields are optional. Unknown keys produce a compile error
786/// pointing at the attribute span.
787#[derive(Default)]
788struct StructOverrides {
789    admin_name: Option<String>,
790    display_name: Option<String>,
791}
792
793fn parse_struct_attr(attrs: &[syn::Attribute]) -> syn::Result<StructOverrides> {
794    let mut out = StructOverrides::default();
795    for attr in attrs {
796        if !attr.path().is_ident("rustio") {
797            continue;
798        }
799        attr.parse_nested_meta(|m| {
800            if m.path.is_ident("admin_name") {
801                let value = m.value()?;
802                let lit: Lit = value.parse()?;
803                if let Lit::Str(s) = lit {
804                    out.admin_name = Some(s.value());
805                }
806                Ok(())
807            } else if m.path.is_ident("display_name") {
808                let value = m.value()?;
809                let lit: Lit = value.parse()?;
810                if let Lit::Str(s) = lit {
811                    out.display_name = Some(s.value());
812                }
813                Ok(())
814            } else {
815                // Field-level keys (e.g. `belongs_to`, `display`)
816                // legitimately appear on `#[rustio(...)]` placed on
817                // FIELDS, not the struct. When the same `rustio`
818                // attribute is on the struct, those keys are
819                // surprising. Reject so a misplaced field attribute
820                // doesn't silently fail.
821                Err(m.error(
822                    "unknown rustio struct attribute; expected `admin_name` or `display_name`",
823                ))
824            }
825        })?;
826    }
827    Ok(out)
828}
829
830fn parse_relation_attr(
831    attrs: &[syn::Attribute],
832    field_name: &str,
833) -> syn::Result<Option<(String, Option<String>)>> {
834    for attr in attrs {
835        if !attr.path().is_ident("rustio") {
836            continue;
837        }
838        let mut target: Option<String> = None;
839        let mut display: Option<String> = None;
840        attr.parse_nested_meta(|m| {
841            if m.path.is_ident("belongs_to") {
842                let value = m.value()?;
843                let lit: Lit = value.parse()?;
844                if let Lit::Str(s) = lit {
845                    target = Some(s.value());
846                }
847                Ok(())
848            } else if m.path.is_ident("display") {
849                let value = m.value()?;
850                let lit: Lit = value.parse()?;
851                if let Lit::Str(s) = lit {
852                    display = Some(s.value());
853                }
854                Ok(())
855            } else if m.path.is_ident("file") {
856                // Marker attribute — handled by `parse_file_attr`,
857                // ignored here so a field can carry both
858                // `belongs_to` and `file` without one parser
859                // erroring on the other's keyword.
860                Ok(())
861            } else if m.path.is_ident("format") || m.path.is_ident("choices") {
862                // Handled by `parse_format_attr` / `parse_choices_attr`.
863                // Consume the value (a string literal or an array)
864                // generically so this parser doesn't trip on it,
865                // mirroring the `file` marker's pass-through.
866                let _: syn::Expr = m.value()?.parse()?;
867                Ok(())
868            } else {
869                Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
870            }
871        })?;
872        if let Some(t) = target {
873            return Ok(Some((t, display)));
874        }
875        if display.is_some() {
876            return Err(syn::Error::new_spanned(
877                attr,
878                "`display` requires `belongs_to` alongside it",
879            ));
880        }
881    }
882    // Suppress the unused warning for `Meta`.
883    let _ = std::marker::PhantomData::<Meta>;
884    Ok(None)
885}
886
887/// `#[rustio(file)]` marker — promotes a `String` /
888/// `Option<String>` field to `FieldKind::FilePath` /
889/// `FieldKind::OptionalFilePath`. The form renderer then emits
890/// `<input type="file">` and the runtime's multipart-form
891/// handler writes the uploaded bytes to `Admin::uploads_dir`
892/// before injecting the relative path back into the form's
893/// string slot.
894fn parse_file_attr(attrs: &[syn::Attribute]) -> syn::Result<bool> {
895    for attr in attrs {
896        if !attr.path().is_ident("rustio") {
897            continue;
898        }
899        let mut found = false;
900        attr.parse_nested_meta(|m| {
901            if m.path.is_ident("file") {
902                found = true;
903                Ok(())
904            } else if m.input.peek(syn::Token![=]) {
905                // Other keys (`belongs_to = "…"`, `choices = [...]`)
906                // carry an `=` and a value we must consume so the
907                // parser doesn't choke on the trailing `,`. Parse as a
908                // generic `Expr` so array values (`choices`) skip
909                // cleanly alongside literals. We don't validate here —
910                // each key's owning parser does.
911                let _: syn::Expr = m.value()?.parse()?;
912                Ok(())
913            } else {
914                // Marker key without `=` (future flags). Just skip.
915                Ok(())
916            }
917        })?;
918        if found {
919            return Ok(true);
920        }
921    }
922    Ok(false)
923}
924
925/// Read `#[rustio(format = "email" | "phone")]` off a field. Returns
926/// the lowercase format name, or `None` when no such attribute is
927/// present. Any value other than `"email"` / `"phone"` is a compile
928/// error pointing at the attribute span. Other `rustio` keys
929/// (`belongs_to = "…"`, `display = "…"`, the `file` marker) are
930/// tolerated and skipped so this parser composes with the others.
931fn parse_format_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<String>> {
932    for attr in attrs {
933        if !attr.path().is_ident("rustio") {
934            continue;
935        }
936        let mut found: Option<String> = None;
937        attr.parse_nested_meta(|m| {
938            if m.path.is_ident("format") {
939                let value = m.value()?;
940                let lit: syn::LitStr = value.parse()?;
941                let v = lit.value();
942                if v != "email" && v != "phone" {
943                    return Err(m.error(format!(
944                        "#[rustio(format = \"...\")] accepts only \"email\" or \"phone\"; got \"{v}\""
945                    )));
946                }
947                found = Some(v);
948                Ok(())
949            } else if m.input.peek(syn::Token![=]) {
950                // Some other `key = value` (incl. `choices = [...]`);
951                // consume it as a generic `Expr` so arrays skip
952                // cleanly without tripping the trailing `,`.
953                let _: syn::Expr = m.value()?.parse()?;
954                Ok(())
955            } else {
956                // Marker key without `=` (e.g. `file`). Skip.
957                Ok(())
958            }
959        })?;
960        if found.is_some() {
961            return Ok(found);
962        }
963    }
964    Ok(None)
965}
966
967/// Read `#[rustio(choices = ["a", "b", ...])]` off a field. Returns
968/// the declared values in declaration order, or `None` when absent.
969/// An empty array, a non-array value, or a non-string-literal element
970/// is a compile error. Other `rustio` keys are skipped so this
971/// composes with the file / format / relation parsers.
972fn parse_choices_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<Vec<String>>> {
973    for attr in attrs {
974        if !attr.path().is_ident("rustio") {
975            continue;
976        }
977        let mut found: Option<Vec<String>> = None;
978        attr.parse_nested_meta(|m| {
979            if m.path.is_ident("choices") {
980                let array: syn::ExprArray = m.value()?.parse()?;
981                let mut values = Vec::with_capacity(array.elems.len());
982                for elem in &array.elems {
983                    match elem {
984                        syn::Expr::Lit(syn::ExprLit {
985                            lit: Lit::Str(s), ..
986                        }) => values.push(s.value()),
987                        other => {
988                            return Err(syn::Error::new_spanned(
989                                other,
990                                "#[rustio(choices = [...])] elements must be string literals",
991                            ));
992                        }
993                    }
994                }
995                if values.is_empty() {
996                    return Err(m.error("#[rustio(choices = [...])] needs at least one value"));
997                }
998                found = Some(values);
999                Ok(())
1000            } else if m.input.peek(syn::Token![=]) {
1001                let _: syn::Expr = m.value()?.parse()?;
1002                Ok(())
1003            } else {
1004                Ok(())
1005            }
1006        })?;
1007        if found.is_some() {
1008            return Ok(found);
1009        }
1010    }
1011    Ok(None)
1012}
1013
1014fn plural_snake(camel: &str) -> String {
1015    let snake = camel_to_snake(camel);
1016    // Regular English pluralisation. Irregular plurals (Person →
1017    // People, Mouse → Mice) need `#[rustio(admin_name = "...")]`.
1018    if snake.ends_with('s') {
1019        // Already ends in 's' — leave as-is so structs named in the
1020        // plural (`Posts`) don't become `postss`. Edge cases like
1021        // `Bus` → `buses` need the F1 override.
1022        snake
1023    } else if snake.ends_with('x')
1024        || snake.ends_with('z')
1025        || snake.ends_with("ch")
1026        || snake.ends_with("sh")
1027    {
1028        format!("{snake}es")
1029    } else if let Some(stem) = snake.strip_suffix('y') {
1030        // consonant + y → ies (Category → Categories);
1031        // vowel + y → s (Toy → Toys).
1032        let before = stem.chars().last();
1033        if matches!(before, Some('a' | 'e' | 'i' | 'o' | 'u')) || stem.is_empty() {
1034            format!("{snake}s")
1035        } else {
1036            format!("{stem}ies")
1037        }
1038    } else {
1039        format!("{snake}s")
1040    }
1041}
1042
1043fn camel_to_snake(s: &str) -> String {
1044    let mut out = String::new();
1045    for (i, c) in s.chars().enumerate() {
1046        if c.is_ascii_uppercase() && i > 0 {
1047            out.push('_');
1048        }
1049        out.push(c.to_ascii_lowercase());
1050    }
1051    out
1052}
1053
1054fn humanise(snake: &str) -> String {
1055    // "blog_posts" → "Blog posts"
1056    let mut chars = snake.chars();
1057    let mut out = String::new();
1058    if let Some(first) = chars.next() {
1059        out.push(first.to_ascii_uppercase());
1060    }
1061    for c in chars {
1062        if c == '_' {
1063            out.push(' ');
1064        } else {
1065            out.push(c);
1066        }
1067    }
1068    out
1069}
1070
1071fn find_label_field(
1072    fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
1073) -> Option<String> {
1074    // Heuristic: prefer `name`, then `title`, then `full_name`, then
1075    // fall through to `#id`. Keeps `object_label()` useful without
1076    // forcing users to implement anything.
1077    let names = ["name", "title", "full_name", "label", "email"];
1078    for candidate in names {
1079        if fields
1080            .iter()
1081            .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
1082        {
1083            return Some(candidate.to_string());
1084        }
1085    }
1086    None
1087}
1088
1089#[cfg(test)]
1090mod plural_snake_tests {
1091    use super::plural_snake;
1092
1093    #[test]
1094    fn regular_plurals() {
1095        assert_eq!(plural_snake("Post"), "posts");
1096        assert_eq!(plural_snake("Loan"), "loans");
1097        assert_eq!(plural_snake("BlogPost"), "blog_posts");
1098        assert_eq!(plural_snake("CaseAction"), "case_actions");
1099    }
1100
1101    #[test]
1102    fn ch_sh_x_z_suffixes_take_es() {
1103        assert_eq!(plural_snake("Branch"), "branches");
1104        assert_eq!(plural_snake("Box"), "boxes");
1105        assert_eq!(plural_snake("Dish"), "dishes");
1106        assert_eq!(plural_snake("Buzz"), "buzzes");
1107    }
1108
1109    #[test]
1110    fn consonant_y_becomes_ies_vowel_y_keeps_s() {
1111        assert_eq!(plural_snake("Category"), "categories");
1112        assert_eq!(plural_snake("Story"), "stories");
1113        assert_eq!(plural_snake("Toy"), "toys");
1114        assert_eq!(plural_snake("Day"), "days");
1115    }
1116
1117    #[test]
1118    fn trailing_s_left_alone() {
1119        assert_eq!(plural_snake("Posts"), "posts");
1120        assert_eq!(plural_snake("Status"), "status");
1121    }
1122}
1123
1124#[cfg(test)]
1125mod humanise_field_tests {
1126    use super::humanise_field;
1127
1128    #[test]
1129    fn snake_case_to_title_case() {
1130        assert_eq!(humanise_field("title"), "Title");
1131        assert_eq!(humanise_field("chart_number"), "Chart Number");
1132        assert_eq!(humanise_field("full_name"), "Full Name");
1133        assert_eq!(
1134            humanise_field("performed_by_technician"),
1135            "Performed By Technician"
1136        );
1137    }
1138
1139    #[test]
1140    fn standalone_acronyms_are_uppercased() {
1141        // The shipped fix: `id` no longer humanises to `Id`.
1142        assert_eq!(humanise_field("id"), "ID");
1143        assert_eq!(humanise_field("ip"), "IP");
1144        assert_eq!(humanise_field("url"), "URL");
1145        assert_eq!(humanise_field("uuid"), "UUID");
1146        assert_eq!(humanise_field("mfa"), "MFA");
1147    }
1148
1149    #[test]
1150    fn acronyms_inside_compound_names_are_uppercased() {
1151        assert_eq!(humanise_field("email_id"), "Email ID");
1152        assert_eq!(humanise_field("id_card"), "ID Card");
1153        assert_eq!(humanise_field("user_ip"), "User IP");
1154        assert_eq!(humanise_field("api_token"), "API Token");
1155        assert_eq!(humanise_field("mfa_secret_key_id"), "MFA Secret Key ID");
1156        assert_eq!(humanise_field("csv_export_path"), "CSV Export Path");
1157    }
1158
1159    #[test]
1160    fn acronym_substrings_are_not_uppercased() {
1161        // `id` appears inside `video` — the WORD is the unit, not
1162        // any embedded substring. Without this guarantee a field
1163        // named `video_url` would render as `vIDeo URL`.
1164        assert_eq!(humanise_field("video"), "Video");
1165        assert_eq!(humanise_field("video_url"), "Video URL");
1166        assert_eq!(humanise_field("hidden_field"), "Hidden Field");
1167        assert_eq!(humanise_field("idle_seconds"), "Idle Seconds");
1168    }
1169
1170    #[test]
1171    fn empty_and_trivial_inputs_are_safe() {
1172        assert_eq!(humanise_field(""), "");
1173        assert_eq!(humanise_field("a"), "A");
1174    }
1175
1176    #[test]
1177    fn datetime_suffixes_preserved() {
1178        // `at` / `to` / `by` are prepositions, not acronyms —
1179        // they stay sentence-case.
1180        assert_eq!(humanise_field("created_at"), "Created At");
1181        assert_eq!(humanise_field("revoked_by"), "Revoked By");
1182        assert_eq!(humanise_field("expires_at"), "Expires At");
1183    }
1184}