1use 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#[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 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 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 let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
87 FieldKind::DateTimeAuto
88 } else {
89 kind
90 };
91 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 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 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 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 multi: false,
185 })
186 }
187 }
188 None => quote! { ::std::option::Option::None },
189 };
190
191 let humanised_label = humanise_field(&fname_str);
198 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 let display_arm = match kind {
220 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 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 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 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
272 },
273 FieldKind::OptionalDateTime => quote! {
274 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 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%d").to_string()));
287 },
288 FieldKind::Time => quote! {
289 out.push((#fname_str.to_string(), self.#fname.format("%H:%M").to_string()));
291 },
292 };
293 display_value_arms.push(display_arm);
294
295 if fname_str == "id" {
297 from_form_fields.push(quote! { #fname: 0 });
298 continue;
299 }
300
301 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 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 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 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 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 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 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 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 let table_name = match struct_overrides.table {
591 Some(ref t) => t.clone(),
592 None => admin_name.clone(),
593 };
594 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 Email,
698 Phone,
700 Choice,
706 DateTime,
707 Date,
708 Time,
709 Uuid,
710 DateTimeAuto,
711 OptionalString,
712 OptionalI64,
713 OptionalDateTime,
714 FilePath,
720 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 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 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
778fn is_auto_timestamp_name(name: &str) -> bool {
784 matches!(name, "created_at" | "updated_at")
785}
786
787fn 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
825const 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#[derive(Default)]
884struct StructOverrides {
885 admin_name: Option<String>,
886 display_name: Option<String>,
887 table: Option<String>,
890 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 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 Ok(())
987 } else if m.path.is_ident("format") || m.path.is_ident("choices") {
988 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 let _ = std::marker::PhantomData::<Meta>;
1010 Ok(None)
1011}
1012
1013fn 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 let _: syn::Expr = m.value()?.parse()?;
1038 Ok(())
1039 } else {
1040 Ok(())
1042 }
1043 })?;
1044 if found {
1045 return Ok(true);
1046 }
1047 }
1048 Ok(false)
1049}
1050
1051fn 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 let _: syn::Expr = m.value()?.parse()?;
1080 Ok(())
1081 } else {
1082 Ok(())
1084 }
1085 })?;
1086 if found.is_some() {
1087 return Ok(found);
1088 }
1089 }
1090 Ok(None)
1091}
1092
1093fn 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 if snake.ends_with('s') {
1145 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 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 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 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 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 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 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}