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 editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
92
93 let type_variant = kind.field_type_ident();
94 let relation = parse_relation_attr(&f.attrs, &fname_str)?;
95 let relation_tokens = match &relation {
96 Some((target, display)) => {
97 let display_tok = match display {
98 Some(d) => quote! { ::std::option::Option::Some(#d) },
99 None => quote! { ::std::option::Option::None },
100 };
101 quote! {
102 ::std::option::Option::Some(::rustio_admin::admin::AdminRelation {
103 target_model: #target,
104 display_field: #display_tok,
105 multi: false,
112 })
113 }
114 }
115 None => quote! { ::std::option::Option::None },
116 };
117
118 let humanised_label = humanise_field(&fname_str);
125 field_metas.push(quote! {
126 ::rustio_admin::admin::AdminField {
127 name: #fname_str,
128 label: #humanised_label,
129 field_type: ::rustio_admin::admin::FieldType::#type_variant,
130 editable: #editable,
131 relation: #relation_tokens,
132 choices: ::std::option::Option::None,
138 }
139 });
140
141 let display_arm = match kind {
143 FieldKind::String | FieldKind::FilePath => quote! {
150 out.push((#fname_str.to_string(), self.#fname.clone()));
151 },
152 FieldKind::OptionalString | FieldKind::OptionalFilePath => quote! {
153 out.push((#fname_str.to_string(), match &self.#fname {
157 Some(v) => v.clone(),
158 None => String::new(),
159 }));
160 },
161 FieldKind::I32 | FieldKind::I64 => quote! {
162 out.push((#fname_str.to_string(), self.#fname.to_string()));
163 },
164 FieldKind::OptionalI64 => quote! {
165 out.push((#fname_str.to_string(), match &self.#fname {
166 Some(v) => v.to_string(),
167 None => String::new(),
168 }));
169 },
170 FieldKind::Bool => quote! {
171 out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
172 },
173 FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
174 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
183 },
184 FieldKind::OptionalDateTime => quote! {
185 out.push((#fname_str.to_string(), match &self.#fname {
189 Some(v) => v.format("%Y-%m-%dT%H:%M").to_string(),
190 None => String::new(),
191 }));
192 },
193 };
194 display_value_arms.push(display_arm);
195
196 if fname_str == "id" {
198 from_form_fields.push(quote! { #fname: 0 });
199 continue;
200 }
201
202 let required_msg = format!("{humanised_label} is required.");
208 let number_msg = format!("{humanised_label} must be a number.");
209 let date_invalid_msg = format!("{humanised_label} is not a valid date.");
210
211 match kind {
212 FieldKind::String | FieldKind::FilePath => {
213 from_form_parses.push(quote! {
221 let #fname = match form.get(#fname_str).map(str::trim) {
222 Some(v) if !v.is_empty() => v.to_string(),
223 _ => { errors.push(#required_msg.to_string()); String::new() }
224 };
225 });
226 from_form_fields.push(quote! { #fname });
227 }
228 FieldKind::OptionalString | FieldKind::OptionalFilePath => {
229 from_form_parses.push(quote! {
235 let #fname: Option<String> = form
236 .get(#fname_str)
237 .map(|s| s.trim().to_string())
238 .filter(|s| !s.is_empty());
239 });
240 from_form_fields.push(quote! { #fname });
241 }
242 FieldKind::I32 => {
243 from_form_parses.push(quote! {
244 let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
245 Some(v) => v,
246 None => { errors.push(#number_msg.to_string()); 0 }
247 };
248 });
249 from_form_fields.push(quote! { #fname });
250 }
251 FieldKind::I64 => {
252 from_form_parses.push(quote! {
253 let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
254 Some(v) => v,
255 None => { errors.push(#number_msg.to_string()); 0 }
256 };
257 });
258 from_form_fields.push(quote! { #fname });
259 }
260 FieldKind::OptionalI64 => {
261 from_form_parses.push(quote! {
265 let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
266 None | Some("") => None,
267 Some(raw) => match raw.parse::<i64>() {
268 Ok(n) => Some(n),
269 Err(_) => {
270 errors.push(#number_msg.to_string());
271 None
272 }
273 },
274 };
275 });
276 from_form_fields.push(quote! { #fname });
277 }
278 FieldKind::Bool => {
279 from_form_parses.push(quote! {
280 let #fname: bool = form.bool_flag(#fname_str);
281 });
282 from_form_fields.push(quote! { #fname });
283 }
284 FieldKind::DateTime => {
285 from_form_parses.push(quote! {
286 let #fname = match form.get(#fname_str) {
287 Some(raw) if !raw.is_empty() => {
288 match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
289 Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
290 Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
291 }
292 }
293 _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
294 };
295 });
296 from_form_fields.push(quote! { #fname });
297 }
298 FieldKind::DateTimeAuto => {
299 from_form_parses.push(quote! {
301 let #fname = ::chrono::Utc::now();
302 });
303 from_form_fields.push(quote! { #fname });
304 }
305 FieldKind::OptionalDateTime => {
306 from_form_parses.push(quote! {
310 let #fname: ::std::option::Option<::chrono::DateTime<::chrono::Utc>> =
311 match form.get(#fname_str).map(str::trim) {
312 None | Some("") => ::std::option::Option::None,
313 Some(raw) => match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
314 Ok(dt) => ::std::option::Option::Some(
315 ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
316 ),
317 Err(_) => {
318 errors.push(#date_invalid_msg.to_string());
319 ::std::option::Option::None
320 }
321 },
322 };
323 });
324 from_form_fields.push(quote! { #fname });
325 }
326 }
327
328 update_tuples.push(quote! {
329 (#fname_str, self.#fname.clone().into())
330 });
331 }
332
333 let object_label_expr = find_label_field(fields)
334 .map(|n| {
335 let id = format_ident!("{n}");
336 quote! { self.#id.clone().to_string() }
337 })
338 .unwrap_or_else(|| quote! { format!("#{}", self.id) });
339
340 Ok(quote! {
341 impl ::rustio_admin::admin::AdminModel for #struct_name {
342 const ADMIN_NAME: &'static str = #admin_name;
343 const DISPLAY_NAME: &'static str = #display_name;
344 const SINGULAR_NAME: &'static str = #singular;
345 const FIELDS: &'static [::rustio_admin::admin::AdminField] = &[
346 #(#field_metas),*
347 ];
348
349 fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
350 let mut out = ::std::vec::Vec::new();
351 #(#display_value_arms)*
352 out
353 }
354
355 fn from_form(form: &::rustio_admin::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
356 where
357 Self: Sized,
358 {
359 let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
360 #(#from_form_parses)*
361 if !errors.is_empty() {
362 return Err(errors);
363 }
364 Ok(Self { #(#from_form_fields),* })
365 }
366
367 fn object_label(&self) -> ::std::string::String {
368 #object_label_expr
369 }
370
371 fn id(&self) -> i64 {
372 self.id
373 }
374
375 fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_admin::orm::Value)> {
376 ::std::vec![#(#update_tuples),*]
377 }
378 }
379 })
380}
381
382fn struct_fields(
383 input: &DeriveInput,
384) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
385 let data = match &input.data {
386 Data::Struct(s) => s,
387 _ => {
388 return Err(syn::Error::new_spanned(
389 &input.ident,
390 "RustioAdmin can only derive on structs",
391 ))
392 }
393 };
394 match &data.fields {
395 Fields::Named(named) => Ok(&named.named),
396 _ => Err(syn::Error::new_spanned(
397 &input.ident,
398 "RustioAdmin requires a struct with named fields",
399 )),
400 }
401}
402
403#[derive(Debug, PartialEq, Clone, Copy)]
404enum FieldKind {
405 I32,
406 I64,
407 Bool,
408 String,
409 DateTime,
410 DateTimeAuto,
411 OptionalString,
412 OptionalI64,
413 OptionalDateTime,
414 FilePath,
420 OptionalFilePath,
422}
423
424impl FieldKind {
425 fn field_type_ident(&self) -> proc_macro2::Ident {
426 match self {
427 FieldKind::I32 => format_ident!("I32"),
428 FieldKind::I64 => format_ident!("I64"),
429 FieldKind::Bool => format_ident!("Bool"),
430 FieldKind::String => format_ident!("String"),
431 FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
432 FieldKind::OptionalString => format_ident!("OptionalString"),
433 FieldKind::OptionalI64 => format_ident!("OptionalI64"),
434 FieldKind::OptionalDateTime => format_ident!("OptionalDateTime"),
435 FieldKind::FilePath => format_ident!("FilePath"),
436 FieldKind::OptionalFilePath => format_ident!("OptionalFilePath"),
437 }
438 }
439}
440
441fn is_auto_timestamp_name(name: &str) -> bool {
447 matches!(name, "created_at" | "updated_at")
448}
449
450fn humanise_field(s: &str) -> String {
462 if s.is_empty() {
463 return String::new();
464 }
465 let mut out = String::with_capacity(s.len());
466 let mut first_segment = true;
467 for segment in s.split('_') {
468 if !first_segment {
469 out.push(' ');
470 }
471 first_segment = false;
472 let lower = segment.to_ascii_lowercase();
473 if HUMANISE_ACRONYMS.contains(&lower.as_str()) {
474 out.push_str(&lower.to_ascii_uppercase());
475 } else {
476 let mut chars = segment.chars();
477 if let Some(first) = chars.next() {
478 out.push(first.to_ascii_uppercase());
479 for c in chars {
480 out.push(c);
481 }
482 }
483 }
484 }
485 out
486}
487
488const HUMANISE_ACRONYMS: &[&str] = &[
496 "id", "ip", "url", "uri", "api", "uuid", "mfa", "csv", "sql", "html", "http", "https", "json",
497 "tls", "ssl", "smtp", "xml",
498];
499
500fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
501 let as_string = quote! { #ty }.to_string().replace(' ', "");
502 let kind = match as_string.as_str() {
503 "i32" => FieldKind::I32,
504 "i64" => FieldKind::I64,
505 "bool" => FieldKind::Bool,
506 "String" => FieldKind::String,
507 "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
508 "Option<String>" => FieldKind::OptionalString,
509 "Option<i64>" => FieldKind::OptionalI64,
510 "Option<DateTime<Utc>>" | "Option<chrono::DateTime<chrono::Utc>>" => {
511 FieldKind::OptionalDateTime
512 }
513 other => {
514 return Err(syn::Error::new_spanned(
515 ty,
516 format!("unsupported field type for RustioAdmin: {other}"),
517 ))
518 }
519 };
520 Ok(kind)
521}
522
523#[derive(Default)]
542struct StructOverrides {
543 admin_name: Option<String>,
544 display_name: Option<String>,
545}
546
547fn parse_struct_attr(attrs: &[syn::Attribute]) -> syn::Result<StructOverrides> {
548 let mut out = StructOverrides::default();
549 for attr in attrs {
550 if !attr.path().is_ident("rustio") {
551 continue;
552 }
553 attr.parse_nested_meta(|m| {
554 if m.path.is_ident("admin_name") {
555 let value = m.value()?;
556 let lit: Lit = value.parse()?;
557 if let Lit::Str(s) = lit {
558 out.admin_name = Some(s.value());
559 }
560 Ok(())
561 } else if m.path.is_ident("display_name") {
562 let value = m.value()?;
563 let lit: Lit = value.parse()?;
564 if let Lit::Str(s) = lit {
565 out.display_name = Some(s.value());
566 }
567 Ok(())
568 } else {
569 Err(m.error(
576 "unknown rustio struct attribute; expected `admin_name` or `display_name`",
577 ))
578 }
579 })?;
580 }
581 Ok(out)
582}
583
584fn parse_relation_attr(
585 attrs: &[syn::Attribute],
586 field_name: &str,
587) -> syn::Result<Option<(String, Option<String>)>> {
588 for attr in attrs {
589 if !attr.path().is_ident("rustio") {
590 continue;
591 }
592 let mut target: Option<String> = None;
593 let mut display: Option<String> = None;
594 attr.parse_nested_meta(|m| {
595 if m.path.is_ident("belongs_to") {
596 let value = m.value()?;
597 let lit: Lit = value.parse()?;
598 if let Lit::Str(s) = lit {
599 target = Some(s.value());
600 }
601 Ok(())
602 } else if m.path.is_ident("display") {
603 let value = m.value()?;
604 let lit: Lit = value.parse()?;
605 if let Lit::Str(s) = lit {
606 display = Some(s.value());
607 }
608 Ok(())
609 } else if m.path.is_ident("file") {
610 Ok(())
615 } else {
616 Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
617 }
618 })?;
619 if let Some(t) = target {
620 return Ok(Some((t, display)));
621 }
622 if display.is_some() {
623 return Err(syn::Error::new_spanned(
624 attr,
625 "`display` requires `belongs_to` alongside it",
626 ));
627 }
628 }
629 let _ = std::marker::PhantomData::<Meta>;
631 Ok(None)
632}
633
634fn parse_file_attr(attrs: &[syn::Attribute]) -> syn::Result<bool> {
642 for attr in attrs {
643 if !attr.path().is_ident("rustio") {
644 continue;
645 }
646 let mut found = false;
647 attr.parse_nested_meta(|m| {
648 if m.path.is_ident("file") {
649 found = true;
650 Ok(())
651 } else if m.input.peek(syn::Token![=]) {
652 let _value = m.value()?;
658 let _: Lit = _value.parse()?;
659 Ok(())
660 } else {
661 Ok(())
663 }
664 })?;
665 if found {
666 return Ok(true);
667 }
668 }
669 Ok(false)
670}
671
672fn plural_snake(camel: &str) -> String {
673 let snake = camel_to_snake(camel);
674 if snake.ends_with('s') {
677 snake
681 } else if snake.ends_with('x')
682 || snake.ends_with('z')
683 || snake.ends_with("ch")
684 || snake.ends_with("sh")
685 {
686 format!("{snake}es")
687 } else if let Some(stem) = snake.strip_suffix('y') {
688 let before = stem.chars().last();
691 if matches!(before, Some('a' | 'e' | 'i' | 'o' | 'u')) || stem.is_empty() {
692 format!("{snake}s")
693 } else {
694 format!("{stem}ies")
695 }
696 } else {
697 format!("{snake}s")
698 }
699}
700
701fn camel_to_snake(s: &str) -> String {
702 let mut out = String::new();
703 for (i, c) in s.chars().enumerate() {
704 if c.is_ascii_uppercase() && i > 0 {
705 out.push('_');
706 }
707 out.push(c.to_ascii_lowercase());
708 }
709 out
710}
711
712fn humanise(snake: &str) -> String {
713 let mut chars = snake.chars();
715 let mut out = String::new();
716 if let Some(first) = chars.next() {
717 out.push(first.to_ascii_uppercase());
718 }
719 for c in chars {
720 if c == '_' {
721 out.push(' ');
722 } else {
723 out.push(c);
724 }
725 }
726 out
727}
728
729fn find_label_field(
730 fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
731) -> Option<String> {
732 let names = ["name", "title", "full_name", "label", "email"];
736 for candidate in names {
737 if fields
738 .iter()
739 .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
740 {
741 return Some(candidate.to_string());
742 }
743 }
744 None
745}
746
747#[cfg(test)]
748mod plural_snake_tests {
749 use super::plural_snake;
750
751 #[test]
752 fn regular_plurals() {
753 assert_eq!(plural_snake("Post"), "posts");
754 assert_eq!(plural_snake("Loan"), "loans");
755 assert_eq!(plural_snake("BlogPost"), "blog_posts");
756 assert_eq!(plural_snake("CaseAction"), "case_actions");
757 }
758
759 #[test]
760 fn ch_sh_x_z_suffixes_take_es() {
761 assert_eq!(plural_snake("Branch"), "branches");
762 assert_eq!(plural_snake("Box"), "boxes");
763 assert_eq!(plural_snake("Dish"), "dishes");
764 assert_eq!(plural_snake("Buzz"), "buzzes");
765 }
766
767 #[test]
768 fn consonant_y_becomes_ies_vowel_y_keeps_s() {
769 assert_eq!(plural_snake("Category"), "categories");
770 assert_eq!(plural_snake("Story"), "stories");
771 assert_eq!(plural_snake("Toy"), "toys");
772 assert_eq!(plural_snake("Day"), "days");
773 }
774
775 #[test]
776 fn trailing_s_left_alone() {
777 assert_eq!(plural_snake("Posts"), "posts");
778 assert_eq!(plural_snake("Status"), "status");
779 }
780}
781
782#[cfg(test)]
783mod humanise_field_tests {
784 use super::humanise_field;
785
786 #[test]
787 fn snake_case_to_title_case() {
788 assert_eq!(humanise_field("title"), "Title");
789 assert_eq!(humanise_field("chart_number"), "Chart Number");
790 assert_eq!(humanise_field("full_name"), "Full Name");
791 assert_eq!(
792 humanise_field("performed_by_technician"),
793 "Performed By Technician"
794 );
795 }
796
797 #[test]
798 fn standalone_acronyms_are_uppercased() {
799 assert_eq!(humanise_field("id"), "ID");
801 assert_eq!(humanise_field("ip"), "IP");
802 assert_eq!(humanise_field("url"), "URL");
803 assert_eq!(humanise_field("uuid"), "UUID");
804 assert_eq!(humanise_field("mfa"), "MFA");
805 }
806
807 #[test]
808 fn acronyms_inside_compound_names_are_uppercased() {
809 assert_eq!(humanise_field("email_id"), "Email ID");
810 assert_eq!(humanise_field("id_card"), "ID Card");
811 assert_eq!(humanise_field("user_ip"), "User IP");
812 assert_eq!(humanise_field("api_token"), "API Token");
813 assert_eq!(humanise_field("mfa_secret_key_id"), "MFA Secret Key ID");
814 assert_eq!(humanise_field("csv_export_path"), "CSV Export Path");
815 }
816
817 #[test]
818 fn acronym_substrings_are_not_uppercased() {
819 assert_eq!(humanise_field("video"), "Video");
823 assert_eq!(humanise_field("video_url"), "Video URL");
824 assert_eq!(humanise_field("hidden_field"), "Hidden Field");
825 assert_eq!(humanise_field("idle_seconds"), "Idle Seconds");
826 }
827
828 #[test]
829 fn empty_and_trivial_inputs_are_safe() {
830 assert_eq!(humanise_field(""), "");
831 assert_eq!(humanise_field("a"), "A");
832 }
833
834 #[test]
835 fn datetime_suffixes_preserved() {
836 assert_eq!(humanise_field("created_at"), "Created At");
839 assert_eq!(humanise_field("revoked_by"), "Revoked By");
840 assert_eq!(humanise_field("expires_at"), "Expires At");
841 }
842}