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))]
19pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
20 let input = parse_macro_input!(input as DeriveInput);
21 expand(input)
22 .unwrap_or_else(|e| e.to_compile_error())
23 .into()
24}
25
26fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
27 let struct_name = &input.ident;
28 let fields = struct_fields(&input)?;
29
30 let struct_overrides = parse_struct_attr(&input.attrs)?;
37
38 let admin_name = match struct_overrides.admin_name {
39 Some(ref s) => s.clone(),
40 None => plural_snake(&struct_name.to_string()),
41 };
42 let display_name = match struct_overrides.display_name {
43 Some(ref s) => s.clone(),
44 None => humanise(&plural_snake(&struct_name.to_string())),
45 };
46 let singular = struct_name.to_string();
47
48 let mut field_metas = Vec::new();
49 let mut display_value_arms = Vec::new();
50 let mut from_form_parses = Vec::new();
51 let mut from_form_fields = Vec::new();
52 let mut update_tuples = Vec::new();
53
54 for f in fields {
55 let fname = f.ident.as_ref().unwrap();
56 let fname_str = fname.to_string();
57 let kind = classify_type(&f.ty)?;
58 let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
65 FieldKind::DateTimeAuto
66 } else {
67 kind
68 };
69 let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
70
71 let type_variant = kind.field_type_ident();
72 let relation = parse_relation_attr(&f.attrs, &fname_str)?;
73 let relation_tokens = match &relation {
74 Some((target, display)) => {
75 let display_tok = match display {
76 Some(d) => quote! { ::std::option::Option::Some(#d) },
77 None => quote! { ::std::option::Option::None },
78 };
79 quote! {
80 ::std::option::Option::Some(::rustio_admin::admin::AdminRelation {
81 target_model: #target,
82 display_field: #display_tok,
83 multi: false,
90 })
91 }
92 }
93 None => quote! { ::std::option::Option::None },
94 };
95
96 field_metas.push(quote! {
97 ::rustio_admin::admin::AdminField {
98 name: #fname_str,
99 label: #fname_str,
100 field_type: ::rustio_admin::admin::FieldType::#type_variant,
101 editable: #editable,
102 relation: #relation_tokens,
103 choices: ::std::option::Option::None,
109 }
110 });
111
112 let display_arm = match kind {
114 FieldKind::String => quote! {
115 out.push((#fname_str.to_string(), self.#fname.clone()));
116 },
117 FieldKind::OptionalString => quote! {
118 out.push((#fname_str.to_string(), match &self.#fname {
122 Some(v) => v.clone(),
123 None => String::new(),
124 }));
125 },
126 FieldKind::I32 | FieldKind::I64 => quote! {
127 out.push((#fname_str.to_string(), self.#fname.to_string()));
128 },
129 FieldKind::OptionalI64 => quote! {
130 out.push((#fname_str.to_string(), match &self.#fname {
131 Some(v) => v.to_string(),
132 None => String::new(),
133 }));
134 },
135 FieldKind::Bool => quote! {
136 out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
137 },
138 FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
139 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
148 },
149 };
150 display_value_arms.push(display_arm);
151
152 if fname_str == "id" {
154 from_form_fields.push(quote! { #fname: 0 });
155 continue;
156 }
157
158 let humanised_label = humanise_field(&fname_str);
163 let required_msg = format!("{humanised_label} is required.");
164 let number_msg = format!("{humanised_label} must be a number.");
165 let date_invalid_msg = format!("{humanised_label} is not a valid date.");
166
167 match kind {
168 FieldKind::String => {
169 from_form_parses.push(quote! {
174 let #fname = match form.get(#fname_str).map(str::trim) {
175 Some(v) if !v.is_empty() => v.to_string(),
176 _ => { errors.push(#required_msg.to_string()); String::new() }
177 };
178 });
179 from_form_fields.push(quote! { #fname });
180 }
181 FieldKind::OptionalString => {
182 from_form_parses.push(quote! {
185 let #fname: Option<String> = form
186 .get(#fname_str)
187 .map(|s| s.trim().to_string())
188 .filter(|s| !s.is_empty());
189 });
190 from_form_fields.push(quote! { #fname });
191 }
192 FieldKind::I32 => {
193 from_form_parses.push(quote! {
194 let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
195 Some(v) => v,
196 None => { errors.push(#number_msg.to_string()); 0 }
197 };
198 });
199 from_form_fields.push(quote! { #fname });
200 }
201 FieldKind::I64 => {
202 from_form_parses.push(quote! {
203 let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
204 Some(v) => v,
205 None => { errors.push(#number_msg.to_string()); 0 }
206 };
207 });
208 from_form_fields.push(quote! { #fname });
209 }
210 FieldKind::OptionalI64 => {
211 from_form_parses.push(quote! {
215 let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
216 None | Some("") => None,
217 Some(raw) => match raw.parse::<i64>() {
218 Ok(n) => Some(n),
219 Err(_) => {
220 errors.push(#number_msg.to_string());
221 None
222 }
223 },
224 };
225 });
226 from_form_fields.push(quote! { #fname });
227 }
228 FieldKind::Bool => {
229 from_form_parses.push(quote! {
230 let #fname: bool = form.bool_flag(#fname_str);
231 });
232 from_form_fields.push(quote! { #fname });
233 }
234 FieldKind::DateTime => {
235 from_form_parses.push(quote! {
236 let #fname = match form.get(#fname_str) {
237 Some(raw) if !raw.is_empty() => {
238 match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
239 Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
240 Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
241 }
242 }
243 _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
244 };
245 });
246 from_form_fields.push(quote! { #fname });
247 }
248 FieldKind::DateTimeAuto => {
249 from_form_parses.push(quote! {
251 let #fname = ::chrono::Utc::now();
252 });
253 from_form_fields.push(quote! { #fname });
254 }
255 }
256
257 update_tuples.push(quote! {
258 (#fname_str, self.#fname.clone().into())
259 });
260 }
261
262 let object_label_expr = find_label_field(fields)
263 .map(|n| {
264 let id = format_ident!("{n}");
265 quote! { self.#id.clone().to_string() }
266 })
267 .unwrap_or_else(|| quote! { format!("#{}", self.id) });
268
269 Ok(quote! {
270 impl ::rustio_admin::admin::AdminModel for #struct_name {
271 const ADMIN_NAME: &'static str = #admin_name;
272 const DISPLAY_NAME: &'static str = #display_name;
273 const SINGULAR_NAME: &'static str = #singular;
274 const FIELDS: &'static [::rustio_admin::admin::AdminField] = &[
275 #(#field_metas),*
276 ];
277
278 fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
279 let mut out = ::std::vec::Vec::new();
280 #(#display_value_arms)*
281 out
282 }
283
284 fn from_form(form: &::rustio_admin::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
285 where
286 Self: Sized,
287 {
288 let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
289 #(#from_form_parses)*
290 if !errors.is_empty() {
291 return Err(errors);
292 }
293 Ok(Self { #(#from_form_fields),* })
294 }
295
296 fn object_label(&self) -> ::std::string::String {
297 #object_label_expr
298 }
299
300 fn id(&self) -> i64 {
301 self.id
302 }
303
304 fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_admin::orm::Value)> {
305 ::std::vec![#(#update_tuples),*]
306 }
307 }
308 })
309}
310
311fn struct_fields(
312 input: &DeriveInput,
313) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
314 let data = match &input.data {
315 Data::Struct(s) => s,
316 _ => {
317 return Err(syn::Error::new_spanned(
318 &input.ident,
319 "RustioAdmin can only derive on structs",
320 ))
321 }
322 };
323 match &data.fields {
324 Fields::Named(named) => Ok(&named.named),
325 _ => Err(syn::Error::new_spanned(
326 &input.ident,
327 "RustioAdmin requires a struct with named fields",
328 )),
329 }
330}
331
332#[derive(Debug, PartialEq, Clone, Copy)]
333enum FieldKind {
334 I32,
335 I64,
336 Bool,
337 String,
338 DateTime,
339 DateTimeAuto,
340 OptionalString,
341 OptionalI64,
342}
343
344impl FieldKind {
345 fn field_type_ident(&self) -> proc_macro2::Ident {
346 match self {
347 FieldKind::I32 => format_ident!("I32"),
348 FieldKind::I64 => format_ident!("I64"),
349 FieldKind::Bool => format_ident!("Bool"),
350 FieldKind::String => format_ident!("String"),
351 FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
352 FieldKind::OptionalString => format_ident!("OptionalString"),
353 FieldKind::OptionalI64 => format_ident!("OptionalI64"),
354 }
355 }
356}
357
358fn is_auto_timestamp_name(name: &str) -> bool {
364 matches!(name, "created_at" | "updated_at")
365}
366
367fn humanise_field(s: &str) -> String {
372 let mut out = String::with_capacity(s.len());
373 let mut next_upper = true;
374 for ch in s.chars() {
375 if ch == '_' {
376 out.push(' ');
377 next_upper = true;
378 } else if next_upper {
379 out.push(ch.to_ascii_uppercase());
380 next_upper = false;
381 } else {
382 out.push(ch);
383 }
384 }
385 out
386}
387
388fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
389 let as_string = quote! { #ty }.to_string().replace(' ', "");
390 let kind = match as_string.as_str() {
391 "i32" => FieldKind::I32,
392 "i64" => FieldKind::I64,
393 "bool" => FieldKind::Bool,
394 "String" => FieldKind::String,
395 "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
396 "Option<String>" => FieldKind::OptionalString,
397 "Option<i64>" => FieldKind::OptionalI64,
398 other => {
399 return Err(syn::Error::new_spanned(
400 ty,
401 format!("unsupported field type for RustioAdmin: {other}"),
402 ))
403 }
404 };
405 Ok(kind)
406}
407
408#[derive(Default)]
427struct StructOverrides {
428 admin_name: Option<String>,
429 display_name: Option<String>,
430}
431
432fn parse_struct_attr(attrs: &[syn::Attribute]) -> syn::Result<StructOverrides> {
433 let mut out = StructOverrides::default();
434 for attr in attrs {
435 if !attr.path().is_ident("rustio") {
436 continue;
437 }
438 attr.parse_nested_meta(|m| {
439 if m.path.is_ident("admin_name") {
440 let value = m.value()?;
441 let lit: Lit = value.parse()?;
442 if let Lit::Str(s) = lit {
443 out.admin_name = Some(s.value());
444 }
445 Ok(())
446 } else if m.path.is_ident("display_name") {
447 let value = m.value()?;
448 let lit: Lit = value.parse()?;
449 if let Lit::Str(s) = lit {
450 out.display_name = Some(s.value());
451 }
452 Ok(())
453 } else {
454 Err(m.error(
461 "unknown rustio struct attribute; expected `admin_name` or `display_name`",
462 ))
463 }
464 })?;
465 }
466 Ok(out)
467}
468
469fn parse_relation_attr(
470 attrs: &[syn::Attribute],
471 field_name: &str,
472) -> syn::Result<Option<(String, Option<String>)>> {
473 for attr in attrs {
474 if !attr.path().is_ident("rustio") {
475 continue;
476 }
477 let mut target: Option<String> = None;
478 let mut display: Option<String> = None;
479 attr.parse_nested_meta(|m| {
480 if m.path.is_ident("belongs_to") {
481 let value = m.value()?;
482 let lit: Lit = value.parse()?;
483 if let Lit::Str(s) = lit {
484 target = Some(s.value());
485 }
486 Ok(())
487 } else if m.path.is_ident("display") {
488 let value = m.value()?;
489 let lit: Lit = value.parse()?;
490 if let Lit::Str(s) = lit {
491 display = Some(s.value());
492 }
493 Ok(())
494 } else {
495 Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
496 }
497 })?;
498 if let Some(t) = target {
499 return Ok(Some((t, display)));
500 }
501 if display.is_some() {
502 return Err(syn::Error::new_spanned(
503 attr,
504 "`display` requires `belongs_to` alongside it",
505 ));
506 }
507 }
508 let _ = std::marker::PhantomData::<Meta>;
510 Ok(None)
511}
512
513fn plural_snake(camel: &str) -> String {
514 let snake = camel_to_snake(camel);
515 if snake.ends_with('s') {
516 snake
517 } else {
518 format!("{snake}s")
519 }
520}
521
522fn camel_to_snake(s: &str) -> String {
523 let mut out = String::new();
524 for (i, c) in s.chars().enumerate() {
525 if c.is_ascii_uppercase() && i > 0 {
526 out.push('_');
527 }
528 out.push(c.to_ascii_lowercase());
529 }
530 out
531}
532
533fn humanise(snake: &str) -> String {
534 let mut chars = snake.chars();
536 let mut out = String::new();
537 if let Some(first) = chars.next() {
538 out.push(first.to_ascii_uppercase());
539 }
540 for c in chars {
541 if c == '_' {
542 out.push(' ');
543 } else {
544 out.push(c);
545 }
546 }
547 out
548}
549
550fn find_label_field(
551 fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
552) -> Option<String> {
553 let names = ["name", "title", "full_name", "label", "email"];
557 for candidate in names {
558 if fields
559 .iter()
560 .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
561 {
562 return Some(candidate.to_string());
563 }
564 }
565 None
566}