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        let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
92
93        let type_variant = kind.field_type_ident();
94        let relation = parse_relation_attr(&f.attrs, &fname_str)?;
95        let relation_tokens = match &relation {
96            Some((target, display)) => {
97                let display_tok = match display {
98                    Some(d) => quote! { ::std::option::Option::Some(#d) },
99                    None => quote! { ::std::option::Option::None },
100                };
101                quote! {
102                    ::std::option::Option::Some(::rustio_admin::admin::AdminRelation {
103                        target_model: #target,
104                        display_field: #display_tok,
105                        // Single belongs_to relations default to
106                        // single `<select>`. Many-to-many is opt-in via
107                        // a future `#[rustio(many_to_many)]` attribute;
108                        // the macro emits `false` for now so consumers
109                        // that want multi-select must hand-set the
110                        // field on the generated AdminRelation.
111                        multi: false,
112                    })
113                }
114            }
115            None => quote! { ::std::option::Option::None },
116        };
117
118        // Humanised display label, computed once at expansion time:
119        // `performed_by_technician` → `"Performed by technician"`. The
120        // list page renders this through CSS uppercase+tracking as
121        // `PERFORMED BY TECHNICIAN` with real word boundaries, so the
122        // header can wrap on narrow rows instead of dictating a wide
123        // column floor. Also reused below for validation messages.
124        let humanised_label = humanise_field(&fname_str);
125        field_metas.push(quote! {
126            ::rustio_admin::admin::AdminField {
127                name: #fname_str,
128                label: #humanised_label,
129                field_type: ::rustio_admin::admin::FieldType::#type_variant,
130                editable: #editable,
131                relation: #relation_tokens,
132                // Derived models don't carry enum choices yet. A future
133                // macro pass will accept `#[rustio(choices = [...])]`
134                // and populate this; today consumers that want a
135                // `<select>` backed by a static value list set this on
136                // the generated AdminField directly.
137                choices: ::std::option::Option::None,
138            }
139        });
140
141        // `display_values`: stringify the field for the list page.
142        let display_arm = match kind {
143            // FilePath / OptionalFilePath live in `String` /
144            // `Option<String>` Rust types but render in the form
145            // as `<input type="file">`. The display path is
146            // identical to the string variants — the stored value
147            // IS the relative path, surfaced as plain text on the
148            // list page.
149            FieldKind::String | FieldKind::FilePath => quote! {
150                out.push((#fname_str.to_string(), self.#fname.clone()));
151            },
152            FieldKind::OptionalString | FieldKind::OptionalFilePath => quote! {
153                // `Option<String>` does not implement `Display`, so we
154                // can't share the String arm. None → empty string,
155                // Some(v) → v.
156                out.push((#fname_str.to_string(), match &self.#fname {
157                    Some(v) => v.clone(),
158                    None => String::new(),
159                }));
160            },
161            FieldKind::I32 | FieldKind::I64 => quote! {
162                out.push((#fname_str.to_string(), self.#fname.to_string()));
163            },
164            FieldKind::OptionalI64 => quote! {
165                out.push((#fname_str.to_string(), match &self.#fname {
166                    Some(v) => v.to_string(),
167                    None => String::new(),
168                }));
169            },
170            FieldKind::Bool => quote! {
171                out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
172            },
173            FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
174                // ISO-8601 form with `T` separator. This is the exact
175                // wire format `<input type="datetime-local">` expects
176                // (`%Y-%m-%dT%H:%M`); the form-render path puts this
177                // string straight into the input's `value=` attribute.
178                // The list path detects the same shape (16 chars, `T`
179                // at index 10) and splits it into the two-line cell
180                // layout. NOTE: `datetime-local` cannot encode timezone;
181                // we surface UTC values directly.
182                out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
183            },
184            FieldKind::OptionalDateTime => quote! {
185                // Symmetric to `OptionalString` / `OptionalI64`: None →
186                // empty string, Some(v) → same ISO-8601 form as the
187                // non-optional `DateTime` arm.
188                out.push((#fname_str.to_string(), match &self.#fname {
189                    Some(v) => v.format("%Y-%m-%dT%H:%M").to_string(),
190                    None => String::new(),
191                }));
192            },
193        };
194        display_value_arms.push(display_arm);
195
196        // `from_form`: read the HTML form body into a struct field.
197        if fname_str == "id" {
198            from_form_fields.push(quote! { #fname: 0 });
199            continue;
200        }
201
202        // Precompute human-readable validation messages at expansion
203        // time so the runtime error path doesn't repeat the same
204        // `format!` work per request and so every model emits
205        // identically-styled copy. `humanised_label` was already
206        // computed above for `AdminField.label`.
207        let required_msg = format!("{humanised_label} is required.");
208        let number_msg = format!("{humanised_label} must be a number.");
209        let date_invalid_msg = format!("{humanised_label} is not a valid date.");
210
211        match kind {
212            FieldKind::String | FieldKind::FilePath => {
213                // Trim incoming whitespace so a `"   "` submission is
214                // treated as empty (and triggers the required-field
215                // error) instead of silently saving a whitespace-only
216                // string. FilePath uses the same trimming path: the
217                // multipart-form handler injects the saved relative
218                // path string into the form before `from_form` sees
219                // it, so the value lands here as a normal String.
220                from_form_parses.push(quote! {
221                    let #fname = match form.get(#fname_str).map(str::trim) {
222                        Some(v) if !v.is_empty() => v.to_string(),
223                        _ => { errors.push(#required_msg.to_string()); String::new() }
224                    };
225                });
226                from_form_fields.push(quote! { #fname });
227            }
228            FieldKind::OptionalString | FieldKind::OptionalFilePath => {
229                // Trim, then collapse trimmed-empty to None so the
230                // column stores NULL instead of `""`. Optional
231                // FilePath shares the same path — the file-input
232                // widget can submit an empty string when the
233                // operator clears the field.
234                from_form_parses.push(quote! {
235                    let #fname: Option<String> = form
236                        .get(#fname_str)
237                        .map(|s| s.trim().to_string())
238                        .filter(|s| !s.is_empty());
239                });
240                from_form_fields.push(quote! { #fname });
241            }
242            FieldKind::I32 => {
243                from_form_parses.push(quote! {
244                    let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
245                        Some(v) => v,
246                        None => { errors.push(#number_msg.to_string()); 0 }
247                    };
248                });
249                from_form_fields.push(quote! { #fname });
250            }
251            FieldKind::I64 => {
252                from_form_parses.push(quote! {
253                    let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
254                        Some(v) => v,
255                        None => { errors.push(#number_msg.to_string()); 0 }
256                    };
257                });
258                from_form_fields.push(quote! { #fname });
259            }
260            FieldKind::OptionalI64 => {
261                // Distinguish "user left it blank" (None, legitimate)
262                // from "user typed garbage" (validation error, NOT
263                // silently dropped).
264                from_form_parses.push(quote! {
265                    let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
266                        None | Some("") => None,
267                        Some(raw) => match raw.parse::<i64>() {
268                            Ok(n) => Some(n),
269                            Err(_) => {
270                                errors.push(#number_msg.to_string());
271                                None
272                            }
273                        },
274                    };
275                });
276                from_form_fields.push(quote! { #fname });
277            }
278            FieldKind::Bool => {
279                from_form_parses.push(quote! {
280                    let #fname: bool = form.bool_flag(#fname_str);
281                });
282                from_form_fields.push(quote! { #fname });
283            }
284            FieldKind::DateTime => {
285                from_form_parses.push(quote! {
286                    let #fname = match form.get(#fname_str) {
287                        Some(raw) if !raw.is_empty() => {
288                            match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
289                                Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
290                                Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
291                            }
292                        }
293                        _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
294                    };
295                });
296                from_form_fields.push(quote! { #fname });
297            }
298            FieldKind::DateTimeAuto => {
299                // created_at-style fields default to now().
300                from_form_parses.push(quote! {
301                    let #fname = ::chrono::Utc::now();
302                });
303                from_form_fields.push(quote! { #fname });
304            }
305            FieldKind::OptionalDateTime => {
306                // Symmetric to `OptionalI64`: blank → None (legitimate),
307                // garbage → validation error + None (NOT silently
308                // defaulted to `Utc::now()` like the non-optional arm).
309                from_form_parses.push(quote! {
310                    let #fname: ::std::option::Option<::chrono::DateTime<::chrono::Utc>> =
311                        match form.get(#fname_str).map(str::trim) {
312                            None | Some("") => ::std::option::Option::None,
313                            Some(raw) => match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
314                                Ok(dt) => ::std::option::Option::Some(
315                                    ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
316                                ),
317                                Err(_) => {
318                                    errors.push(#date_invalid_msg.to_string());
319                                    ::std::option::Option::None
320                                }
321                            },
322                        };
323                });
324                from_form_fields.push(quote! { #fname });
325            }
326        }
327
328        update_tuples.push(quote! {
329            (#fname_str, self.#fname.clone().into())
330        });
331    }
332
333    let object_label_expr = find_label_field(fields)
334        .map(|n| {
335            let id = format_ident!("{n}");
336            quote! { self.#id.clone().to_string() }
337        })
338        .unwrap_or_else(|| quote! { format!("#{}", self.id) });
339
340    Ok(quote! {
341        impl ::rustio_admin::admin::AdminModel for #struct_name {
342            const ADMIN_NAME: &'static str = #admin_name;
343            const DISPLAY_NAME: &'static str = #display_name;
344            const SINGULAR_NAME: &'static str = #singular;
345            const FIELDS: &'static [::rustio_admin::admin::AdminField] = &[
346                #(#field_metas),*
347            ];
348
349            fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
350                let mut out = ::std::vec::Vec::new();
351                #(#display_value_arms)*
352                out
353            }
354
355            fn from_form(form: &::rustio_admin::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
356            where
357                Self: Sized,
358            {
359                let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
360                #(#from_form_parses)*
361                if !errors.is_empty() {
362                    return Err(errors);
363                }
364                Ok(Self { #(#from_form_fields),* })
365            }
366
367            fn object_label(&self) -> ::std::string::String {
368                #object_label_expr
369            }
370
371            fn id(&self) -> i64 {
372                self.id
373            }
374
375            fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_admin::orm::Value)> {
376                ::std::vec![#(#update_tuples),*]
377            }
378        }
379    })
380}
381
382fn struct_fields(
383    input: &DeriveInput,
384) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
385    let data = match &input.data {
386        Data::Struct(s) => s,
387        _ => {
388            return Err(syn::Error::new_spanned(
389                &input.ident,
390                "RustioAdmin can only derive on structs",
391            ))
392        }
393    };
394    match &data.fields {
395        Fields::Named(named) => Ok(&named.named),
396        _ => Err(syn::Error::new_spanned(
397            &input.ident,
398            "RustioAdmin requires a struct with named fields",
399        )),
400    }
401}
402
403#[derive(Debug, PartialEq, Clone, Copy)]
404enum FieldKind {
405    I32,
406    I64,
407    Bool,
408    String,
409    DateTime,
410    DateTimeAuto,
411    OptionalString,
412    OptionalI64,
413    OptionalDateTime,
414    /// `String` column flagged with `#[rustio(file)]`. Renders as
415    /// `<input type="file">`; the multipart-form handler writes
416    /// the uploaded bytes under `Admin::uploads_dir` and injects
417    /// the relative path string back into the form before
418    /// `from_form` parses it as a normal String.
419    FilePath,
420    /// `Option<String>` counterpart.
421    OptionalFilePath,
422}
423
424impl FieldKind {
425    fn field_type_ident(&self) -> proc_macro2::Ident {
426        match self {
427            FieldKind::I32 => format_ident!("I32"),
428            FieldKind::I64 => format_ident!("I64"),
429            FieldKind::Bool => format_ident!("Bool"),
430            FieldKind::String => format_ident!("String"),
431            FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
432            FieldKind::OptionalString => format_ident!("OptionalString"),
433            FieldKind::OptionalI64 => format_ident!("OptionalI64"),
434            FieldKind::OptionalDateTime => format_ident!("OptionalDateTime"),
435            FieldKind::FilePath => format_ident!("FilePath"),
436            FieldKind::OptionalFilePath => format_ident!("OptionalFilePath"),
437        }
438    }
439}
440
441/// Names treated as framework-managed timestamps. These fields are
442/// auto-promoted to `FieldKind::DateTimeAuto` regardless of declared
443/// type so the admin UI doesn't render them and `from_form` fills
444/// them with `Utc::now()`. Conservative list; expand only when a real
445/// model needs another conventionally-named timestamp.
446fn is_auto_timestamp_name(name: &str) -> bool {
447    matches!(name, "created_at" | "updated_at")
448}
449
450/// Turn a snake_case column name into a Title-Case label for human-
451/// readable validation errors emitted by `from_form`. Mirrors the
452/// runtime humanise helper so error labels and rendered form labels
453/// use identical capitalisation.
454///
455/// Whole-word acronym recognition: each underscore-separated segment
456/// is checked against [`HUMANISE_ACRONYMS`] before being
457/// title-cased, so `id` → `ID`, `email_id` → `Email ID`,
458/// `mfa_secret_key_id` → `MFA Secret Key ID`. Words *containing* but
459/// not *being* an acronym (`video` is not `vIDeo`) are left to the
460/// default first-letter-uppercase rule.
461fn humanise_field(s: &str) -> String {
462    if s.is_empty() {
463        return String::new();
464    }
465    let mut out = String::with_capacity(s.len());
466    let mut first_segment = true;
467    for segment in s.split('_') {
468        if !first_segment {
469            out.push(' ');
470        }
471        first_segment = false;
472        let lower = segment.to_ascii_lowercase();
473        if HUMANISE_ACRONYMS.contains(&lower.as_str()) {
474            out.push_str(&lower.to_ascii_uppercase());
475        } else {
476            let mut chars = segment.chars();
477            if let Some(first) = chars.next() {
478                out.push(first.to_ascii_uppercase());
479                for c in chars {
480                    out.push(c);
481                }
482            }
483        }
484    }
485    out
486}
487
488/// Acronyms that should be fully uppercase in humanised labels.
489///
490/// Byte-for-byte mirror of
491/// `rustio_admin::admin::render::HUMANISE_ACRONYMS` — the macros
492/// crate cannot depend on the main crate (proc-macro cycle), so
493/// the two lists are intentionally duplicated. Update both
494/// together.
495const HUMANISE_ACRONYMS: &[&str] = &[
496    "id", "ip", "url", "uri", "api", "uuid", "mfa", "csv", "sql", "html", "http", "https", "json",
497    "tls", "ssl", "smtp", "xml",
498];
499
500fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
501    let as_string = quote! { #ty }.to_string().replace(' ', "");
502    let kind = match as_string.as_str() {
503        "i32" => FieldKind::I32,
504        "i64" => FieldKind::I64,
505        "bool" => FieldKind::Bool,
506        "String" => FieldKind::String,
507        "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
508        "Option<String>" => FieldKind::OptionalString,
509        "Option<i64>" => FieldKind::OptionalI64,
510        "Option<DateTime<Utc>>" | "Option<chrono::DateTime<chrono::Utc>>" => {
511            FieldKind::OptionalDateTime
512        }
513        other => {
514            return Err(syn::Error::new_spanned(
515                ty,
516                format!("unsupported field type for RustioAdmin: {other}"),
517            ))
518        }
519    };
520    Ok(kind)
521}
522
523/// Project-side struct-level overrides parsed from
524/// `#[rustio(...)]` on the deriving struct. Adds a polish escape
525/// hatch for the otherwise-correct auto-derived defaults — see
526/// `VISIBILITY_AUDIT.md` F3.
527///
528/// Example:
529///
530/// ```ignore
531/// #[derive(RustioAdmin)]
532/// #[rustio(
533///     admin_name = "case-actions",
534///     display_name = "Case events"
535/// )]
536/// pub struct CaseAction { … }
537/// ```
538///
539/// Both fields are optional. Unknown keys produce a compile error
540/// pointing at the attribute span.
541#[derive(Default)]
542struct StructOverrides {
543    admin_name: Option<String>,
544    display_name: Option<String>,
545}
546
547fn parse_struct_attr(attrs: &[syn::Attribute]) -> syn::Result<StructOverrides> {
548    let mut out = StructOverrides::default();
549    for attr in attrs {
550        if !attr.path().is_ident("rustio") {
551            continue;
552        }
553        attr.parse_nested_meta(|m| {
554            if m.path.is_ident("admin_name") {
555                let value = m.value()?;
556                let lit: Lit = value.parse()?;
557                if let Lit::Str(s) = lit {
558                    out.admin_name = Some(s.value());
559                }
560                Ok(())
561            } else if m.path.is_ident("display_name") {
562                let value = m.value()?;
563                let lit: Lit = value.parse()?;
564                if let Lit::Str(s) = lit {
565                    out.display_name = Some(s.value());
566                }
567                Ok(())
568            } else {
569                // Field-level keys (e.g. `belongs_to`, `display`)
570                // legitimately appear on `#[rustio(...)]` placed on
571                // FIELDS, not the struct. When the same `rustio`
572                // attribute is on the struct, those keys are
573                // surprising. Reject so a misplaced field attribute
574                // doesn't silently fail.
575                Err(m.error(
576                    "unknown rustio struct attribute; expected `admin_name` or `display_name`",
577                ))
578            }
579        })?;
580    }
581    Ok(out)
582}
583
584fn parse_relation_attr(
585    attrs: &[syn::Attribute],
586    field_name: &str,
587) -> syn::Result<Option<(String, Option<String>)>> {
588    for attr in attrs {
589        if !attr.path().is_ident("rustio") {
590            continue;
591        }
592        let mut target: Option<String> = None;
593        let mut display: Option<String> = None;
594        attr.parse_nested_meta(|m| {
595            if m.path.is_ident("belongs_to") {
596                let value = m.value()?;
597                let lit: Lit = value.parse()?;
598                if let Lit::Str(s) = lit {
599                    target = Some(s.value());
600                }
601                Ok(())
602            } else if m.path.is_ident("display") {
603                let value = m.value()?;
604                let lit: Lit = value.parse()?;
605                if let Lit::Str(s) = lit {
606                    display = Some(s.value());
607                }
608                Ok(())
609            } else if m.path.is_ident("file") {
610                // Marker attribute — handled by `parse_file_attr`,
611                // ignored here so a field can carry both
612                // `belongs_to` and `file` without one parser
613                // erroring on the other's keyword.
614                Ok(())
615            } else {
616                Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
617            }
618        })?;
619        if let Some(t) = target {
620            return Ok(Some((t, display)));
621        }
622        if display.is_some() {
623            return Err(syn::Error::new_spanned(
624                attr,
625                "`display` requires `belongs_to` alongside it",
626            ));
627        }
628    }
629    // Suppress the unused warning for `Meta`.
630    let _ = std::marker::PhantomData::<Meta>;
631    Ok(None)
632}
633
634/// `#[rustio(file)]` marker — promotes a `String` /
635/// `Option<String>` field to `FieldKind::FilePath` /
636/// `FieldKind::OptionalFilePath`. The form renderer then emits
637/// `<input type="file">` and the runtime's multipart-form
638/// handler writes the uploaded bytes to `Admin::uploads_dir`
639/// before injecting the relative path back into the form's
640/// string slot.
641fn parse_file_attr(attrs: &[syn::Attribute]) -> syn::Result<bool> {
642    for attr in attrs {
643        if !attr.path().is_ident("rustio") {
644            continue;
645        }
646        let mut found = false;
647        attr.parse_nested_meta(|m| {
648            if m.path.is_ident("file") {
649                found = true;
650                Ok(())
651            } else if m.input.peek(syn::Token![=]) {
652                // Other keys (`belongs_to = "…"`, `display = "…"`)
653                // carry an `=` and a literal we must consume so the
654                // parser doesn't choke on the trailing `,`. We don't
655                // validate the value here — `parse_relation_attr`
656                // owns the surface; this is just lexer-level skip.
657                let _value = m.value()?;
658                let _: Lit = _value.parse()?;
659                Ok(())
660            } else {
661                // Marker key without `=` (future flags). Just skip.
662                Ok(())
663            }
664        })?;
665        if found {
666            return Ok(true);
667        }
668    }
669    Ok(false)
670}
671
672fn plural_snake(camel: &str) -> String {
673    let snake = camel_to_snake(camel);
674    // Regular English pluralisation. Irregular plurals (Person →
675    // People, Mouse → Mice) need `#[rustio(admin_name = "...")]`.
676    if snake.ends_with('s') {
677        // Already ends in 's' — leave as-is so structs named in the
678        // plural (`Posts`) don't become `postss`. Edge cases like
679        // `Bus` → `buses` need the F1 override.
680        snake
681    } else if snake.ends_with('x')
682        || snake.ends_with('z')
683        || snake.ends_with("ch")
684        || snake.ends_with("sh")
685    {
686        format!("{snake}es")
687    } else if let Some(stem) = snake.strip_suffix('y') {
688        // consonant + y → ies (Category → Categories);
689        // vowel + y → s (Toy → Toys).
690        let before = stem.chars().last();
691        if matches!(before, Some('a' | 'e' | 'i' | 'o' | 'u')) || stem.is_empty() {
692            format!("{snake}s")
693        } else {
694            format!("{stem}ies")
695        }
696    } else {
697        format!("{snake}s")
698    }
699}
700
701fn camel_to_snake(s: &str) -> String {
702    let mut out = String::new();
703    for (i, c) in s.chars().enumerate() {
704        if c.is_ascii_uppercase() && i > 0 {
705            out.push('_');
706        }
707        out.push(c.to_ascii_lowercase());
708    }
709    out
710}
711
712fn humanise(snake: &str) -> String {
713    // "blog_posts" → "Blog posts"
714    let mut chars = snake.chars();
715    let mut out = String::new();
716    if let Some(first) = chars.next() {
717        out.push(first.to_ascii_uppercase());
718    }
719    for c in chars {
720        if c == '_' {
721            out.push(' ');
722        } else {
723            out.push(c);
724        }
725    }
726    out
727}
728
729fn find_label_field(
730    fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
731) -> Option<String> {
732    // Heuristic: prefer `name`, then `title`, then `full_name`, then
733    // fall through to `#id`. Keeps `object_label()` useful without
734    // forcing users to implement anything.
735    let names = ["name", "title", "full_name", "label", "email"];
736    for candidate in names {
737        if fields
738            .iter()
739            .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
740        {
741            return Some(candidate.to_string());
742        }
743    }
744    None
745}
746
747#[cfg(test)]
748mod plural_snake_tests {
749    use super::plural_snake;
750
751    #[test]
752    fn regular_plurals() {
753        assert_eq!(plural_snake("Post"), "posts");
754        assert_eq!(plural_snake("Loan"), "loans");
755        assert_eq!(plural_snake("BlogPost"), "blog_posts");
756        assert_eq!(plural_snake("CaseAction"), "case_actions");
757    }
758
759    #[test]
760    fn ch_sh_x_z_suffixes_take_es() {
761        assert_eq!(plural_snake("Branch"), "branches");
762        assert_eq!(plural_snake("Box"), "boxes");
763        assert_eq!(plural_snake("Dish"), "dishes");
764        assert_eq!(plural_snake("Buzz"), "buzzes");
765    }
766
767    #[test]
768    fn consonant_y_becomes_ies_vowel_y_keeps_s() {
769        assert_eq!(plural_snake("Category"), "categories");
770        assert_eq!(plural_snake("Story"), "stories");
771        assert_eq!(plural_snake("Toy"), "toys");
772        assert_eq!(plural_snake("Day"), "days");
773    }
774
775    #[test]
776    fn trailing_s_left_alone() {
777        assert_eq!(plural_snake("Posts"), "posts");
778        assert_eq!(plural_snake("Status"), "status");
779    }
780}
781
782#[cfg(test)]
783mod humanise_field_tests {
784    use super::humanise_field;
785
786    #[test]
787    fn snake_case_to_title_case() {
788        assert_eq!(humanise_field("title"), "Title");
789        assert_eq!(humanise_field("chart_number"), "Chart Number");
790        assert_eq!(humanise_field("full_name"), "Full Name");
791        assert_eq!(
792            humanise_field("performed_by_technician"),
793            "Performed By Technician"
794        );
795    }
796
797    #[test]
798    fn standalone_acronyms_are_uppercased() {
799        // The shipped fix: `id` no longer humanises to `Id`.
800        assert_eq!(humanise_field("id"), "ID");
801        assert_eq!(humanise_field("ip"), "IP");
802        assert_eq!(humanise_field("url"), "URL");
803        assert_eq!(humanise_field("uuid"), "UUID");
804        assert_eq!(humanise_field("mfa"), "MFA");
805    }
806
807    #[test]
808    fn acronyms_inside_compound_names_are_uppercased() {
809        assert_eq!(humanise_field("email_id"), "Email ID");
810        assert_eq!(humanise_field("id_card"), "ID Card");
811        assert_eq!(humanise_field("user_ip"), "User IP");
812        assert_eq!(humanise_field("api_token"), "API Token");
813        assert_eq!(humanise_field("mfa_secret_key_id"), "MFA Secret Key ID");
814        assert_eq!(humanise_field("csv_export_path"), "CSV Export Path");
815    }
816
817    #[test]
818    fn acronym_substrings_are_not_uppercased() {
819        // `id` appears inside `video` — the WORD is the unit, not
820        // any embedded substring. Without this guarantee a field
821        // named `video_url` would render as `vIDeo URL`.
822        assert_eq!(humanise_field("video"), "Video");
823        assert_eq!(humanise_field("video_url"), "Video URL");
824        assert_eq!(humanise_field("hidden_field"), "Hidden Field");
825        assert_eq!(humanise_field("idle_seconds"), "Idle Seconds");
826    }
827
828    #[test]
829    fn empty_and_trivial_inputs_are_safe() {
830        assert_eq!(humanise_field(""), "");
831        assert_eq!(humanise_field("a"), "A");
832    }
833
834    #[test]
835    fn datetime_suffixes_preserved() {
836        // `at` / `to` / `by` are prepositions, not acronyms —
837        // they stay sentence-case.
838        assert_eq!(humanise_field("created_at"), "Created At");
839        assert_eq!(humanise_field("revoked_by"), "Revoked By");
840        assert_eq!(humanise_field("expires_at"), "Expires At");
841    }
842}