1use proc_macro::TokenStream;
2use proc_macro2::TokenStream as TokenStream2;
3use quote::quote;
4use syn::{
5 parse_macro_input, Data, DeriveInput, Field, Fields, GenericArgument, Lit, PathArguments, Type,
6};
7
8#[derive(Clone, Copy)]
9enum FieldKind {
10 I32,
11 I64,
12 String,
13 Bool,
14 DateTime,
15}
16
17#[derive(Clone)]
23struct RelationAttr {
24 target: syn::Ident,
27 display: Option<String>,
30}
31
32struct FieldInfo {
33 ident: syn::Ident,
34 name_str: String,
35 kind: FieldKind,
36 editable: bool,
37 nullable: bool,
38 relation: Option<RelationAttr>,
39}
40
41#[proc_macro_derive(RustioAdmin, attributes(rustio))]
42pub fn derive_rustio_admin(input: TokenStream) -> TokenStream {
43 let input = parse_macro_input!(input as DeriveInput);
44 let name = &input.ident;
45
46 let data = match &input.data {
47 Data::Struct(d) => d,
48 _ => {
49 return syn::Error::new_spanned(
50 &input.ident,
51 "RustioAdmin only supports structs with named fields",
52 )
53 .to_compile_error()
54 .into();
55 }
56 };
57
58 let named = match &data.fields {
59 Fields::Named(n) => n,
60 _ => {
61 return syn::Error::new_spanned(&input.ident, "RustioAdmin requires named fields")
62 .to_compile_error()
63 .into();
64 }
65 };
66
67 let mut fields: Vec<FieldInfo> = Vec::new();
68 for f in &named.named {
69 let ident = f.ident.clone().expect("named field");
70 let name_str = ident.to_string();
71 let (kind, nullable) = match classify_type(&f.ty) {
72 Some(r) => r,
73 None => {
74 return syn::Error::new_spanned(
75 &f.ty,
76 "RustioAdmin: unsupported field type (supported: i32, i64, \
77 String, bool, DateTime<Utc>, and Option<T> of any of those)",
78 )
79 .to_compile_error()
80 .into();
81 }
82 };
83 if name_str == "id" && nullable {
86 return syn::Error::new_spanned(
87 &f.ty,
88 "RustioAdmin: `id` must be `i64`, not `Option<i64>`",
89 )
90 .to_compile_error()
91 .into();
92 }
93 let editable = name_str != "id";
94
95 let relation = match parse_relation_attr(f) {
96 Ok(r) => r,
97 Err(e) => return e.to_compile_error().into(),
98 };
99
100 if relation.is_some() && !matches!(kind, FieldKind::I64 | FieldKind::I32) {
103 return syn::Error::new_spanned(
104 &f.ty,
105 "RustioAdmin: #[rustio(belongs_to = \"...\")] can only be applied to \
106 `i32` or `i64` fields (the foreign-key column)",
107 )
108 .to_compile_error()
109 .into();
110 }
111
112 fields.push(FieldInfo {
113 ident,
114 name_str,
115 kind,
116 editable,
117 nullable,
118 relation,
119 });
120 }
121
122 let admin_name = pluralize(&name.to_string().to_lowercase());
123 let display_name = pluralize(&name.to_string());
124 let singular_name = singularize(&name.to_string());
125
126 let field_entries: Vec<TokenStream2> = fields
127 .iter()
128 .map(|f| {
129 let n = &f.name_str;
130 let kind_token = kind_token(f.kind);
131 let editable = f.editable;
132 let nullable = f.nullable;
133 let relation_token = relation_token(f.relation.as_ref());
134 quote! {
135 ::rustio_core::admin::AdminField {
136 name: #n,
137 ty: #kind_token,
138 editable: #editable,
139 nullable: #nullable,
140 relation: #relation_token,
141 }
142 }
143 })
144 .collect();
145
146 let display_arms: Vec<TokenStream2> = fields.iter().map(display_arm).collect();
147
148 let from_form_assignments: Vec<TokenStream2> =
149 fields.iter().map(from_form_assignment).collect();
150
151 let relation_checks: Vec<TokenStream2> = fields
157 .iter()
158 .filter_map(|f| f.relation.as_ref().map(|r| relation_check(&f.name_str, r)))
159 .collect();
160
161 let expanded = quote! {
162 impl ::rustio_core::admin::AdminModel for #name {
163 const ADMIN_NAME: &'static str = #admin_name;
164 const DISPLAY_NAME: &'static str = #display_name;
165 const FIELDS: &'static [::rustio_core::admin::AdminField] = &[
166 #( #field_entries ),*
167 ];
168
169 fn singular_name() -> &'static str {
170 #singular_name
171 }
172
173 fn field_display(&self, name: &str) -> Option<String> {
174 match name {
175 #( #display_arms )*
176 _ => None,
177 }
178 }
179
180 fn from_form(
181 form: &::rustio_core::admin::FormData,
182 id: Option<i64>,
183 ) -> Result<Self, ::rustio_core::Error> {
184 Ok(Self {
185 #( #from_form_assignments )*
186 })
187 }
188 }
189
190 #( #relation_checks )*
191 };
192
193 expanded.into()
194}
195
196fn pluralize(name: &str) -> String {
197 if name.ends_with('s') {
198 name.to_string()
199 } else {
200 format!("{name}s")
201 }
202}
203
204fn singularize(name: &str) -> String {
205 if let Some(stripped) = name.strip_suffix('s') {
206 if !stripped.is_empty() {
207 return stripped.to_string();
208 }
209 }
210 name.to_string()
211}
212
213fn parse_relation_attr(field: &Field) -> syn::Result<Option<RelationAttr>> {
219 let mut found: Option<RelationAttr> = None;
220 for attr in &field.attrs {
221 if !attr.path().is_ident("rustio") {
222 continue;
223 }
224
225 let mut belongs_to: Option<syn::Ident> = None;
226 let mut display: Option<String> = None;
227
228 attr.parse_nested_meta(|meta| {
229 if meta.path.is_ident("belongs_to") {
230 let value: Lit = meta.value()?.parse()?;
231 match value {
232 Lit::Str(s) => {
233 let ident = syn::parse_str::<syn::Ident>(&s.value()).map_err(|_| {
234 meta.error(format!(
235 "`belongs_to` value `{}` is not a valid Rust type name",
236 s.value()
237 ))
238 })?;
239 belongs_to = Some(ident);
240 Ok(())
241 }
242 _ => Err(meta.error("`belongs_to` must be a string literal")),
243 }
244 } else if meta.path.is_ident("display") {
245 let value: Lit = meta.value()?.parse()?;
246 match value {
247 Lit::Str(s) => {
248 display = Some(s.value());
249 Ok(())
250 }
251 _ => Err(meta.error("`display` must be a string literal")),
252 }
253 } else {
254 Err(meta.error(format!(
255 "unknown #[rustio(...)] key: `{}` (expected `belongs_to` or `display`)",
256 meta.path
257 .get_ident()
258 .map(|i| i.to_string())
259 .unwrap_or_else(|| "<non-ident>".into())
260 )))
261 }
262 })?;
263
264 match (belongs_to, display) {
265 (Some(target), display) => {
266 if found.is_some() {
267 return Err(syn::Error::new_spanned(
268 attr,
269 "#[rustio(...)] may appear at most once per field",
270 ));
271 }
272 found = Some(RelationAttr { target, display });
273 }
274 (None, Some(_)) => {
275 return Err(syn::Error::new_spanned(
276 attr,
277 "#[rustio(display = \"...\")] requires `belongs_to = \"...\"` on the same field",
278 ));
279 }
280 (None, None) => {
281 return Err(syn::Error::new_spanned(
282 attr,
283 "empty #[rustio()]: expected `belongs_to = \"ModelName\"`",
284 ));
285 }
286 }
287 }
288 Ok(found)
289}
290
291fn relation_token(r: Option<&RelationAttr>) -> TokenStream2 {
292 let Some(r) = r else {
293 return quote! { None };
294 };
295 let target = r.target.to_string();
296 let display_token = match &r.display {
297 Some(s) => quote! { Some(#s) },
298 None => quote! { None },
299 };
300 quote! {
301 Some(::rustio_core::admin::AdminRelation {
302 kind: ::rustio_core::schema::RelationKind::BelongsTo,
303 model: #target,
304 display_field: #display_token,
305 })
306 }
307}
308
309fn relation_check(field_name: &str, r: &RelationAttr) -> TokenStream2 {
320 let target = &r.target;
321 let target_str = target.to_string();
322 match &r.display {
323 None => quote! {
324 const _: () = {
325 let _: &'static str = <#target as ::rustio_core::orm::Model>::TABLE;
327 };
328 },
329 Some(display) => {
330 let not_found_msg = format!(
331 "#[rustio(belongs_to = \"{target_str}\", display = \"{display}\")] on field `{field_name}`: \
332 column `{display}` not found in `{target_str}::COLUMNS`. Declare the field on the target \
333 model or drop the `display = ...` key."
334 );
335 quote! {
336 const _: () = {
337 let _: &'static str = <#target as ::rustio_core::orm::Model>::TABLE;
338
339 const fn __rustio_str_eq(a: &str, b: &str) -> bool {
340 let a = a.as_bytes();
341 let b = b.as_bytes();
342 if a.len() != b.len() {
343 return false;
344 }
345 let mut i = 0;
346 while i < a.len() {
347 if a[i] != b[i] {
348 return false;
349 }
350 i += 1;
351 }
352 true
353 }
354
355 let cols = <#target as ::rustio_core::orm::Model>::COLUMNS;
356 let mut i = 0;
357 let mut found = false;
358 while i < cols.len() {
359 if __rustio_str_eq(cols[i], #display) {
360 found = true;
361 }
362 i += 1;
363 }
364 if !found {
365 panic!(#not_found_msg);
366 }
367 };
368 }
369 }
370 }
371}
372
373fn classify_type(ty: &Type) -> Option<(FieldKind, bool)> {
378 let Type::Path(syn::TypePath { path, .. }) = ty else {
379 return None;
380 };
381 let last = path.segments.last()?;
382
383 if last.ident == "Option" {
384 let PathArguments::AngleBracketed(args) = &last.arguments else {
386 return None;
387 };
388 let inner_ty = args.args.iter().find_map(|a| match a {
389 GenericArgument::Type(t) => Some(t),
390 _ => None,
391 })?;
392 let kind = base_kind(inner_ty)?;
393 return Some((kind, true));
394 }
395
396 base_kind(ty).map(|k| (k, false))
397}
398
399fn base_kind(ty: &Type) -> Option<FieldKind> {
401 let Type::Path(syn::TypePath { path, .. }) = ty else {
402 return None;
403 };
404 let last = path.segments.last()?;
405 match last.ident.to_string().as_str() {
406 "i32" => Some(FieldKind::I32),
407 "i64" => Some(FieldKind::I64),
408 "String" => Some(FieldKind::String),
409 "bool" => Some(FieldKind::Bool),
410 "DateTime" => Some(FieldKind::DateTime),
416 _ => None,
417 }
418}
419
420fn kind_token(kind: FieldKind) -> TokenStream2 {
421 match kind {
422 FieldKind::I32 => quote! { ::rustio_core::admin::FieldType::I32 },
423 FieldKind::I64 => quote! { ::rustio_core::admin::FieldType::I64 },
424 FieldKind::String => quote! { ::rustio_core::admin::FieldType::String },
425 FieldKind::Bool => quote! { ::rustio_core::admin::FieldType::Bool },
426 FieldKind::DateTime => quote! { ::rustio_core::admin::FieldType::DateTime },
427 }
428}
429
430const DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M";
435
436fn display_arm(f: &FieldInfo) -> TokenStream2 {
438 let ident = &f.ident;
439 let name_str = &f.name_str;
440
441 if f.nullable {
443 return match f.kind {
444 FieldKind::DateTime => quote! {
445 #name_str => Some(match &self.#ident {
446 Some(v) => v.format(#DATETIME_FORMAT).to_string(),
447 None => String::new(),
448 }),
449 },
450 _ => quote! {
451 #name_str => Some(match &self.#ident {
452 Some(v) => v.to_string(),
453 None => String::new(),
454 }),
455 },
456 };
457 }
458
459 match f.kind {
460 FieldKind::DateTime => quote! {
461 #name_str => Some(self.#ident.format(#DATETIME_FORMAT).to_string()),
462 },
463 _ => quote! {
464 #name_str => Some(self.#ident.to_string()),
465 },
466 }
467}
468
469fn from_form_assignment(f: &FieldInfo) -> TokenStream2 {
475 let ident = &f.ident;
476 let name_str = &f.name_str;
477 if !f.editable {
478 return quote! { #ident: id.unwrap_or(0), };
479 }
480
481 if f.nullable {
482 return nullable_assignment(ident, name_str, f.kind);
483 }
484
485 match f.kind {
486 FieldKind::String => quote! {
487 #ident: {
488 let v = form.get(#name_str).unwrap_or("").trim();
489 if v.is_empty() {
490 return Err(::rustio_core::Error::BadRequest(
491 format!("field `{}` is required", #name_str)
492 ));
493 }
494 v.to_owned()
495 },
496 },
497 FieldKind::Bool => quote! {
498 #ident: matches!(form.get(#name_str), Some(v) if v == "on" || v == "true"),
499 },
500 FieldKind::I64 => quote! {
501 #ident: {
502 let raw = form.get(#name_str).unwrap_or("").trim();
503 if raw.is_empty() {
504 return Err(::rustio_core::Error::BadRequest(
505 format!("field `{}` is required", #name_str)
506 ));
507 }
508 raw.parse::<i64>().map_err(|_| ::rustio_core::Error::BadRequest(
509 format!("field `{}` must be a valid integer", #name_str)
510 ))?
511 },
512 },
513 FieldKind::I32 => quote! {
514 #ident: {
515 let raw = form.get(#name_str).unwrap_or("").trim();
516 if raw.is_empty() {
517 return Err(::rustio_core::Error::BadRequest(
518 format!("field `{}` is required", #name_str)
519 ));
520 }
521 raw.parse::<i32>().map_err(|_| ::rustio_core::Error::BadRequest(
522 format!("field `{}` must be a valid integer", #name_str)
523 ))?
524 },
525 },
526 FieldKind::DateTime => quote! {
527 #ident: {
528 let raw = form.get(#name_str).unwrap_or("").trim();
529 if raw.is_empty() {
530 return Err(::rustio_core::Error::BadRequest(
531 format!("field `{}` is required", #name_str)
532 ));
533 }
534 ::rustio_core::admin::parse_datetime_local(raw).map_err(|e| {
535 ::rustio_core::Error::BadRequest(
536 format!("field `{}`: {}", #name_str, e)
537 )
538 })?
539 },
540 },
541 }
542}
543
544fn nullable_assignment(ident: &syn::Ident, name_str: &str, kind: FieldKind) -> TokenStream2 {
546 match kind {
547 FieldKind::String => quote! {
548 #ident: {
549 let v = form.get(#name_str).unwrap_or("").trim();
550 if v.is_empty() { None } else { Some(v.to_owned()) }
551 },
552 },
553 FieldKind::Bool => quote! {
559 #ident: match form.get(#name_str) {
560 Some(v) if v == "on" || v == "true" => Some(true),
561 Some(v) if v == "off" || v == "false" => Some(false),
562 Some(_) | None => None,
563 },
564 },
565 FieldKind::I64 => quote! {
566 #ident: {
567 let raw = form.get(#name_str).unwrap_or("").trim();
568 if raw.is_empty() {
569 None
570 } else {
571 Some(raw.parse::<i64>().map_err(|_| ::rustio_core::Error::BadRequest(
572 format!("field `{}` must be a valid integer", #name_str)
573 ))?)
574 }
575 },
576 },
577 FieldKind::I32 => quote! {
578 #ident: {
579 let raw = form.get(#name_str).unwrap_or("").trim();
580 if raw.is_empty() {
581 None
582 } else {
583 Some(raw.parse::<i32>().map_err(|_| ::rustio_core::Error::BadRequest(
584 format!("field `{}` must be a valid integer", #name_str)
585 ))?)
586 }
587 },
588 },
589 FieldKind::DateTime => quote! {
590 #ident: {
591 let raw = form.get(#name_str).unwrap_or("").trim();
592 if raw.is_empty() {
593 None
594 } else {
595 Some(::rustio_core::admin::parse_datetime_local(raw).map_err(|e| {
596 ::rustio_core::Error::BadRequest(
597 format!("field `{}`: {}", #name_str, e)
598 )
599 })?)
600 }
601 },
602 },
603 }
604}