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 admin_name = plural_snake(&struct_name.to_string());
31 let display_name = humanise(&plural_snake(&struct_name.to_string()));
32 let singular = struct_name.to_string();
33
34 let mut field_metas = Vec::new();
35 let mut display_value_arms = Vec::new();
36 let mut from_form_parses = Vec::new();
37 let mut from_form_fields = Vec::new();
38 let mut update_tuples = Vec::new();
39
40 for f in fields {
41 let fname = f.ident.as_ref().unwrap();
42 let fname_str = fname.to_string();
43 let kind = classify_type(&f.ty)?;
44 let kind = if matches!(kind, FieldKind::DateTime) && is_auto_timestamp_name(&fname_str) {
51 FieldKind::DateTimeAuto
52 } else {
53 kind
54 };
55 let editable = fname_str != "id" && kind != FieldKind::DateTimeAuto;
56
57 let type_variant = kind.field_type_ident();
58 let relation = parse_relation_attr(&f.attrs, &fname_str)?;
59 let relation_tokens = match &relation {
60 Some((target, display)) => {
61 let display_tok = match display {
62 Some(d) => quote! { ::std::option::Option::Some(#d) },
63 None => quote! { ::std::option::Option::None },
64 };
65 quote! {
66 ::std::option::Option::Some(::rustio_admin::admin::AdminRelation {
67 target_model: #target,
68 display_field: #display_tok,
69 multi: false,
76 })
77 }
78 }
79 None => quote! { ::std::option::Option::None },
80 };
81
82 field_metas.push(quote! {
83 ::rustio_admin::admin::AdminField {
84 name: #fname_str,
85 label: #fname_str,
86 field_type: ::rustio_admin::admin::FieldType::#type_variant,
87 editable: #editable,
88 relation: #relation_tokens,
89 choices: ::std::option::Option::None,
95 }
96 });
97
98 let display_arm = match kind {
100 FieldKind::String => quote! {
101 out.push((#fname_str.to_string(), self.#fname.clone()));
102 },
103 FieldKind::OptionalString => quote! {
104 out.push((#fname_str.to_string(), match &self.#fname {
108 Some(v) => v.clone(),
109 None => String::new(),
110 }));
111 },
112 FieldKind::I32 | FieldKind::I64 => quote! {
113 out.push((#fname_str.to_string(), self.#fname.to_string()));
114 },
115 FieldKind::OptionalI64 => quote! {
116 out.push((#fname_str.to_string(), match &self.#fname {
117 Some(v) => v.to_string(),
118 None => String::new(),
119 }));
120 },
121 FieldKind::Bool => quote! {
122 out.push((#fname_str.to_string(), if self.#fname { "true".to_string() } else { "false".to_string() }));
123 },
124 FieldKind::DateTime | FieldKind::DateTimeAuto => quote! {
125 out.push((#fname_str.to_string(), self.#fname.format("%Y-%m-%dT%H:%M").to_string()));
134 },
135 };
136 display_value_arms.push(display_arm);
137
138 if fname_str == "id" {
140 from_form_fields.push(quote! { #fname: 0 });
141 continue;
142 }
143
144 let humanised_label = humanise_field(&fname_str);
149 let required_msg = format!("{humanised_label} is required.");
150 let number_msg = format!("{humanised_label} must be a number.");
151 let date_invalid_msg = format!("{humanised_label} is not a valid date.");
152
153 match kind {
154 FieldKind::String => {
155 from_form_parses.push(quote! {
160 let #fname = match form.get(#fname_str).map(str::trim) {
161 Some(v) if !v.is_empty() => v.to_string(),
162 _ => { errors.push(#required_msg.to_string()); String::new() }
163 };
164 });
165 from_form_fields.push(quote! { #fname });
166 }
167 FieldKind::OptionalString => {
168 from_form_parses.push(quote! {
171 let #fname: Option<String> = form
172 .get(#fname_str)
173 .map(|s| s.trim().to_string())
174 .filter(|s| !s.is_empty());
175 });
176 from_form_fields.push(quote! { #fname });
177 }
178 FieldKind::I32 => {
179 from_form_parses.push(quote! {
180 let #fname: i32 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
181 Some(v) => v,
182 None => { errors.push(#number_msg.to_string()); 0 }
183 };
184 });
185 from_form_fields.push(quote! { #fname });
186 }
187 FieldKind::I64 => {
188 from_form_parses.push(quote! {
189 let #fname: i64 = match form.get(#fname_str).and_then(|v| v.parse().ok()) {
190 Some(v) => v,
191 None => { errors.push(#number_msg.to_string()); 0 }
192 };
193 });
194 from_form_fields.push(quote! { #fname });
195 }
196 FieldKind::OptionalI64 => {
197 from_form_parses.push(quote! {
201 let #fname: Option<i64> = match form.get(#fname_str).map(str::trim) {
202 None | Some("") => None,
203 Some(raw) => match raw.parse::<i64>() {
204 Ok(n) => Some(n),
205 Err(_) => {
206 errors.push(#number_msg.to_string());
207 None
208 }
209 },
210 };
211 });
212 from_form_fields.push(quote! { #fname });
213 }
214 FieldKind::Bool => {
215 from_form_parses.push(quote! {
216 let #fname: bool = form.bool_flag(#fname_str);
217 });
218 from_form_fields.push(quote! { #fname });
219 }
220 FieldKind::DateTime => {
221 from_form_parses.push(quote! {
222 let #fname = match form.get(#fname_str) {
223 Some(raw) if !raw.is_empty() => {
224 match ::chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") {
225 Ok(dt) => ::chrono::DateTime::<::chrono::Utc>::from_naive_utc_and_offset(dt, ::chrono::Utc),
226 Err(_) => { errors.push(#date_invalid_msg.to_string()); ::chrono::Utc::now() }
227 }
228 }
229 _ => { errors.push(#required_msg.to_string()); ::chrono::Utc::now() }
230 };
231 });
232 from_form_fields.push(quote! { #fname });
233 }
234 FieldKind::DateTimeAuto => {
235 from_form_parses.push(quote! {
237 let #fname = ::chrono::Utc::now();
238 });
239 from_form_fields.push(quote! { #fname });
240 }
241 }
242
243 update_tuples.push(quote! {
244 (#fname_str, self.#fname.clone().into())
245 });
246 }
247
248 let object_label_expr = find_label_field(fields)
249 .map(|n| {
250 let id = format_ident!("{n}");
251 quote! { self.#id.clone().to_string() }
252 })
253 .unwrap_or_else(|| quote! { format!("#{}", self.id) });
254
255 Ok(quote! {
256 impl ::rustio_admin::admin::AdminModel for #struct_name {
257 const ADMIN_NAME: &'static str = #admin_name;
258 const DISPLAY_NAME: &'static str = #display_name;
259 const SINGULAR_NAME: &'static str = #singular;
260 const FIELDS: &'static [::rustio_admin::admin::AdminField] = &[
261 #(#field_metas),*
262 ];
263
264 fn display_values(&self) -> ::std::vec::Vec<(::std::string::String, ::std::string::String)> {
265 let mut out = ::std::vec::Vec::new();
266 #(#display_value_arms)*
267 out
268 }
269
270 fn from_form(form: &::rustio_admin::http::FormData) -> ::std::result::Result<Self, ::std::vec::Vec<::std::string::String>>
271 where
272 Self: Sized,
273 {
274 let mut errors: ::std::vec::Vec<::std::string::String> = ::std::vec::Vec::new();
275 #(#from_form_parses)*
276 if !errors.is_empty() {
277 return Err(errors);
278 }
279 Ok(Self { #(#from_form_fields),* })
280 }
281
282 fn object_label(&self) -> ::std::string::String {
283 #object_label_expr
284 }
285
286 fn id(&self) -> i64 {
287 self.id
288 }
289
290 fn values_to_update(&self) -> ::std::vec::Vec<(&'static str, ::rustio_admin::orm::Value)> {
291 ::std::vec![#(#update_tuples),*]
292 }
293 }
294 })
295}
296
297fn struct_fields(
298 input: &DeriveInput,
299) -> syn::Result<&syn::punctuated::Punctuated<syn::Field, syn::Token![,]>> {
300 let data = match &input.data {
301 Data::Struct(s) => s,
302 _ => {
303 return Err(syn::Error::new_spanned(
304 &input.ident,
305 "RustioAdmin can only derive on structs",
306 ))
307 }
308 };
309 match &data.fields {
310 Fields::Named(named) => Ok(&named.named),
311 _ => Err(syn::Error::new_spanned(
312 &input.ident,
313 "RustioAdmin requires a struct with named fields",
314 )),
315 }
316}
317
318#[derive(Debug, PartialEq, Clone, Copy)]
319enum FieldKind {
320 I32,
321 I64,
322 Bool,
323 String,
324 DateTime,
325 DateTimeAuto,
326 OptionalString,
327 OptionalI64,
328}
329
330impl FieldKind {
331 fn field_type_ident(&self) -> proc_macro2::Ident {
332 match self {
333 FieldKind::I32 => format_ident!("I32"),
334 FieldKind::I64 => format_ident!("I64"),
335 FieldKind::Bool => format_ident!("Bool"),
336 FieldKind::String => format_ident!("String"),
337 FieldKind::DateTime | FieldKind::DateTimeAuto => format_ident!("DateTime"),
338 FieldKind::OptionalString => format_ident!("OptionalString"),
339 FieldKind::OptionalI64 => format_ident!("OptionalI64"),
340 }
341 }
342}
343
344fn is_auto_timestamp_name(name: &str) -> bool {
350 matches!(name, "created_at" | "updated_at")
351}
352
353fn humanise_field(s: &str) -> String {
358 let mut out = String::with_capacity(s.len());
359 let mut next_upper = true;
360 for ch in s.chars() {
361 if ch == '_' {
362 out.push(' ');
363 next_upper = true;
364 } else if next_upper {
365 out.push(ch.to_ascii_uppercase());
366 next_upper = false;
367 } else {
368 out.push(ch);
369 }
370 }
371 out
372}
373
374fn classify_type(ty: &syn::Type) -> syn::Result<FieldKind> {
375 let as_string = quote! { #ty }.to_string().replace(' ', "");
376 let kind = match as_string.as_str() {
377 "i32" => FieldKind::I32,
378 "i64" => FieldKind::I64,
379 "bool" => FieldKind::Bool,
380 "String" => FieldKind::String,
381 "DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => FieldKind::DateTime,
382 "Option<String>" => FieldKind::OptionalString,
383 "Option<i64>" => FieldKind::OptionalI64,
384 other => {
385 return Err(syn::Error::new_spanned(
386 ty,
387 format!("unsupported field type for RustioAdmin: {other}"),
388 ))
389 }
390 };
391 Ok(kind)
392}
393
394fn parse_relation_attr(
395 attrs: &[syn::Attribute],
396 field_name: &str,
397) -> syn::Result<Option<(String, Option<String>)>> {
398 for attr in attrs {
399 if !attr.path().is_ident("rustio") {
400 continue;
401 }
402 let mut target: Option<String> = None;
403 let mut display: Option<String> = None;
404 attr.parse_nested_meta(|m| {
405 if m.path.is_ident("belongs_to") {
406 let value = m.value()?;
407 let lit: Lit = value.parse()?;
408 if let Lit::Str(s) = lit {
409 target = Some(s.value());
410 }
411 Ok(())
412 } else if m.path.is_ident("display") {
413 let value = m.value()?;
414 let lit: Lit = value.parse()?;
415 if let Lit::Str(s) = lit {
416 display = Some(s.value());
417 }
418 Ok(())
419 } else {
420 Err(m.error(format!("unknown rustio attribute for field `{field_name}`")))
421 }
422 })?;
423 if let Some(t) = target {
424 return Ok(Some((t, display)));
425 }
426 if display.is_some() {
427 return Err(syn::Error::new_spanned(
428 attr,
429 "`display` requires `belongs_to` alongside it",
430 ));
431 }
432 }
433 let _ = std::marker::PhantomData::<Meta>;
435 Ok(None)
436}
437
438fn plural_snake(camel: &str) -> String {
439 let snake = camel_to_snake(camel);
440 if snake.ends_with('s') {
441 snake
442 } else {
443 format!("{snake}s")
444 }
445}
446
447fn camel_to_snake(s: &str) -> String {
448 let mut out = String::new();
449 for (i, c) in s.chars().enumerate() {
450 if c.is_ascii_uppercase() && i > 0 {
451 out.push('_');
452 }
453 out.push(c.to_ascii_lowercase());
454 }
455 out
456}
457
458fn humanise(snake: &str) -> String {
459 let mut chars = snake.chars();
461 let mut out = String::new();
462 if let Some(first) = chars.next() {
463 out.push(first.to_ascii_uppercase());
464 }
465 for c in chars {
466 if c == '_' {
467 out.push(' ');
468 } else {
469 out.push(c);
470 }
471 }
472 out
473}
474
475fn find_label_field(
476 fields: &syn::punctuated::Punctuated<syn::Field, syn::Token![,]>,
477) -> Option<String> {
478 let names = ["name", "title", "full_name", "label", "email"];
482 for candidate in names {
483 if fields
484 .iter()
485 .any(|f| f.ident.as_ref().map(|i| i == candidate).unwrap_or(false))
486 {
487 return Some(candidate.to_string());
488 }
489 }
490 None
491}