1use 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#[proc_macro_derive(RustioAdmin, attributes(rustio))]
20pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
21 let input = parse_macro_input!(input as DeriveInput);
22 expand(input)
23 .unwrap_or_else(|e| e.to_compile_error())
24 .into()
25}
26
27fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
28 let struct_name = &input.ident;
29 let fields = struct_fields(&input)?;
30
31 let 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 let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
66 FieldKind::DateTimeAuto
67 } else {
68 kind
69 };
70 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 kind = match parse_format_attr(&f.attrs)? {
96 Some(fmt) => match kind {
97 FieldKind::String if fmt == "email" => FieldKind::Email,
98 FieldKind::String if fmt == "phone" => FieldKind::Phone,
99 other => {
100 return Err(syn::Error::new_spanned(
101 f,
102 format!(
103 "#[rustio(format = \"...\")] is only valid on String fields; \
104 got {other:?} for `{fname_str}`"
105 ),
106 ));
107 }
108 },
109 None => kind,
110 };
111 let field_choices = parse_choices_attr(&f.attrs)?;
116 let kind = match &field_choices {
117 Some(values) if !values.is_empty() => match kind {
118 FieldKind::String => FieldKind::Choice,
119 other => {
120 return Err(syn::Error::new_spanned(
121 f,
122 format!(
123 "#[rustio(choices = [...])] is only valid on String fields; \
124 got {other:?} for `{fname_str}`"
125 ),
126 ));
127 }
128 },
129 _ => kind,
130 };
131 let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
132
133 let type_variant = kind.field_type_ident();
134 let relation = parse_relation_attr(&f.attrs, &fname_str)?;
135 let relation_tokens = match &relation {
136 Some((target, display)) => {
137 let display_tok = match display {
138 Some(d) => quote! { ::std::option::Option::Some(#d) },
139 None => quote! { ::std::option::Option::None },
140 };
141 quote! {
142 ::std::option::Option::Some(::rustio_admin::admin::AdminRelation {
143 target_model: #target,
144 display_field: #display_tok,
145 multi: false,
152 })
153 }
154 }
155 None => quote! { ::std::option::Option::None },
156 };
157
158 let humanised_label = humanise_field(&fname_str);
165 let choices_tokens = match &field_choices {
168 Some(values) => {
169 let lits = values.iter().map(|v| v.as_str());
170 quote! { ::std::option::Option::Some(&[ #(#lits),* ]) }
171 }
172 None => quote! { ::std::option::Option::None },
173 };
174 field_metas.push(quote! {
175 ::rustio_admin::admin::AdminField {
176 name: #fname_str,
177 label: #humanised_label,
178 field_type: ::rustio_admin::admin::FieldType::#type_variant,
179 editable: #editable,
180 relation: #relation_tokens,
181 choices: #choices_tokens,
182 }
183 });
184
185 let display_arm = match kind {
187 FieldKind::String
194 | FieldKind::FilePath
195 | FieldKind::Email
196 | FieldKind::Phone
197 | FieldKind::Choice => {
198 quote! {
199 out.push((#fname_str.to_string(), self.#fname.clone()));
200 }
201 }
202 FieldKind::OptionalString | FieldKind::OptionalFilePath => quote! {
203 out.push((#fname_str.to_string(), match &self.#fname {
207 Some(v) => v.clone(),
208 None => String::new(),
209 }));
210 },
211 FieldKind::I32
212 | FieldKind::I64
213 | FieldKind::F64
214 | FieldKind::Decimal
215 | FieldKind::Uuid => quote! {
216 out.push((#fname_str.to_string(), self.#fname.to_string()));
219 },
220 FieldKind::OptionalI64 => quote! {
221 out.push((#fname_str.to_string(), match &self.#fname {
222 Some(v) => v.to_string(),
223 None => String::new(),
224 }));
225 },
226 FieldKind::Bool => quote! {
227 out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
228 },
229 FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
230 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
239 },
240 FieldKind::OptionalDateTime => quote! {
241 out.push((#fname_str.to_string(), match &self.#fname {
245 Some(v) => v.format("%Y-%m-%dT%H:%M").to_string(),
246 None => String::new(),
247 }));
248 },
249 FieldKind::Date => quote! {
250 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%d").to_string()));
254 },
255 FieldKind::Time => quote! {
256 out.push((#fname_str.to_string(), self.#fname.format("%H:%M").to_string()));
258 },
259 };
260 display_value_arms.push(display_arm);
261
262 if fname_str == "id" {
264 from_form_fields.push(quote! { #fname: 0 });
265 continue;
266 }
267
268 let required_msg = format!("{humanised_label} is required.");
274 let number_msg = format!("{humanised_label} must be a number.");
275 let date_invalid_msg = format!("{humanised_label} is not a valid date.");
276 let time_invalid_msg = format!("{humanised_label} is not a valid time.");
277 let uuid_invalid_msg = format!("{humanised_label} is not a valid UUID.");
278 let email_invalid_msg = format!("{humanised_label} is not a valid email address.");
279 let phone_invalid_msg = format!("{humanised_label} is not a valid phone number.");
280
281 match kind {
282 FieldKind::String | FieldKind::FilePath => {
283 from_form_parses.push(quote! {
291 let #fname = match form.get(#fname_str).map(str::trim) {
292 Some(v) if !v.is_empty() => v.to_string(),
293 _ => { errors.push(#required_msg.to_string()); String::new() }
294 };
295 });
296 from_form_fields.push(quote! { #fname });
297 }
298 FieldKind::Email => {
299 from_form_parses.push(quote! {
304 let #fname = match form.get(#fname_str).map(str::trim) {
305 Some(v) if !v.is_empty() => {
306 if !::rustio_admin::admin::is_valid_email(v) {
307 errors.push(#email_invalid_msg.to_string());
308 }
309 v.to_string()
310 }
311 _ => { errors.push(#required_msg.to_string()); String::new() }
312 };
313 });
314 from_form_fields.push(quote! { #fname });
315 }
316 FieldKind::Phone => {
317 from_form_parses.push(quote! {
318 let #fname = match form.get(#fname_str).map(str::trim) {
319 Some(v) if !v.is_empty() => {
320 if !::rustio_admin::admin::is_valid_phone(v) {
321 errors.push(#phone_invalid_msg.to_string());
322 }
323 v.to_string()
324 }
325 _ => { errors.push(#required_msg.to_string()); String::new() }
326 };
327 });
328 from_form_fields.push(quote! { #fname });
329 }
330 FieldKind::Choice => {
331 let values = field_choices
336 .as_ref()
337 .expect("Choice kind is only set when choices are present");
338 let choice_lits = values.iter().map(|v| v.as_str());
339 let choice_invalid_msg =
340 format!("{humanised_label} must be one of: {}.", values.join(", "));
341 from_form_parses.push(quote! {
342 let #fname = match form.get(#fname_str).map(str::trim) {
343 Some(v) if !v.is_empty() => {
344 const CHOICES: &[&str] = &[ #(#choice_lits),* ];
345 if !CHOICES.contains(&v) {
346 errors.push(#choice_invalid_msg.to_string());
347 }
348 v.to_string()
349 }
350 _ => { errors.push(#required_msg.to_string()); String::new() }
351 };
352 });
353 from_form_fields.push(quote! { #fname });
354 }
355 FieldKind::OptionalString | FieldKind::OptionalFilePath => {
356 from_form_parses.push(quote! {
362 let #fname: Option<String> = form
363 .get(#fname_str)
364 .map(|s| s.trim().to_string())
365 .filter(|s| !s.is_empty());
366 });
367 from_form_fields.push(quote! { #fname });
368 }
369 FieldKind::I32 => {
370 from_form_parses.push(quote! {
371 let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
372 Some(v) => v,
373 None => { errors.push(#number_msg.to_string()); 0 }
374 };
375 });
376 from_form_fields.push(quote! { #fname });
377 }
378 FieldKind::I64 => {
379 from_form_parses.push(quote! {
380 let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
381 Some(v) => v,
382 None => { errors.push(#number_msg.to_string()); 0 }
383 };
384 });
385 from_form_fields.push(quote! { #fname });
386 }
387 FieldKind::F64 => {
388 from_form_parses.push(quote! {
389 let #fname: f64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
390 Some(v) => v,
391 None => { errors.push(#number_msg.to_string()); 0.0 }
392 };
393 });
394 from_form_fields.push(quote! { #fname });
395 }
396 FieldKind::Decimal => {
397 from_form_parses.push(quote! {
398 let #fname: ::rust_decimal::Decimal =
399 match form.get(#fname_str).map(str::trim) {
400 Some(raw) if !raw.is_empty() => match raw.parse() {
401 Ok(v) => v,
402 Err(_) => {
403 errors.push(#number_msg.to_string());
404 ::rust_decimal::Decimal::ZERO
405 }
406 },
407 _ => {
408 errors.push(#required_msg.to_string());
409 ::rust_decimal::Decimal::ZERO
410 }
411 };
412 });
413 from_form_fields.push(quote! { #fname });
414 }
415 FieldKind::OptionalI64 => {
416 from_form_parses.push(quote! {
420 let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
421 None | Some("") => None,
422 Some(raw) => match raw.parse::<i64>() {
423 Ok(n) => Some(n),
424 Err(_) => {
425 errors.push(#number_msg.to_string());
426 None
427 }
428 },
429 };
430 });
431 from_form_fields.push(quote! { #fname });
432 }
433 FieldKind::Bool => {
434 from_form_parses.push(quote! {
435 let #fname: bool = form.bool_flag(#fname_str);
436 });
437 from_form_fields.push(quote! { #fname });
438 }
439 FieldKind::DateTime => {
440 from_form_parses.push(quote! {
441 let #fname = match form.get(#fname_str) {
442 Some(raw) if !raw.is_empty() => {
443 match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
444 Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
445 Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
446 }
447 }
448 _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
449 };
450 });
451 from_form_fields.push(quote! { #fname });
452 }
453 FieldKind::Date => {
454 from_form_parses.push(quote! {
455 let #fname = match form.get(#fname_str) {
456 Some(raw) if !raw.is_empty() => {
457 match ::chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
458 Ok(d) => d,
459 Err(_) => {
460 errors.push(#date_invalid_msg.to_string());
461 ::chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()
462 }
463 }
464 }
465 _ => {
466 errors.push(#required_msg.to_string());
467 ::chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap()
468 }
469 };
470 });
471 from_form_fields.push(quote! { #fname });
472 }
473 FieldKind::Time => {
474 from_form_parses.push(quote! {
475 let #fname = match form.get(#fname_str) {
476 Some(raw) if !raw.is_empty() => {
477 match ::chrono::NaiveTime::parse_from_str(raw, "%H:%M") {
478 Ok(t) => t,
479 Err(_) => {
480 errors.push(#time_invalid_msg.to_string());
481 ::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()
482 }
483 }
484 }
485 _ => {
486 errors.push(#required_msg.to_string());
487 ::chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()
488 }
489 };
490 });
491 from_form_fields.push(quote! { #fname });
492 }
493 FieldKind::Uuid => {
494 from_form_parses.push(quote! {
495 let #fname = match form.get(#fname_str).map(str::trim) {
496 Some(raw) if !raw.is_empty() => match ::uuid::Uuid::parse_str(raw) {
497 Ok(u) => u,
498 Err(_) => {
499 errors.push(#uuid_invalid_msg.to_string());
500 ::uuid::Uuid::nil()
501 }
502 },
503 _ => {
504 errors.push(#required_msg.to_string());
505 ::uuid::Uuid::nil()
506 }
507 };
508 });
509 from_form_fields.push(quote! { #fname });
510 }
511 FieldKind::DateTimeAuto => {
512 from_form_parses.push(quote! {
514 let #fname = ::chrono::Utc::now();
515 });
516 from_form_fields.push(quote! { #fname });
517 }
518 FieldKind::OptionalDateTime => {
519 from_form_parses.push(quote! {
523 let #fname: ::std::option::Option<::chrono::DateTime<::chrono::Utc>> =
524 match form.get(#fname_str).map(str::trim) {
525 None | Some("") => ::std::option::Option::None,
526 Some(raw) => match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
527 Ok(dt) => ::std::option::Option::Some(
528 ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
529 ),
530 Err(_) => {
531 errors.push(#date_invalid_msg.to_string());
532 ::std::option::Option::None
533 }
534 },
535 };
536 });
537 from_form_fields.push(quote! { #fname });
538 }
539 }
540
541 update_tuples.push(quote! {
542 (#fname_str, self.#fname.clone().into())
543 });
544 }
545
546 let object_label_expr = find_label_field(fields)
547 .map(|n| {
548 let id = format_ident!("{n}");
549 quote! { self.#id.clone().to_string() }
550 })
551 .unwrap_or_else(|| quote! { format!("#{}", self.id) });
552
553 Ok(quote! {
554 impl ::rustio_admin::admin::AdminModel for #struct_name {
555 const ADMIN_NAME: &'static str = #admin_name;
556 const DISPLAY_NAME: &'static str = #display_name;
557 const SINGULAR_NAME: &'static str = #singular;
558 const FIELDS: &'static [::rustio_admin::admin::AdminField] = &[
559 #(#field_metas),*
560 ];
561
562 fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
563 let mut out = ::std::vec::Vec::new();
564 #(#display_value_arms)*
565 out
566 }
567
568 fn from_form(form: &::rustio_admin::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
569 where
570 Self: Sized,
571 {
572 let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
573 #(#from_form_parses)*
574 if !errors.is_empty() {
575 return Err(errors);
576 }
577 Ok(Self { #(#from_form_fields),* })
578 }
579
580 fn object_label(&self) -> ::std::string::String {
581 #object_label_expr
582 }
583
584 fn id(&self) -> i64 {
585 self.id
586 }
587
588 fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_admin::orm::Value)> {
589 ::std::vec![#(#update_tuples),*]
590 }
591 }
592 })
593}
594
595fn struct_fields(
596 input: &DeriveInput,
597) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
598 let data = match &input.data {
599 Data::Struct(s) => s,
600 _ => {
601 return Err(syn::Error::new_spanned(
602 &input.ident,
603 "RustioAdmin can only derive on structs",
604 ))
605 }
606 };
607 match &data.fields {
608 Fields::Named(named) => Ok(&named.named),
609 _ => Err(syn::Error::new_spanned(
610 &input.ident,
611 "RustioAdmin requires a struct with named fields",
612 )),
613 }
614}
615
616#[derive(Debug, PartialEq, Clone, Copy)]
617enum FieldKind {
618 I32,
619 I64,
620 F64,
621 Decimal,
622 Bool,
623 String,
624 Email,
629 Phone,
631 Choice,
637 DateTime,
638 Date,
639 Time,
640 Uuid,
641 DateTimeAuto,
642 OptionalString,
643 OptionalI64,
644 OptionalDateTime,
645 FilePath,
651 OptionalFilePath,
653}
654
655impl FieldKind {
656 fn field_type_ident(&self) -> proc_macro2::Ident {
657 match self {
658 FieldKind::I32 => format_ident!("I32"),
659 FieldKind::I64 => format_ident!("I64"),
660 FieldKind::F64 => format_ident!("F64"),
661 FieldKind::Decimal => format_ident!("Decimal"),
662 FieldKind::Bool => format_ident!("Bool"),
663 FieldKind::String => format_ident!("String"),
664 FieldKind::Email => format_ident!("Email"),
665 FieldKind::Phone => format_ident!("Phone"),
666 FieldKind::Choice => format_ident!("String"),
669 FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
670 FieldKind::Date => format_ident!("Date"),
671 FieldKind::Time => format_ident!("Time"),
672 FieldKind::Uuid => format_ident!("Uuid"),
673 FieldKind::OptionalString => format_ident!("OptionalString"),
674 FieldKind::OptionalI64 => format_ident!("OptionalI64"),
675 FieldKind::OptionalDateTime => format_ident!("OptionalDateTime"),
676 FieldKind::FilePath => format_ident!("FilePath"),
677 FieldKind::OptionalFilePath => format_ident!("OptionalFilePath"),
678 }
679 }
680}
681
682fn is_auto_timestamp_name(name: &str) -> bool {
688 matches!(name, "created_at" | "updated_at")
689}
690
691fn humanise_field(s: &str) -> String {
703 if s.is_empty() {
704 return String::new();
705 }
706 let mut out = String::with_capacity(s.len());
707 let mut first_segment = true;
708 for segment in s.split('_') {
709 if !first_segment {
710 out.push(' ');
711 }
712 first_segment = false;
713 let lower = segment.to_ascii_lowercase();
714 if HUMANISE_ACRONYMS.contains(&lower.as_str()) {
715 out.push_str(&lower.to_ascii_uppercase());
716 } else {
717 let mut chars = segment.chars();
718 if let Some(first) = chars.next() {
719 out.push(first.to_ascii_uppercase());
720 for c in chars {
721 out.push(c);
722 }
723 }
724 }
725 }
726 out
727}
728
729const HUMANISE_ACRONYMS: &[&str] = &[
737 "id", "ip", "url", "uri", "api", "uuid", "mfa", "csv", "sql", "html", "http", "https", "json",
738 "tls", "ssl", "smtp", "xml",
739];
740
741fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
742 let as_string = quote! { #ty }.to_string().replace(' ', "");
743 let kind = match as_string.as_str() {
744 "i32" => FieldKind::I32,
745 "i64" => FieldKind::I64,
746 "f64" => FieldKind::F64,
747 "Decimal" | "rust_decimal::Decimal" => FieldKind::Decimal,
748 "bool" => FieldKind::Bool,
749 "String" => FieldKind::String,
750 "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
751 "NaiveDate" | "chrono::NaiveDate" => FieldKind::Date,
752 "NaiveTime" | "chrono::NaiveTime" => FieldKind::Time,
753 "Uuid" | "uuid::Uuid" => FieldKind::Uuid,
754 "Option<String>" => FieldKind::OptionalString,
755 "Option<i64>" => FieldKind::OptionalI64,
756 "Option<DateTime<Utc>>" | "Option<chrono::DateTime<chrono::Utc>>" => {
757 FieldKind::OptionalDateTime
758 }
759 other => {
760 return Err(syn::Error::new_spanned(
761 ty,
762 format!("unsupported field type for RustioAdmin: {other}"),
763 ))
764 }
765 };
766 Ok(kind)
767}
768
769#[derive(Default)]
788struct StructOverrides {
789 admin_name: Option<String>,
790 display_name: Option<String>,
791}
792
793fn parse_struct_attr(attrs: &[syn::Attribute]) -> syn::Result<StructOverrides> {
794 let mut out = StructOverrides::default();
795 for attr in attrs {
796 if !attr.path().is_ident("rustio") {
797 continue;
798 }
799 attr.parse_nested_meta(|m| {
800 if m.path.is_ident("admin_name") {
801 let value = m.value()?;
802 let lit: Lit = value.parse()?;
803 if let Lit::Str(s) = lit {
804 out.admin_name = Some(s.value());
805 }
806 Ok(())
807 } else if m.path.is_ident("display_name") {
808 let value = m.value()?;
809 let lit: Lit = value.parse()?;
810 if let Lit::Str(s) = lit {
811 out.display_name = Some(s.value());
812 }
813 Ok(())
814 } else {
815 Err(m.error(
822 "unknown rustio struct attribute; expected `admin_name` or `display_name`",
823 ))
824 }
825 })?;
826 }
827 Ok(out)
828}
829
830fn parse_relation_attr(
831 attrs: &[syn::Attribute],
832 field_name: &str,
833) -> syn::Result<Option<(String, Option<String>)>> {
834 for attr in attrs {
835 if !attr.path().is_ident("rustio") {
836 continue;
837 }
838 let mut target: Option<String> = None;
839 let mut display: Option<String> = None;
840 attr.parse_nested_meta(|m| {
841 if m.path.is_ident("belongs_to") {
842 let value = m.value()?;
843 let lit: Lit = value.parse()?;
844 if let Lit::Str(s) = lit {
845 target = Some(s.value());
846 }
847 Ok(())
848 } else if m.path.is_ident("display") {
849 let value = m.value()?;
850 let lit: Lit = value.parse()?;
851 if let Lit::Str(s) = lit {
852 display = Some(s.value());
853 }
854 Ok(())
855 } else if m.path.is_ident("file") {
856 Ok(())
861 } else if m.path.is_ident("format") || m.path.is_ident("choices") {
862 let _: syn::Expr = m.value()?.parse()?;
867 Ok(())
868 } else {
869 Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
870 }
871 })?;
872 if let Some(t) = target {
873 return Ok(Some((t, display)));
874 }
875 if display.is_some() {
876 return Err(syn::Error::new_spanned(
877 attr,
878 "`display` requires `belongs_to` alongside it",
879 ));
880 }
881 }
882 let _ = std::marker::PhantomData::<Meta>;
884 Ok(None)
885}
886
887fn parse_file_attr(attrs: &[syn::Attribute]) -> syn::Result<bool> {
895 for attr in attrs {
896 if !attr.path().is_ident("rustio") {
897 continue;
898 }
899 let mut found = false;
900 attr.parse_nested_meta(|m| {
901 if m.path.is_ident("file") {
902 found = true;
903 Ok(())
904 } else if m.input.peek(syn::Token![=]) {
905 let _: syn::Expr = m.value()?.parse()?;
912 Ok(())
913 } else {
914 Ok(())
916 }
917 })?;
918 if found {
919 return Ok(true);
920 }
921 }
922 Ok(false)
923}
924
925fn parse_format_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<String>> {
932 for attr in attrs {
933 if !attr.path().is_ident("rustio") {
934 continue;
935 }
936 let mut found: Option<String> = None;
937 attr.parse_nested_meta(|m| {
938 if m.path.is_ident("format") {
939 let value = m.value()?;
940 let lit: syn::LitStr = value.parse()?;
941 let v = lit.value();
942 if v != "email" && v != "phone" {
943 return Err(m.error(format!(
944 "#[rustio(format = \"...\")] accepts only \"email\" or \"phone\"; got \"{v}\""
945 )));
946 }
947 found = Some(v);
948 Ok(())
949 } else if m.input.peek(syn::Token![=]) {
950 let _: syn::Expr = m.value()?.parse()?;
954 Ok(())
955 } else {
956 Ok(())
958 }
959 })?;
960 if found.is_some() {
961 return Ok(found);
962 }
963 }
964 Ok(None)
965}
966
967fn parse_choices_attr(attrs: &[syn::Attribute]) -> syn::Result<Option<Vec<String>>> {
973 for attr in attrs {
974 if !attr.path().is_ident("rustio") {
975 continue;
976 }
977 let mut found: Option<Vec<String>> = None;
978 attr.parse_nested_meta(|m| {
979 if m.path.is_ident("choices") {
980 let array: syn::ExprArray = m.value()?.parse()?;
981 let mut values = Vec::with_capacity(array.elems.len());
982 for elem in &array.elems {
983 match elem {
984 syn::Expr::Lit(syn::ExprLit {
985 lit: Lit::Str(s), ..
986 }) => values.push(s.value()),
987 other => {
988 return Err(syn::Error::new_spanned(
989 other,
990 "#[rustio(choices = [...])] elements must be string literals",
991 ));
992 }
993 }
994 }
995 if values.is_empty() {
996 return Err(m.error("#[rustio(choices = [...])] needs at least one value"));
997 }
998 found = Some(values);
999 Ok(())
1000 } else if m.input.peek(syn::Token![=]) {
1001 let _: syn::Expr = m.value()?.parse()?;
1002 Ok(())
1003 } else {
1004 Ok(())
1005 }
1006 })?;
1007 if found.is_some() {
1008 return Ok(found);
1009 }
1010 }
1011 Ok(None)
1012}
1013
1014fn plural_snake(camel: &str) -> String {
1015 let snake = camel_to_snake(camel);
1016 if snake.ends_with('s') {
1019 snake
1023 } else if snake.ends_with('x')
1024 || snake.ends_with('z')
1025 || snake.ends_with("ch")
1026 || snake.ends_with("sh")
1027 {
1028 format!("{snake}es")
1029 } else if let Some(stem) = snake.strip_suffix('y') {
1030 let before = stem.chars().last();
1033 if matches!(before, Some('a' | 'e' | 'i' | 'o' | 'u')) || stem.is_empty() {
1034 format!("{snake}s")
1035 } else {
1036 format!("{stem}ies")
1037 }
1038 } else {
1039 format!("{snake}s")
1040 }
1041}
1042
1043fn camel_to_snake(s: &str) -> String {
1044 let mut out = String::new();
1045 for (i, c) in s.chars().enumerate() {
1046 if c.is_ascii_uppercase() && i > 0 {
1047 out.push('_');
1048 }
1049 out.push(c.to_ascii_lowercase());
1050 }
1051 out
1052}
1053
1054fn humanise(snake: &str) -> String {
1055 let mut chars = snake.chars();
1057 let mut out = String::new();
1058 if let Some(first) = chars.next() {
1059 out.push(first.to_ascii_uppercase());
1060 }
1061 for c in chars {
1062 if c == '_' {
1063 out.push(' ');
1064 } else {
1065 out.push(c);
1066 }
1067 }
1068 out
1069}
1070
1071fn find_label_field(
1072 fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
1073) -> Option<String> {
1074 let names = ["name", "title", "full_name", "label", "email"];
1078 for candidate in names {
1079 if fields
1080 .iter()
1081 .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
1082 {
1083 return Some(candidate.to_string());
1084 }
1085 }
1086 None
1087}
1088
1089#[cfg(test)]
1090mod plural_snake_tests {
1091 use super::plural_snake;
1092
1093 #[test]
1094 fn regular_plurals() {
1095 assert_eq!(plural_snake("Post"), "posts");
1096 assert_eq!(plural_snake("Loan"), "loans");
1097 assert_eq!(plural_snake("BlogPost"), "blog_posts");
1098 assert_eq!(plural_snake("CaseAction"), "case_actions");
1099 }
1100
1101 #[test]
1102 fn ch_sh_x_z_suffixes_take_es() {
1103 assert_eq!(plural_snake("Branch"), "branches");
1104 assert_eq!(plural_snake("Box"), "boxes");
1105 assert_eq!(plural_snake("Dish"), "dishes");
1106 assert_eq!(plural_snake("Buzz"), "buzzes");
1107 }
1108
1109 #[test]
1110 fn consonant_y_becomes_ies_vowel_y_keeps_s() {
1111 assert_eq!(plural_snake("Category"), "categories");
1112 assert_eq!(plural_snake("Story"), "stories");
1113 assert_eq!(plural_snake("Toy"), "toys");
1114 assert_eq!(plural_snake("Day"), "days");
1115 }
1116
1117 #[test]
1118 fn trailing_s_left_alone() {
1119 assert_eq!(plural_snake("Posts"), "posts");
1120 assert_eq!(plural_snake("Status"), "status");
1121 }
1122}
1123
1124#[cfg(test)]
1125mod humanise_field_tests {
1126 use super::humanise_field;
1127
1128 #[test]
1129 fn snake_case_to_title_case() {
1130 assert_eq!(humanise_field("title"), "Title");
1131 assert_eq!(humanise_field("chart_number"), "Chart Number");
1132 assert_eq!(humanise_field("full_name"), "Full Name");
1133 assert_eq!(
1134 humanise_field("performed_by_technician"),
1135 "Performed By Technician"
1136 );
1137 }
1138
1139 #[test]
1140 fn standalone_acronyms_are_uppercased() {
1141 assert_eq!(humanise_field("id"), "ID");
1143 assert_eq!(humanise_field("ip"), "IP");
1144 assert_eq!(humanise_field("url"), "URL");
1145 assert_eq!(humanise_field("uuid"), "UUID");
1146 assert_eq!(humanise_field("mfa"), "MFA");
1147 }
1148
1149 #[test]
1150 fn acronyms_inside_compound_names_are_uppercased() {
1151 assert_eq!(humanise_field("email_id"), "Email ID");
1152 assert_eq!(humanise_field("id_card"), "ID Card");
1153 assert_eq!(humanise_field("user_ip"), "User IP");
1154 assert_eq!(humanise_field("api_token"), "API Token");
1155 assert_eq!(humanise_field("mfa_secret_key_id"), "MFA Secret Key ID");
1156 assert_eq!(humanise_field("csv_export_path"), "CSV Export Path");
1157 }
1158
1159 #[test]
1160 fn acronym_substrings_are_not_uppercased() {
1161 assert_eq!(humanise_field("video"), "Video");
1165 assert_eq!(humanise_field("video_url"), "Video URL");
1166 assert_eq!(humanise_field("hidden_field"), "Hidden Field");
1167 assert_eq!(humanise_field("idle_seconds"), "Idle Seconds");
1168 }
1169
1170 #[test]
1171 fn empty_and_trivial_inputs_are_safe() {
1172 assert_eq!(humanise_field(""), "");
1173 assert_eq!(humanise_field("a"), "A");
1174 }
1175
1176 #[test]
1177 fn datetime_suffixes_preserved() {
1178 assert_eq!(humanise_field("created_at"), "Created At");
1181 assert_eq!(humanise_field("revoked_by"), "Revoked By");
1182 assert_eq!(humanise_field("expires_at"), "Expires At");
1183 }
1184}