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