Skip to main content

cinderblock_core_macros/
lib.rs

1use core::iter::Iterator;
2use std::collections::{HashMap, HashSet};
3
4use cinderblock_extension_api::{
5    Accept, ReadFilterValue, ResourceActionInputKind, ResourceAttributeInput, ResourceMacroInput,
6    UpdateChange,
7};
8use syn::{spanned::Spanned, Ident, Type};
9
10/// Checks whether a `syn::Type` is `Option<T>`.
11///
12/// We inspect the outermost path segment for the identifier `Option`. This
13/// handles both `Option<T>` and `std::option::Option<T>` (by checking the
14/// last segment).
15fn is_option_type(ty: &Type) -> bool {
16    if let Type::Path(type_path) = ty {
17        type_path
18            .path
19            .segments
20            .last()
21            .is_some_and(|seg| seg.ident == "Option")
22    } else {
23        false
24    }
25}
26
27#[proc_macro]
28pub fn resource(item: proc_macro::TokenStream) -> proc_macro::TokenStream {
29    // Capture the raw input tokens before parsing so we can forward them
30    // verbatim to extension macros without a reconstruct roundtrip.
31    let raw_tokens: proc_macro2::TokenStream = item.clone().into();
32
33    let input = syn::parse_macro_input!(item as ResourceMacroInput);
34
35    let ident = input.name.last().expect("Missing name segment");
36
37    let fields = input
38        .attributes
39        .iter()
40        .map(ResourceAttributeInput::to_field_definition);
41
42    let primary_key_type = {
43        let fields = input
44            .attributes
45            .iter()
46            .filter(|attr| attr.primary_key.value())
47            .collect::<Vec<_>>();
48
49        match fields.len() {
50            0 => todo!("Support no primary keys"),
51            1 => {
52                let ty = fields[0].ty.clone();
53                quote::quote! { #ty }
54            }
55            _ => todo!("Support multiple primary keys"),
56        }
57    };
58
59    let primary_key_generated = {
60        let fields = input
61            .attributes
62            .iter()
63            .filter(|attr| attr.primary_key.value())
64            .collect::<Vec<_>>();
65
66        match fields.len() {
67            0 => todo!("Support no primary keys"),
68            1 => fields[0].generated.value(),
69            _ => todo!("Support multiple primary keys"),
70        }
71    };
72
73    let primary_key_value = {
74        let fields = input
75            .attributes
76            .iter()
77            .filter(|attr| attr.primary_key.value())
78            .collect::<Vec<_>>();
79
80        match fields.len() {
81            0 => todo!("Support no primary keys"),
82            1 => {
83                let ty = fields[0].name.clone();
84                quote::quote! { &self.#ty }
85            }
86            _ => todo!("Support multiple primary keys"),
87        }
88    };
89
90    let data_layer_specified = input.data_layer.is_some();
91
92    let actions = input.actions.iter().map(|action| match &action.kind {
93        ResourceActionInputKind::Read(read_action) => {
94            let action_name = convert_case::ccase!(pascal, action.name.to_string());
95            let action_name = Ident::new(&action_name, action.name.span());
96
97            let is_paged = read_action.paged.is_some();
98
99            // # Arguments struct generation
100            //
101            // When the read action declares arguments, we generate a dedicated
102            // `{ActionName}Arguments` struct with `Deserialize` so it can be
103            // populated from query parameters. When no arguments are declared,
104            // we use `()` as the arguments type — unless the action is paged,
105            // in which case we always need an Arguments struct to hold
106            // `page` and `per_page` fields.
107            let has_user_arguments = !read_action.arguments.is_empty();
108            let needs_arguments_struct = has_user_arguments || is_paged;
109
110            let (arguments_type, arguments_struct) = if needs_arguments_struct {
111                let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
112                let user_arg_fields = read_action.arguments.iter().map(|arg| {
113                    let name = &arg.name;
114                    let ty = &arg.ty;
115                    quote::quote! { pub #name: #ty }
116                });
117
118                // For paged actions, append `page` and `per_page` fields.
119                let paged_fields = if is_paged {
120                    quote::quote! {
121                        pub page: Option<u32>,
122                        pub per_page: Option<u32>,
123                    }
124                } else {
125                    quote::quote! {}
126                };
127
128                (
129                    quote::quote! { #args_name },
130                    quote::quote! {
131                        #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
132                        struct #args_name {
133                            #(#user_arg_fields,)*
134                            #paged_fields
135                        }
136                    },
137                )
138            } else {
139                (quote::quote! { () }, quote::quote! {})
140            };
141
142            // # Paged trait impl
143            //
144            // When the action is paged, generate a `Paged` impl on the
145            // Arguments struct that resolves defaults and clamping from
146            // the DSL config.
147            let paged_impl = if let Some(paged_config) = &read_action.paged {
148                let args_name = Ident::new(&format!("{action_name}Arguments"), action.name.span());
149
150                let default_per_page = match paged_config.default_per_page {
151                    Some(n) => quote::quote! { #n },
152                    None => quote::quote! { cinderblock_core::DEFAULT_PER_PAGE },
153                };
154
155                // Clamp per_page to max if configured, otherwise just use
156                // the resolved value directly.
157                let per_page_body = if let Some(max) = paged_config.max_per_page {
158                    quote::quote! {
159                        self.per_page.unwrap_or(#default_per_page).min(#max)
160                    }
161                } else {
162                    quote::quote! {
163                        self.per_page.unwrap_or(#default_per_page)
164                    }
165                };
166
167                quote::quote! {
168                    impl cinderblock_core::Paged for #args_name {
169                        fn page(&self) -> u32 {
170                            self.page.unwrap_or(1)
171                        }
172
173                        fn per_page(&self) -> u32 {
174                            #per_page_body
175                        }
176                    }
177                }
178            } else {
179                quote::quote! {}
180            };
181
182            // # Response type
183            //
184            // Non-paged actions return `Vec<Output>`, paged actions return
185            // `PaginatedResult<Output>`.
186            let response_type = if is_paged {
187                quote::quote! { cinderblock_core::PaginatedResult<#ident> }
188            } else {
189                quote::quote! { Vec<#ident> }
190            };
191
192            // # In-memory data layer codegen
193            //
194            // When no custom data layer is specified, generate three things:
195            //
196            // 1. The filter trait impl (`InMemoryReadAction` or
197            //    `InMemoryPagedReadAction`) — contains the per-row predicate.
198            //
199            // 2. The `InMemoryPerformRead` impl — bridges the filter trait to
200            //    the framework's `PerformRead` by applying the filter to all
201            //    rows and either collecting into `Vec` (non-paged) or slicing
202            //    into `PaginatedResult` (paged).
203            //
204            // This mirrors the pattern in `cinderblock-sqlx-macros` where both
205            // the SQL filter trait and `SqlPerformRead` are generated together.
206            let data_layer_block = if data_layer_specified {
207                quote::quote! { }
208            } else if is_paged {
209                // # Filter codegen for InMemoryPagedReadAction
210                let filters = read_action.filters.iter().map(|filter| {
211                    let field = &filter.field;
212                    let op = match filter.op {
213                        cinderblock_extension_api::ReadFilterOperation::Eq => quote::quote! { == },
214                    };
215                    match &filter.value {
216                        ReadFilterValue::Literal(expr) => {
217                            quote::quote! {
218                                row.#field #op #expr &&
219                            }
220                        }
221                        ReadFilterValue::Arg(arg_name) => {
222                            let arg_decl = read_action
223                                .arguments
224                                .iter()
225                                .find(|a| a.name == *arg_name)
226                                .expect("arg reference validated during parsing");
227                            if is_option_type(&arg_decl.ty) {
228                                quote::quote! {
229                                    args.#arg_name.as_ref().map_or(true, |v| row.#field #op *v) &&
230                                }
231                            } else {
232                                quote::quote! {
233                                    row.#field #op args.#arg_name &&
234                                }
235                            }
236                        }
237                    }
238                });
239
240                quote::quote! {
241                    impl cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction for #action_name {
242                        fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
243                            #(#filters)* true
244                        }
245                    }
246
247                    impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
248                        fn execute(
249                            all: impl Iterator<Item = Self::Output>,
250                            args: &Self::Arguments,
251                        ) -> Self::Response {
252                            use cinderblock_core::Paged;
253
254                            let filtered: Vec<Self::Output> = all
255                                .filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryPagedReadAction>::filter(row, args))
256                                .collect();
257
258                            let total = filtered.len() as u64;
259                            let page = args.page();
260                            let per_page = args.per_page();
261                            let total_pages = if total == 0 { 1 } else { ((total as u32).saturating_add(per_page - 1)) / per_page };
262
263                            let skip = ((page.saturating_sub(1)) as usize) * (per_page as usize);
264                            let data: Vec<Self::Output> = filtered
265                                .into_iter()
266                                .skip(skip)
267                                .take(per_page as usize)
268                                .collect();
269
270                            cinderblock_core::PaginatedResult {
271                                data,
272                                meta: cinderblock_core::PaginationMeta {
273                                    page,
274                                    per_page,
275                                    total,
276                                    total_pages,
277                                },
278                            }
279                        }
280                    }
281                }
282            } else {
283                // # Filter codegen for InMemoryReadAction
284                //
285                // Each filter becomes a boolean clause AND'd together. Literal
286                // values are emitted directly; `arg(name)` references access
287                // the corresponding field on the args struct.
288                //
289                // For `Option<T>` argument types, the filter clause is
290                // conditional: when `None`, the clause is skipped (evaluates
291                // to `true`). This lets optional arguments act as "filter if
292                // provided" semantics.
293                let filters = read_action.filters.iter().map(|filter| {
294                    let field = &filter.field;
295
296                    let op = match filter.op {
297                        cinderblock_extension_api::ReadFilterOperation::Eq => quote::quote! { == },
298                    };
299
300                    match &filter.value {
301                        ReadFilterValue::Literal(expr) => {
302                            quote::quote! {
303                                row.#field #op #expr &&
304                            }
305                        }
306                        ReadFilterValue::Arg(arg_name) => {
307                            // Check if the argument type is Option<T> by
308                            // looking it up in the declared arguments.
309                            let arg_decl = read_action
310                                .arguments
311                                .iter()
312                                .find(|a| a.name == *arg_name)
313                                .expect("arg reference validated during parsing");
314
315                            if is_option_type(&arg_decl.ty) {
316                                // Optional arg: skip the filter when None
317                                quote::quote! {
318                                    args.#arg_name.as_ref().map_or(true, |v| row.#field #op *v) &&
319                                }
320                            } else {
321                                quote::quote! {
322                                    row.#field #op args.#arg_name &&
323                                }
324                            }
325                        }
326                    }
327                });
328
329                quote::quote! {
330                    impl cinderblock_core::data_layer::in_memory::InMemoryReadAction for #action_name {
331                        fn filter(row: &Self::Output, args: &Self::Arguments) -> bool {
332                            #(#filters)* true
333                        }
334                    }
335
336                    impl cinderblock_core::data_layer::in_memory::InMemoryPerformRead for #action_name {
337                        fn execute(
338                            all: impl Iterator<Item = Self::Output>,
339                            args: &Self::Arguments,
340                        ) -> Self::Response {
341                            all.filter(|row| <Self as cinderblock_core::data_layer::in_memory::InMemoryReadAction>::filter(row, args))
342                                .collect()
343                        }
344                    }
345                }
346            };
347
348            quote::quote! {
349                #arguments_struct
350
351                #paged_impl
352
353                struct #action_name;
354
355                impl cinderblock_core::ReadAction for #action_name {
356                    type Output = #ident;
357
358                    type Arguments = #arguments_type;
359
360                    type Response = #response_type;
361                }
362
363                #data_layer_block
364            }
365        }
366        ResourceActionInputKind::Create { accept } => {
367            let action_name = convert_case::ccase!(pascal, action.name.to_string());
368            let action_name = Ident::new(&action_name, action.name.span());
369            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
370
371            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
372
373            let (present, mut missing_names) = match accept {
374                Accept::Default => (
375                    attributes
376                        .map(|attr| (attr.name.to_string(), attr))
377                        .collect::<HashMap<_, _>>(),
378                    HashMap::new(),
379                ),
380                Accept::Only(idents) => {
381                    let idents = idents
382                        .iter()
383                        .map(|ident| ident.to_string())
384                        .collect::<HashSet<_>>();
385
386                    attributes.fold(
387                        (HashMap::new(), HashMap::new()),
388                        |(mut present, mut missing), attr| {
389                            if idents.contains(&attr.name.to_string()) {
390                                present.insert(attr.name.to_string(), attr);
391                            } else {
392                                missing.insert(attr.name.to_string(), attr);
393                            }
394                            (present, missing)
395                        },
396                    )
397                }
398            };
399
400            input
401                .attributes
402                .iter()
403                .filter(|attr| {
404                    !attr.writable.value() || !present.contains_key(&attr.name.to_string())
405                })
406                .for_each(|attr| {
407                    missing_names.insert(attr.name.to_string(), attr);
408                });
409
410            let attributes = present.values().map(|attr| attr.to_field_definition());
411
412            let missing_names = missing_names.values().map(|attr| attr.to_default());
413
414            let present_names = present.values().map(|attr| attr.name.clone());
415
416            quote::quote! {
417                #[derive(::std::fmt::Debug)]
418                struct #action_name;
419
420                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
421                struct #input_name {
422                    #(pub #attributes),*
423                }
424
425                impl cinderblock_core::Create<#action_name> for #ident {
426                    type Input = #input_name;
427
428                    fn from_create_input(input: Self::Input) -> Self {
429                        #ident {
430                            // Iterate over attributes in #action_name.
431                            #(#present_names: input.#present_names,)*
432
433                            // All types attributes not present in #action_name should use default
434                            #(#missing_names),*
435                        }
436                    }
437                }
438            }
439        }
440        ResourceActionInputKind::Update(update) => {
441            let action_name = convert_case::ccase!(pascal, action.name.to_string());
442            let action_name = Ident::new(&action_name, action.name.span());
443            let input_name = Ident::new(&format!("{action_name}Input"), action.name.span());
444
445            let attributes = input.attributes.iter().filter(|attr| attr.writable.value());
446
447            let present = match &update.accept {
448                Accept::Default => attributes.collect::<Vec<_>>(),
449                Accept::Only(idents) => {
450                    let idents = idents
451                        .iter()
452                        .map(|ident| ident.to_string())
453                        .collect::<HashSet<_>>();
454
455                    attributes
456                        .filter(|attr| idents.contains(&attr.name.to_string()))
457                        .collect()
458                }
459            };
460
461            let field_definitions = present.iter().map(|attr| attr.to_field_definition());
462
463            // # Field assignment generation
464            //
465            // Each accepted field from the input struct gets assigned onto `self`
466            // in the generated `apply_update_input` method.
467            let field_assignments = present.iter().map(|attr| {
468                let name = &attr.name;
469                quote::quote! { self.#name = input.#name; }
470            });
471
472            // # Change closure generation
473            //
474            // Each `change_ref` closure is emitted as a typed closure bound to a
475            // variable, then called with `self`. We inject `&mut Self` as the
476            // parameter type so field access resolves without the user needing
477            // to annotate the type in the DSL.
478            let change_ref_calls =
479                update
480                    .changes
481                    .iter()
482                    .enumerate()
483                    .filter_map(|(i, change)| match change {
484                        UpdateChange::ChangeRef(closure) => {
485                            let param = closure
486                                .inputs
487                                .first()
488                                .expect("change_ref closure must have exactly one parameter");
489                            let body = &closure.body;
490                            let binding = Ident::new(&format!("change_ref_{i}"), param.span());
491                            Some(quote::quote! {
492                                let #binding = |#param: &mut Self| #body;
493                                #binding(self);
494                            })
495                        }
496                        // TODO: support `change` (by-value) variant
497                        UpdateChange::Change(_) => None,
498                    });
499
500            quote::quote! {
501                #[derive(::std::fmt::Debug)]
502                struct #action_name;
503
504                #[derive(::std::fmt::Debug, cinderblock_core::serde::Deserialize)]
505                struct #input_name {
506                    #(pub #field_definitions),*
507                }
508
509                impl cinderblock_core::Update<#action_name> for #ident {
510                    type Input = #input_name;
511
512                    fn apply_update_input(&mut self, input: Self::Input) {
513                        #(#field_assignments)*
514                        #(#change_ref_calls)*
515                    }
516                }
517            }
518        }
519        ResourceActionInputKind::Destroy => {
520            // # Destroy action codegen
521            //
522            // Destroy actions only need a marker struct and a `Destroy<A>` impl.
523            // No input struct is generated — the primary key comes from the
524            // URL path at the HTTP layer.
525            let action_name = convert_case::ccase!(pascal, action.name.to_string());
526            let action_name = Ident::new(&action_name, action.name.span());
527
528            quote::quote! {
529                #[derive(::std::fmt::Debug)]
530                struct #action_name;
531
532                impl cinderblock_core::Destroy<#action_name> for #ident {}
533            }
534        }
535    });
536
537    let name_segments = input.name.iter().map(|segment| segment.to_string());
538
539    // # Data layer selection
540    //
541    // If the user specified `data_layer = some::Path;` in the DSL, use that
542    // path. Otherwise default to the built-in in-memory data layer.
543    let data_layer_path = input.data_layer.map_or_else(
544        || quote::quote! { cinderblock_core::data_layer::in_memory::InMemoryDataLayer },
545        |path| quote::quote! { #path },
546    );
547
548    // # Extension forwarding
549    //
550    // For each declared extension, we forward the raw DSL tokens (captured
551    // before parsing) inside a braced group, followed by a `config = { ... }`
552    // block containing the extension-specific configuration. This avoids a
553    // parse-then-reconstruct roundtrip — the extension macro receives the
554    // exact tokens the user wrote.
555    let extension_calls = input.extensions.iter().map(|ext| {
556        let path = &ext.path;
557        let config_tokens = &ext.config_tokens;
558
559        quote::quote! {
560            #path::__resource_extension! {
561                { #raw_tokens }
562
563                config = {
564                    #config_tokens
565                }
566            }
567        }
568    });
569
570    quote::quote! {
571        #[derive(::std::fmt::Debug, ::std::clone::Clone, cinderblock_core::serde::Serialize, cinderblock_core::serde::Deserialize)]
572        struct #ident {
573            #(#fields),*
574        }
575
576        impl cinderblock_core::Resource for #ident {
577            type PrimaryKey = #primary_key_type;
578
579            type DataLayer = #data_layer_path;
580
581            const NAME: &'static [&'static str] = &[#(#name_segments),*];
582
583            const PRIMARY_KEY_GENERATED: bool = #primary_key_generated;
584
585            fn primary_key(&self) -> &Self::PrimaryKey {
586                #primary_key_value
587            }
588        }
589
590        #(#actions)*
591
592        #(#extension_calls)*
593    }
594    .into()
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use assert2::{assert, check};
601    use cinderblock_extension_api::ResourceActionInput;
602    use quote::quote;
603
604    fn parse_resource(tokens: proc_macro2::TokenStream) -> ResourceMacroInput {
605        let result = syn::parse2::<ResourceMacroInput>(tokens);
606        assert!(let Ok(input) = result);
607        input
608    }
609
610    #[test]
611    fn minimal_resource_with_one_simple_attribute() {
612        let input = parse_resource(quote! {
613            name = Foo;
614
615            attributes {
616                id String;
617            }
618        });
619
620        check!(input.name.len() == 1);
621        check!(input.name[0] == "Foo");
622
623        check!(input.attributes.len() == 1);
624        let attr = &input.attributes[0];
625        check!(attr.name == "id");
626        check!(!attr.primary_key.value());
627        check!(!attr.generated.value());
628        check!(attr.writable.value());
629        check!(attr.default.is_none());
630
631        check!(input.actions.is_empty());
632    }
633
634    #[test]
635    fn dotted_name_parses_into_multiple_segments() {
636        let input = parse_resource(quote! {
637            name = Helpdesk.Support.Ticket;
638
639            attributes {
640                id String;
641            }
642        });
643
644        check!(input.name.len() == 3);
645        check!(input.name[0] == "Helpdesk");
646        check!(input.name[1] == "Support");
647        check!(input.name[2] == "Ticket");
648    }
649
650    #[test]
651    fn attribute_with_options_block() {
652        let input = parse_resource(quote! {
653            name = Ticket;
654
655            attributes {
656                ticket_id Uuid {
657                    primary_key true;
658                    writable false;
659                    default || uuid::Uuid::new_v4();
660                }
661            }
662        });
663
664        check!(input.attributes.len() == 1);
665        let attr = &input.attributes[0];
666        check!(attr.name == "ticket_id");
667        check!(attr.primary_key.value());
668        check!(!attr.writable.value());
669        check!(attr.default.is_some());
670    }
671
672    #[test]
673    fn attribute_with_generated_flag() {
674        let input = parse_resource(quote! {
675            name = Item;
676
677            attributes {
678                item_id Uuid {
679                    primary_key true;
680                    generated true;
681                }
682            }
683        });
684
685        let attr = &input.attributes[0];
686        check!(attr.generated.value());
687        check!(attr.primary_key.value());
688        // writable should still be the default (true) since it wasn't set.
689        check!(attr.writable.value());
690    }
691
692    #[test]
693    fn multiple_attributes_mixed_simple_and_complex() {
694        let input = parse_resource(quote! {
695            name = Order;
696
697            attributes {
698                order_id Uuid {
699                    primary_key true;
700                    writable false;
701                }
702                item_name String;
703                quantity u32;
704            }
705        });
706
707        check!(input.attributes.len() == 3);
708
709        check!(input.attributes[0].name == "order_id");
710        check!(input.attributes[0].primary_key.value());
711        check!(!input.attributes[0].writable.value());
712
713        check!(input.attributes[1].name == "item_name");
714        check!(!input.attributes[1].primary_key.value());
715        check!(input.attributes[1].writable.value());
716
717        check!(input.attributes[2].name == "quantity");
718        check!(!input.attributes[2].primary_key.value());
719        check!(input.attributes[2].writable.value());
720    }
721
722    #[test]
723    fn actions_block_with_simple_create() {
724        let input = parse_resource(quote! {
725            name = Ticket;
726
727            attributes {
728                id String;
729            }
730
731            actions {
732                create open;
733            }
734        });
735
736        check!(input.actions.len() == 1);
737        check!(input.actions[0].name == "open");
738        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
739    }
740
741    #[test]
742    fn action_with_accept_list() {
743        let input = parse_resource(quote! {
744            name = Ticket;
745
746            attributes {
747                id String;
748            }
749
750            actions {
751                create assign {
752                    accept [subject];
753                };
754            }
755        });
756
757        check!(input.actions.len() == 1);
758        check!(input.actions[0].name == "assign");
759        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[0].kind);
760        check!(idents.len() == 1);
761        check!(idents[0] == "subject");
762    }
763
764    #[test]
765    fn no_actions_block_omitted() {
766        let input = parse_resource(quote! {
767            name = Simple;
768
769            attributes {
770                id u64;
771            }
772        });
773
774        check!(input.actions.is_empty());
775    }
776
777    #[test]
778    fn full_helpdesk_example() {
779        let input = parse_resource(quote! {
780            name = Helpdesk.Support.Ticket;
781
782            attributes {
783                ticket_id Uuid {
784                    primary_key true;
785                    writable false;
786                    default || uuid::Uuid::new_v4();
787                }
788
789                subject String;
790
791                status TicketStatus;
792            }
793
794            actions {
795                create open;
796
797                create assign {
798                    accept [subject];
799                };
800            }
801        });
802
803        check!(input.name.len() == 3);
804        check!(input.name[0] == "Helpdesk");
805        check!(input.name[1] == "Support");
806        check!(input.name[2] == "Ticket");
807
808        check!(input.attributes.len() == 3);
809
810        let ticket_id = &input.attributes[0];
811        check!(ticket_id.name == "ticket_id");
812        check!(ticket_id.primary_key.value());
813        check!(!ticket_id.writable.value());
814        check!(ticket_id.default.is_some());
815
816        let subject = &input.attributes[1];
817        check!(subject.name == "subject");
818        check!(!subject.primary_key.value());
819        check!(subject.writable.value());
820        check!(subject.default.is_none());
821
822        let status = &input.attributes[2];
823        check!(status.name == "status");
824        check!(!status.primary_key.value());
825        check!(status.writable.value());
826
827        check!(input.actions.len() == 2);
828
829        check!(input.actions[0].name == "open");
830        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = &input.actions[0].kind);
831
832        check!(input.actions[1].name == "assign");
833        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = &input.actions[1].kind);
834        check!(idents.len() == 1);
835        check!(idents[0] == "subject");
836    }
837
838    #[test]
839    fn parse_simple_create_action() {
840        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
841            create open;
842        }));
843
844        check!(action.name == "open");
845        check!(let ResourceActionInputKind::Create { accept: Accept::Default } = action.kind);
846    }
847
848    #[test]
849    fn parse_create_action_with_multiple_accept_idents() {
850        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
851            create bulk_insert {
852                accept [name, email, age];
853            }
854        }));
855
856        check!(action.name == "bulk_insert");
857        assert!(let ResourceActionInputKind::Create { accept: Accept::Only(idents) } = action.kind);
858        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
859        check!(names == vec!["name", "email", "age"]);
860    }
861
862    #[test]
863    fn unknown_action_kind_produces_error() {
864        let result = syn::parse2::<ResourceActionInput>(quote! {
865            frobnicate foo;
866        });
867
868        assert!(let Err(err) = result);
869        let msg = err.to_string();
870        check!(msg.contains("Unexpected action kind"));
871        check!(msg.contains("frobnicate"));
872    }
873
874    #[test]
875    fn unknown_attribute_option_produces_error() {
876        let result = syn::parse2::<ResourceMacroInput>(quote! {
877            name = Thing;
878
879            attributes {
880                id String {
881                    bogus true;
882                }
883            }
884        });
885
886        assert!(let Err(err) = result);
887        let msg = err.to_string();
888        check!(msg.contains("Unexpected attribute key"));
889        check!(msg.contains("bogus"));
890    }
891
892    #[test]
893    fn missing_semicolon_after_name_produces_error() {
894        let result = syn::parse2::<ResourceMacroInput>(quote! {
895            name = Foo
896
897            attributes {
898                id String;
899            }
900        });
901
902        check!(let Err(_) = result);
903    }
904
905    #[test]
906    fn parse_simple_update_action_with_default_accept() {
907        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
908            update close;
909        }));
910
911        check!(action.name == "close");
912        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
913        check!(let Accept::Default = update.accept);
914        check!(update.changes.is_empty());
915    }
916
917    #[test]
918    fn parse_update_action_with_accept_and_change_ref() {
919        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
920            update close {
921                accept [];
922                change_ref |resource| {
923                    resource.status = TicketStatus::Closed;
924                };
925            }
926        }));
927
928        check!(action.name == "close");
929        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
930        assert!(let Accept::Only(idents) = &update.accept);
931        check!(idents.is_empty());
932        check!(update.changes.len() == 1);
933        check!(let UpdateChange::ChangeRef(_) = &update.changes[0]);
934    }
935
936    #[test]
937    fn parse_update_action_with_accept_fields() {
938        assert!(let Ok(action) = syn::parse2::<ResourceActionInput>(quote! {
939            update reassign {
940                accept [subject, status];
941            }
942        }));
943
944        check!(action.name == "reassign");
945        assert!(let ResourceActionInputKind::Update(update) = &action.kind);
946        assert!(let Accept::Only(idents) = &update.accept);
947        let names: Vec<String> = idents.iter().map(|i| i.to_string()).collect();
948        check!(names == vec!["subject", "status"]);
949        check!(update.changes.is_empty());
950    }
951}