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 editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
71
72 let type_variant = kind.field_type_ident();
73 let relation = parse_relation_attr(&f.attrs, &fname_str)?;
74 let relation_tokens = match &relation {
75 Some((target, display)) => {
76 let display_tok = match display {
77 Some(d) => quote! { ::std::option::Option::Some(#d) },
78 None => quote! { ::std::option::Option::None },
79 };
80 quote! {
81 ::std::option::Option::Some(::rustio_admin::admin::AdminRelation {
82 target_model: #target,
83 display_field: #display_tok,
84 multi: false,
91 })
92 }
93 }
94 None => quote! { ::std::option::Option::None },
95 };
96
97 field_metas.push(quote! {
98 ::rustio_admin::admin::AdminField {
99 name: #fname_str,
100 label: #fname_str,
101 field_type: ::rustio_admin::admin::FieldType::#type_variant,
102 editable: #editable,
103 relation: #relation_tokens,
104 choices: ::std::option::Option::None,
110 }
111 });
112
113 let display_arm = match kind {
115 FieldKind::String => quote! {
116 out.push((#fname_str.to_string(), self.#fname.clone()));
117 },
118 FieldKind::OptionalString => quote! {
119 out.push((#fname_str.to_string(), match &self.#fname {
123 Some(v) => v.clone(),
124 None => String::new(),
125 }));
126 },
127 FieldKind::I32 | FieldKind::I64 => quote! {
128 out.push((#fname_str.to_string(), self.#fname.to_string()));
129 },
130 FieldKind::OptionalI64 => quote! {
131 out.push((#fname_str.to_string(), match &self.#fname {
132 Some(v) => v.to_string(),
133 None => String::new(),
134 }));
135 },
136 FieldKind::Bool => quote! {
137 out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
138 },
139 FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
140 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
149 },
150 FieldKind::OptionalDateTime => quote! {
151 out.push((#fname_str.to_string(), match &self.#fname {
155 Some(v) => v.format("%Y-%m-%dT%H:%M").to_string(),
156 None => String::new(),
157 }));
158 },
159 };
160 display_value_arms.push(display_arm);
161
162 if fname_str == "id" {
164 from_form_fields.push(quote! { #fname: 0 });
165 continue;
166 }
167
168 let humanised_label = humanise_field(&fname_str);
173 let required_msg = format!("{humanised_label} is required.");
174 let number_msg = format!("{humanised_label} must be a number.");
175 let date_invalid_msg = format!("{humanised_label} is not a valid date.");
176
177 match kind {
178 FieldKind::String => {
179 from_form_parses.push(quote! {
184 let #fname = match form.get(#fname_str).map(str::trim) {
185 Some(v) if !v.is_empty() => v.to_string(),
186 _ => { errors.push(#required_msg.to_string()); String::new() }
187 };
188 });
189 from_form_fields.push(quote! { #fname });
190 }
191 FieldKind::OptionalString => {
192 from_form_parses.push(quote! {
195 let #fname: Option<String> = form
196 .get(#fname_str)
197 .map(|s| s.trim().to_string())
198 .filter(|s| !s.is_empty());
199 });
200 from_form_fields.push(quote! { #fname });
201 }
202 FieldKind::I32 => {
203 from_form_parses.push(quote! {
204 let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
205 Some(v) => v,
206 None => { errors.push(#number_msg.to_string()); 0 }
207 };
208 });
209 from_form_fields.push(quote! { #fname });
210 }
211 FieldKind::I64 => {
212 from_form_parses.push(quote! {
213 let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
214 Some(v) => v,
215 None => { errors.push(#number_msg.to_string()); 0 }
216 };
217 });
218 from_form_fields.push(quote! { #fname });
219 }
220 FieldKind::OptionalI64 => {
221 from_form_parses.push(quote! {
225 let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
226 None | Some("") => None,
227 Some(raw) => match raw.parse::<i64>() {
228 Ok(n) => Some(n),
229 Err(_) => {
230 errors.push(#number_msg.to_string());
231 None
232 }
233 },
234 };
235 });
236 from_form_fields.push(quote! { #fname });
237 }
238 FieldKind::Bool => {
239 from_form_parses.push(quote! {
240 let #fname: bool = form.bool_flag(#fname_str);
241 });
242 from_form_fields.push(quote! { #fname });
243 }
244 FieldKind::DateTime => {
245 from_form_parses.push(quote! {
246 let #fname = match form.get(#fname_str) {
247 Some(raw) if !raw.is_empty() => {
248 match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
249 Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
250 Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
251 }
252 }
253 _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
254 };
255 });
256 from_form_fields.push(quote! { #fname });
257 }
258 FieldKind::DateTimeAuto => {
259 from_form_parses.push(quote! {
261 let #fname = ::chrono::Utc::now();
262 });
263 from_form_fields.push(quote! { #fname });
264 }
265 FieldKind::OptionalDateTime => {
266 from_form_parses.push(quote! {
270 let #fname: ::std::option::Option<::chrono::DateTime<::chrono::Utc>> =
271 match form.get(#fname_str).map(str::trim) {
272 None | Some("") => ::std::option::Option::None,
273 Some(raw) => match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
274 Ok(dt) => ::std::option::Option::Some(
275 ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
276 ),
277 Err(_) => {
278 errors.push(#date_invalid_msg.to_string());
279 ::std::option::Option::None
280 }
281 },
282 };
283 });
284 from_form_fields.push(quote! { #fname });
285 }
286 }
287
288 update_tuples.push(quote! {
289 (#fname_str, self.#fname.clone().into())
290 });
291 }
292
293 let object_label_expr = find_label_field(fields)
294 .map(|n| {
295 let id = format_ident!("{n}");
296 quote! { self.#id.clone().to_string() }
297 })
298 .unwrap_or_else(|| quote! { format!("#{}", self.id) });
299
300 Ok(quote! {
301 impl ::rustio_admin::admin::AdminModel for #struct_name {
302 const ADMIN_NAME: &'static str = #admin_name;
303 const DISPLAY_NAME: &'static str = #display_name;
304 const SINGULAR_NAME: &'static str = #singular;
305 const FIELDS: &'static [::rustio_admin::admin::AdminField] = &[
306 #(#field_metas),*
307 ];
308
309 fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
310 let mut out = ::std::vec::Vec::new();
311 #(#display_value_arms)*
312 out
313 }
314
315 fn from_form(form: &::rustio_admin::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
316 where
317 Self: Sized,
318 {
319 let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
320 #(#from_form_parses)*
321 if !errors.is_empty() {
322 return Err(errors);
323 }
324 Ok(Self { #(#from_form_fields),* })
325 }
326
327 fn object_label(&self) -> ::std::string::String {
328 #object_label_expr
329 }
330
331 fn id(&self) -> i64 {
332 self.id
333 }
334
335 fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_admin::orm::Value)> {
336 ::std::vec![#(#update_tuples),*]
337 }
338 }
339 })
340}
341
342fn struct_fields(
343 input: &DeriveInput,
344) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
345 let data = match &input.data {
346 Data::Struct(s) => s,
347 _ => {
348 return Err(syn::Error::new_spanned(
349 &input.ident,
350 "RustioAdmin can only derive on structs",
351 ))
352 }
353 };
354 match &data.fields {
355 Fields::Named(named) => Ok(&named.named),
356 _ => Err(syn::Error::new_spanned(
357 &input.ident,
358 "RustioAdmin requires a struct with named fields",
359 )),
360 }
361}
362
363#[derive(Debug, PartialEq, Clone, Copy)]
364enum FieldKind {
365 I32,
366 I64,
367 Bool,
368 String,
369 DateTime,
370 DateTimeAuto,
371 OptionalString,
372 OptionalI64,
373 OptionalDateTime,
374}
375
376impl FieldKind {
377 fn field_type_ident(&self) -> proc_macro2::Ident {
378 match self {
379 FieldKind::I32 => format_ident!("I32"),
380 FieldKind::I64 => format_ident!("I64"),
381 FieldKind::Bool => format_ident!("Bool"),
382 FieldKind::String => format_ident!("String"),
383 FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
384 FieldKind::OptionalString => format_ident!("OptionalString"),
385 FieldKind::OptionalI64 => format_ident!("OptionalI64"),
386 FieldKind::OptionalDateTime => format_ident!("OptionalDateTime"),
387 }
388 }
389}
390
391fn is_auto_timestamp_name(name: &str) -> bool {
397 matches!(name, "created_at" | "updated_at")
398}
399
400fn humanise_field(s: &str) -> String {
405 let mut out = String::with_capacity(s.len());
406 let mut next_upper = true;
407 for ch in s.chars() {
408 if ch == '_' {
409 out.push(' ');
410 next_upper = true;
411 } else if next_upper {
412 out.push(ch.to_ascii_uppercase());
413 next_upper = false;
414 } else {
415 out.push(ch);
416 }
417 }
418 out
419}
420
421fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
422 let as_string = quote! { #ty }.to_string().replace(' ', "");
423 let kind = match as_string.as_str() {
424 "i32" => FieldKind::I32,
425 "i64" => FieldKind::I64,
426 "bool" => FieldKind::Bool,
427 "String" => FieldKind::String,
428 "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
429 "Option<String>" => FieldKind::OptionalString,
430 "Option<i64>" => FieldKind::OptionalI64,
431 "Option<DateTime<Utc>>" | "Option<chrono::DateTime<chrono::Utc>>" => {
432 FieldKind::OptionalDateTime
433 }
434 other => {
435 return Err(syn::Error::new_spanned(
436 ty,
437 format!("unsupported field type for RustioAdmin: {other}"),
438 ))
439 }
440 };
441 Ok(kind)
442}
443
444#[derive(Default)]
463struct StructOverrides {
464 admin_name: Option<String>,
465 display_name: Option<String>,
466}
467
468fn parse_struct_attr(attrs: &[syn::Attribute]) -> syn::Result<StructOverrides> {
469 let mut out = StructOverrides::default();
470 for attr in attrs {
471 if !attr.path().is_ident("rustio") {
472 continue;
473 }
474 attr.parse_nested_meta(|m| {
475 if m.path.is_ident("admin_name") {
476 let value = m.value()?;
477 let lit: Lit = value.parse()?;
478 if let Lit::Str(s) = lit {
479 out.admin_name = Some(s.value());
480 }
481 Ok(())
482 } else if m.path.is_ident("display_name") {
483 let value = m.value()?;
484 let lit: Lit = value.parse()?;
485 if let Lit::Str(s) = lit {
486 out.display_name = Some(s.value());
487 }
488 Ok(())
489 } else {
490 Err(m.error(
497 "unknown rustio struct attribute; expected `admin_name` or `display_name`",
498 ))
499 }
500 })?;
501 }
502 Ok(out)
503}
504
505fn parse_relation_attr(
506 attrs: &[syn::Attribute],
507 field_name: &str,
508) -> syn::Result<Option<(String, Option<String>)>> {
509 for attr in attrs {
510 if !attr.path().is_ident("rustio") {
511 continue;
512 }
513 let mut target: Option<String> = None;
514 let mut display: Option<String> = None;
515 attr.parse_nested_meta(|m| {
516 if m.path.is_ident("belongs_to") {
517 let value = m.value()?;
518 let lit: Lit = value.parse()?;
519 if let Lit::Str(s) = lit {
520 target = Some(s.value());
521 }
522 Ok(())
523 } else if m.path.is_ident("display") {
524 let value = m.value()?;
525 let lit: Lit = value.parse()?;
526 if let Lit::Str(s) = lit {
527 display = Some(s.value());
528 }
529 Ok(())
530 } else {
531 Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
532 }
533 })?;
534 if let Some(t) = target {
535 return Ok(Some((t, display)));
536 }
537 if display.is_some() {
538 return Err(syn::Error::new_spanned(
539 attr,
540 "`display` requires `belongs_to` alongside it",
541 ));
542 }
543 }
544 let _ = std::marker::PhantomData::<Meta>;
546 Ok(None)
547}
548
549fn plural_snake(camel: &str) -> String {
550 let snake = camel_to_snake(camel);
551 if snake.ends_with('s') {
554 snake
558 } else if snake.ends_with('x')
559 || snake.ends_with('z')
560 || snake.ends_with("ch")
561 || snake.ends_with("sh")
562 {
563 format!("{snake}es")
564 } else if let Some(stem) = snake.strip_suffix('y') {
565 let before = stem.chars().last();
568 if matches!(before, Some('a' | 'e' | 'i' | 'o' | 'u')) || stem.is_empty() {
569 format!("{snake}s")
570 } else {
571 format!("{stem}ies")
572 }
573 } else {
574 format!("{snake}s")
575 }
576}
577
578#[cfg(test)]
579mod plural_snake_tests {
580 use super::plural_snake;
581
582 #[test]
583 fn regular_plurals() {
584 assert_eq!(plural_snake("Post"), "posts");
585 assert_eq!(plural_snake("Loan"), "loans");
586 assert_eq!(plural_snake("BlogPost"), "blog_posts");
587 assert_eq!(plural_snake("CaseAction"), "case_actions");
588 }
589
590 #[test]
591 fn ch_sh_x_z_suffixes_take_es() {
592 assert_eq!(plural_snake("Branch"), "branches");
593 assert_eq!(plural_snake("Box"), "boxes");
594 assert_eq!(plural_snake("Dish"), "dishes");
595 assert_eq!(plural_snake("Buzz"), "buzzes");
596 }
597
598 #[test]
599 fn consonant_y_becomes_ies_vowel_y_keeps_s() {
600 assert_eq!(plural_snake("Category"), "categories");
601 assert_eq!(plural_snake("Story"), "stories");
602 assert_eq!(plural_snake("Toy"), "toys");
603 assert_eq!(plural_snake("Day"), "days");
604 }
605
606 #[test]
607 fn trailing_s_left_alone() {
608 assert_eq!(plural_snake("Posts"), "posts");
609 assert_eq!(plural_snake("Status"), "status");
610 }
611}
612
613fn camel_to_snake(s: &str) -> String {
614 let mut out = String::new();
615 for (i, c) in s.chars().enumerate() {
616 if c.is_ascii_uppercase() && i > 0 {
617 out.push('_');
618 }
619 out.push(c.to_ascii_lowercase());
620 }
621 out
622}
623
624fn humanise(snake: &str) -> String {
625 let mut chars = snake.chars();
627 let mut out = String::new();
628 if let Some(first) = chars.next() {
629 out.push(first.to_ascii_uppercase());
630 }
631 for c in chars {
632 if c == '_' {
633 out.push(' ');
634 } else {
635 out.push(c);
636 }
637 }
638 out
639}
640
641fn find_label_field(
642 fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
643) -> Option<String> {
644 let names = ["name", "title", "full_name", "label", "email"];
648 for candidate in names {
649 if fields
650 .iter()
651 .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
652 {
653 return Some(candidate.to_string());
654 }
655 }
656 None
657}