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