1use std::path::{Path, PathBuf};
14
15use forge_core::schema::{
16 EnumDef, EnumVariant, FieldDef, FunctionArg, FunctionDef, FunctionKind, RustType,
17 SchemaRegistry, TableDef,
18};
19use forge_core::util::to_snake_case;
20use quote::ToTokens;
21use syn::{Attribute, Expr, Fields, FnArg, Lit, Meta, Pat, ReturnType};
22
23use crate::Error;
24
25fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
26 let entries = match std::fs::read_dir(dir) {
27 Ok(e) => e,
28 Err(_) => return,
29 };
30 for entry in entries.flatten() {
31 let path = entry.path();
32 if path.is_dir() {
33 collect_rs_files(&path, out);
34 } else if path.extension().is_some_and(|ext| ext == "rs") {
35 out.push(path);
36 }
37 }
38}
39
40pub fn parse_project(src_dir: &Path) -> Result<SchemaRegistry, Error> {
42 let registry = SchemaRegistry::new();
43
44 let mut files = Vec::new();
45 collect_rs_files(src_dir, &mut files);
46 files.sort();
47
48 for path in &files {
49 let content = std::fs::read_to_string(path)?;
50 if let Err(e) = parse_file(&content, ®istry) {
51 tracing::debug!(file = ?path, error = %e, "Failed to parse file");
52 }
53 }
54
55 Ok(registry)
56}
57
58fn parse_file(content: &str, registry: &SchemaRegistry) -> Result<(), Error> {
60 let file = syn::parse_file(content).map_err(|e| Error::Parse {
61 file: String::new(),
62 message: e.to_string(),
63 })?;
64
65 for item in file.items {
66 match item {
67 syn::Item::Struct(item_struct) => {
68 if has_forge_attr(&item_struct.attrs, "model") {
69 if let Some(table) = parse_model(&item_struct) {
70 registry.register_table(table);
71 }
72 } else if has_serde_derive(&item_struct.attrs)
73 && let Some(table) = parse_dto_struct(&item_struct)
74 {
75 registry.register_table(table);
76 }
77 }
78 syn::Item::Enum(item_enum) => {
79 if (has_forge_enum_attr(&item_enum.attrs) || has_serde_derive(&item_enum.attrs))
80 && let Some(enum_def) = parse_enum(&item_enum)
81 {
82 registry.register_enum(enum_def);
83 }
84 }
85 syn::Item::Fn(item_fn) => {
86 if let Some(func) = parse_function(&item_fn) {
87 registry.register_function(func);
88 }
89 }
90 _ => {}
91 }
92 }
93
94 Ok(())
95}
96
97fn has_forge_attr(attrs: &[Attribute], name: &str) -> bool {
103 attrs.iter().any(|attr| {
104 let path = attr.path();
105 path.is_ident(name)
106 || matches!(
107 (path.segments.first(), path.segments.get(1), path.segments.get(2)),
108 (Some(first), Some(second), None)
109 if first.ident == "forge" && second.ident == name
110 )
111 })
112}
113
114fn has_forge_enum_attr(attrs: &[Attribute]) -> bool {
116 attrs.iter().any(|attr| {
117 let path = attr.path();
118 path.is_ident("forge_enum")
119 || path.is_ident("enum_type")
120 || matches!(
121 (path.segments.first(), path.segments.get(1), path.segments.get(2)),
122 (Some(first), Some(second), None)
123 if first.ident == "forge"
124 && (second.ident == "enum_type" || second.ident == "forge_enum")
125 )
126 })
127}
128
129fn has_serde_derive(attrs: &[Attribute]) -> bool {
131 attrs.iter().any(|attr| {
132 if !attr.path().is_ident("derive") {
133 return false;
134 }
135 let tokens = attr.meta.to_token_stream().to_string();
136 tokens.contains("Serialize") || tokens.contains("Deserialize")
137 })
138}
139
140fn parse_dto_struct(item: &syn::ItemStruct) -> Option<TableDef> {
146 let struct_name = item.ident.to_string();
147 let mut table = TableDef::new(&struct_name, &struct_name);
148 table.is_dto = true;
149 table.doc = get_doc_comment(&item.attrs);
150
151 if let Fields::Named(fields) = &item.fields {
152 for field in &fields.named {
153 if let Some(field_name) = &field.ident {
154 table
155 .fields
156 .push(parse_field(field_name.to_string(), &field.ty, &field.attrs));
157 }
158 }
159 }
160
161 Some(table)
162}
163
164fn parse_model(item: &syn::ItemStruct) -> Option<TableDef> {
166 let struct_name = item.ident.to_string();
167 let table_name = get_table_name_from_attrs(&item.attrs).unwrap_or_else(|| {
168 let snake = to_snake_case(&struct_name);
169 pluralize(&snake)
170 });
171
172 let mut table = TableDef::new(&table_name, &struct_name);
173 table.doc = get_doc_comment(&item.attrs);
174
175 if let Fields::Named(fields) = &item.fields {
176 for field in &fields.named {
177 if let Some(field_name) = &field.ident {
178 table
179 .fields
180 .push(parse_field(field_name.to_string(), &field.ty, &field.attrs));
181 }
182 }
183 }
184
185 Some(table)
186}
187
188fn parse_field(name: String, ty: &syn::Type, attrs: &[Attribute]) -> FieldDef {
189 let rust_type = type_to_rust_type(ty);
190 let mut field = FieldDef::new(&name, rust_type);
191 field.column_name = to_snake_case(&name);
192 field.doc = get_doc_comment(attrs);
193 field
194}
195
196fn parse_enum(item: &syn::ItemEnum) -> Option<EnumDef> {
201 let enum_name = item.ident.to_string();
202 let mut enum_def = EnumDef::new(&enum_name);
203 enum_def.doc = get_doc_comment(&item.attrs);
204
205 for variant in &item.variants {
206 let variant_name = variant.ident.to_string();
207 let mut enum_variant = EnumVariant::new(&variant_name);
208 enum_variant.doc = get_doc_comment(&variant.attrs);
209
210 if let Some((_, Expr::Lit(lit))) = &variant.discriminant
211 && let Lit::Int(int_lit) = &lit.lit
212 && let Ok(value) = int_lit.base10_parse::<i32>()
213 {
214 enum_variant.int_value = Some(value);
215 }
216
217 enum_def.variants.push(enum_variant);
218 }
219
220 Some(enum_def)
221}
222
223fn parse_function(item: &syn::ItemFn) -> Option<FunctionDef> {
229 let kind = get_function_kind(&item.attrs)?;
230 let func_name = item.sig.ident.to_string();
231
232 let return_type = match &item.sig.output {
233 ReturnType::Default => RustType::Custom("()".to_string()),
234 ReturnType::Type(_, ty) => extract_result_type(ty),
235 };
236
237 let mut func = FunctionDef::new(&func_name, kind, return_type);
238 func.doc = get_doc_comment(&item.attrs);
239 func.is_async = item.sig.asyncness.is_some();
240
241 let mut is_first = true;
243 for arg in &item.sig.inputs {
244 if let FnArg::Typed(pat_type) = arg {
245 if is_first {
246 is_first = false;
247 if is_context_type(&pat_type.ty) {
248 continue;
249 }
250 }
251
252 if let Pat::Ident(pat_ident) = &*pat_type.pat {
253 let arg_name = pat_ident.ident.to_string();
254 let arg_type = type_to_rust_type(&pat_type.ty);
255 func.args.push(FunctionArg::new(arg_name, arg_type));
256 }
257 }
258 }
259
260 Some(func)
261}
262
263fn is_context_type(ty: &syn::Type) -> bool {
269 let type_str = ty.to_token_stream().to_string().replace(' ', "");
271
272 let base = type_str.trim_start_matches('&').trim_start_matches("mut");
274
275 let final_segment = base.rsplit("::").next().unwrap_or(base);
277
278 final_segment.ends_with("Context")
279}
280
281fn get_function_kind(attrs: &[Attribute]) -> Option<FunctionKind> {
282 for attr in attrs {
283 let path = attr.path();
284 let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect();
285
286 let kind_str = match segments.as_slice() {
287 [forge, kind] if forge == "forge" => Some(kind.as_str()),
288 [kind] => Some(kind.as_str()),
289 _ => None,
290 };
291
292 if let Some(kind) = kind_str {
293 match kind {
294 "query" => return Some(FunctionKind::Query),
295 "mutation" => return Some(FunctionKind::Mutation),
296 "job" => return Some(FunctionKind::Job),
297 "cron" => return Some(FunctionKind::Cron),
298 "workflow" => return Some(FunctionKind::Workflow),
299 _ => {}
300 }
301 }
302 }
303 None
304}
305
306fn extract_result_type(ty: &syn::Type) -> RustType {
312 let type_str = quote::quote!(#ty).to_string().replace(' ', "");
313
314 if let Some(rest) = type_str.strip_prefix("Result<") {
315 let mut depth = 0;
317 let mut end_idx = 0;
318 for (i, c) in rest.chars().enumerate() {
319 match c {
320 '<' => depth += 1,
321 '>' => {
322 if depth == 0 {
323 end_idx = i;
324 break;
325 }
326 depth -= 1;
327 }
328 ',' if depth == 0 => {
329 end_idx = i;
330 break;
331 }
332 _ => {}
333 }
334 }
335 let inner = &rest[..end_idx];
336 return match syn::parse_str::<syn::Type>(inner) {
337 Ok(inner_ty) => type_to_rust_type(&inner_ty),
338 Err(_) => {
339 tracing::warn!(
340 "Could not parse Result inner type '{}', treating as custom type",
341 inner
342 );
343 RustType::Custom(inner.to_string())
344 }
345 };
346 }
347
348 type_to_rust_type(ty)
349}
350
351fn type_to_rust_type(ty: &syn::Type) -> RustType {
353 let type_str = quote::quote!(#ty).to_string().replace(' ', "");
354
355 match type_str.as_str() {
356 "String" | "&str" => RustType::String,
357 "i32" => RustType::I32,
358 "i64" => RustType::I64,
359 "f32" => RustType::F32,
360 "f64" => RustType::F64,
361 "bool" => RustType::Bool,
362 "Uuid" | "uuid::Uuid" => RustType::Uuid,
363 "DateTime<Utc>" | "chrono::DateTime<Utc>" | "chrono::DateTime<chrono::Utc>" => {
364 RustType::Instant
365 }
366 "NaiveDate" | "chrono::NaiveDate" => RustType::LocalDate,
367 "NaiveTime" | "chrono::NaiveTime" => RustType::LocalTime,
368 "serde_json::Value" | "Value" => RustType::Json,
369 "Vec<u8>" => RustType::Bytes,
370 _ => parse_generic_or_custom(&type_str),
371 }
372}
373
374fn parse_generic_or_custom(type_str: &str) -> RustType {
376 if let Some(inner) = type_str
378 .strip_prefix("Option<")
379 .and_then(|s| s.strip_suffix('>'))
380 {
381 let inner_type = parse_inner_type(inner);
382 return RustType::Option(Box::new(inner_type));
383 }
384
385 if let Some(inner) = type_str
387 .strip_prefix("Vec<")
388 .and_then(|s| s.strip_suffix('>'))
389 {
390 if inner == "u8" {
391 return RustType::Bytes;
392 }
393 let inner_type = parse_inner_type(inner);
394 return RustType::Vec(Box::new(inner_type));
395 }
396
397 RustType::Custom(type_str.to_string())
399}
400
401fn parse_inner_type(inner: &str) -> RustType {
403 match syn::parse_str::<syn::Type>(inner) {
404 Ok(inner_ty) => type_to_rust_type(&inner_ty),
405 Err(_) => {
406 tracing::warn!(
407 "Could not parse inner type '{}', treating as custom type",
408 inner
409 );
410 RustType::Custom(inner.to_string())
411 }
412 }
413}
414
415fn get_table_name_from_attrs(attrs: &[Attribute]) -> Option<String> {
421 for attr in attrs {
422 if attr.path().is_ident("table")
423 && let Meta::List(list) = &attr.meta
424 {
425 let tokens = list.tokens.to_string();
426 if let Some(value) = extract_name_value(&tokens) {
427 return Some(value);
428 }
429 }
430 }
431 None
432}
433
434fn get_attribute_string_value(attr: &Attribute) -> Option<String> {
436 if let Meta::NameValue(nv) = &attr.meta
437 && let Expr::Lit(lit) = &nv.value
438 && let Lit::Str(s) = &lit.lit
439 {
440 return Some(s.value());
441 }
442 None
443}
444
445fn get_doc_comment(attrs: &[Attribute]) -> Option<String> {
447 let docs: Vec<String> = attrs
448 .iter()
449 .filter_map(|attr| {
450 if attr.path().is_ident("doc") {
451 get_attribute_string_value(attr)
452 } else {
453 None
454 }
455 })
456 .collect();
457
458 if docs.is_empty() {
459 None
460 } else {
461 Some(
462 docs.into_iter()
463 .map(|s| s.trim().to_string())
464 .collect::<Vec<_>>()
465 .join("\n"),
466 )
467 }
468}
469
470fn extract_name_value(s: &str) -> Option<String> {
472 if let Some((_, value)) = s.split_once('=') {
473 let value = value.trim();
474 if let Some(stripped) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
475 return Some(stripped.to_string());
476 }
477 }
478 None
479}
480
481fn pluralize(s: &str) -> String {
487 if s.ends_with('s')
488 || s.ends_with("sh")
489 || s.ends_with("ch")
490 || s.ends_with('x')
491 || s.ends_with('z')
492 {
493 format!("{}es", s)
494 } else if let Some(stem) = s.strip_suffix('y') {
495 if !s.ends_with("ay") && !s.ends_with("ey") && !s.ends_with("oy") && !s.ends_with("uy") {
496 format!("{}ies", stem)
497 } else {
498 format!("{}s", s)
499 }
500 } else {
501 format!("{}s", s)
502 }
503}
504
505#[cfg(test)]
506#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_parse_model_source() {
512 let source = r#"
513 #[model]
514 struct User {
515 #[id]
516 id: Uuid,
517 email: String,
518 name: Option<String>,
519 #[indexed]
520 created_at: DateTime<Utc>,
521 }
522 "#;
523
524 let registry = SchemaRegistry::new();
525 parse_file(source, ®istry).expect("model source should parse");
526
527 let table = registry
528 .get_table("users")
529 .expect("users table should be registered");
530 assert_eq!(table.struct_name, "User");
531 assert_eq!(table.fields.len(), 4);
532 }
533
534 #[test]
535 fn test_parse_enum_source() {
536 let source = r#"
537 #[forge_enum]
538 enum ProjectStatus {
539 Draft,
540 Active,
541 Completed,
542 }
543 "#;
544
545 let registry = SchemaRegistry::new();
546 parse_file(source, ®istry).expect("enum source should parse");
547
548 let enum_def = registry
549 .get_enum("ProjectStatus")
550 .expect("ProjectStatus enum should be registered");
551 assert_eq!(enum_def.variants.len(), 3);
552 }
553
554 #[test]
555 fn test_to_snake_case() {
556 assert_eq!(to_snake_case("UserProfile"), "user_profile");
557 assert_eq!(to_snake_case("ID"), "i_d");
558 assert_eq!(to_snake_case("createdAt"), "created_at");
559 }
560
561 #[test]
562 fn test_pluralize() {
563 assert_eq!(pluralize("user"), "users");
564 assert_eq!(pluralize("category"), "categories");
565 assert_eq!(pluralize("box"), "boxes");
566 assert_eq!(pluralize("address"), "addresses");
567 }
568
569 #[test]
570 fn test_parse_query_function() {
571 let source = r#"
572 #[query]
573 async fn get_user(ctx: QueryContext, id: Uuid) -> Result<User> {
574 todo!()
575 }
576 "#;
577
578 let registry = SchemaRegistry::new();
579 parse_file(source, ®istry).expect("query function should parse");
580
581 let func = registry
582 .get_function("get_user")
583 .expect("get_user function should be registered");
584 assert_eq!(func.name, "get_user");
585 assert_eq!(func.kind, FunctionKind::Query);
586 assert!(func.is_async);
587 }
588
589 #[test]
590 fn test_parse_mutation_function() {
591 let source = r#"
592 #[mutation]
593 async fn create_user(ctx: MutationContext, name: String, email: String) -> Result<User> {
594 todo!()
595 }
596 "#;
597
598 let registry = SchemaRegistry::new();
599 parse_file(source, ®istry).expect("mutation function should parse");
600
601 let func = registry
602 .get_function("create_user")
603 .expect("create_user function should be registered");
604 assert_eq!(func.name, "create_user");
605 assert_eq!(func.kind, FunctionKind::Mutation);
606 assert_eq!(func.args.len(), 2);
607 assert_eq!(
608 func.args.first().expect("name arg should exist").name,
609 "name"
610 );
611 assert_eq!(
612 func.args.get(1).expect("email arg should exist").name,
613 "email"
614 );
615 }
616
617 #[test]
618 fn test_context_detection_structural() {
619 let source = r#"
621 #[query]
622 async fn test(ctx: forge::QueryContext, id: Uuid) -> Result<User> {
623 todo!()
624 }
625 "#;
626 let registry = SchemaRegistry::new();
627 parse_file(source, ®istry).expect("context query should parse");
628 let func = registry
629 .get_function("test")
630 .expect("test function should be registered");
631 assert_eq!(func.args.len(), 1); assert_eq!(func.args.first().expect("id arg should exist").name, "id");
633 }
634
635 #[test]
636 fn test_context_detection_does_not_match_other_types() {
637 let source = r#"
639 #[query]
640 async fn test(data: ContextManager, id: Uuid) -> Result<User> {
641 todo!()
642 }
643 "#;
644 let registry = SchemaRegistry::new();
645 parse_file(source, ®istry).expect("non-context query should parse");
646 let func = registry
647 .get_function("test")
648 .expect("test function should be registered");
649 assert_eq!(func.args.len(), 2);
651 }
652
653 #[test]
654 fn test_naive_time_maps_to_local_time() {
655 let source = r#"
656 #[derive(Serialize, Deserialize)]
657 struct Schedule {
658 start_time: NaiveTime,
659 }
660 "#;
661 let registry = SchemaRegistry::new();
662 parse_file(source, ®istry).expect("schedule DTO should parse");
663 let table = registry
664 .get_table("Schedule")
665 .expect("Schedule table should be registered");
666 assert_eq!(
667 table
668 .fields
669 .first()
670 .expect("start_time field should exist")
671 .rust_type,
672 RustType::LocalTime
673 );
674 }
675
676 #[test]
682 fn end_to_end_realistic_schema_pipeline() {
683 let source = r#"
684 use forge::prelude::*;
685
686 #[model]
687 struct User {
688 id: Uuid,
689 email: String,
690 name: Option<String>,
691 role: UserRole,
692 created_at: DateTime<Utc>,
693 }
694
695 #[model]
696 struct Post {
697 id: Uuid,
698 title: String,
699 body: String,
700 author_id: Uuid,
701 published: bool,
702 view_count: i64,
703 created_at: DateTime<Utc>,
704 }
705
706 #[forge_enum]
707 enum UserRole {
708 Admin,
709 Member,
710 Guest,
711 }
712
713 #[derive(Serialize, Deserialize)]
714 struct CreateUserArgs {
715 email: String,
716 name: Option<String>,
717 role: UserRole,
718 }
719
720 #[query]
721 async fn get_users(ctx: QueryContext) -> Result<Vec<User>> {
722 todo!()
723 }
724
725 #[query]
726 async fn get_user(ctx: QueryContext, id: Uuid) -> Result<User> {
727 todo!()
728 }
729
730 #[mutation]
731 async fn create_user(ctx: MutationContext, args: CreateUserArgs) -> Result<User> {
732 todo!()
733 }
734
735 #[mutation]
736 async fn delete_user(ctx: MutationContext, id: Uuid) -> Result<()> {
737 todo!()
738 }
739
740 #[job]
741 async fn send_welcome_email(ctx: JobContext, user_id: Uuid) -> Result<()> {
742 todo!()
743 }
744
745 #[workflow]
746 async fn onboarding(ctx: WorkflowContext, user_id: Uuid) -> Result<String> {
747 todo!()
748 }
749
750 #[cron]
751 async fn daily_cleanup(ctx: CronContext) -> Result<()> {
752 todo!()
753 }
754 "#;
755
756 let registry = SchemaRegistry::new();
757 parse_file(source, ®istry).expect("realistic project should parse");
758
759 let users = registry.get_table("users").expect("users table");
761 assert_eq!(users.fields.len(), 5);
762 let posts = registry.get_table("posts").expect("posts table");
763 assert_eq!(posts.fields.len(), 7);
764
765 let role_enum = registry.get_enum("UserRole").expect("UserRole enum");
767 assert_eq!(role_enum.variants.len(), 3);
768
769 let args = registry
771 .get_table("CreateUserArgs")
772 .expect("CreateUserArgs DTO");
773 assert_eq!(args.fields.len(), 3);
774
775 let all_fns = registry.all_functions();
777 assert_eq!(all_fns.len(), 7);
778
779 let queries: Vec<_> = all_fns
780 .iter()
781 .filter(|f| f.kind == FunctionKind::Query)
782 .collect();
783 assert_eq!(queries.len(), 2);
784
785 let mutations: Vec<_> = all_fns
786 .iter()
787 .filter(|f| f.kind == FunctionKind::Mutation)
788 .collect();
789 assert_eq!(mutations.len(), 2);
790
791 let jobs: Vec<_> = all_fns
792 .iter()
793 .filter(|f| f.kind == FunctionKind::Job)
794 .collect();
795 assert_eq!(jobs.len(), 1);
796
797 let workflows: Vec<_> = all_fns
798 .iter()
799 .filter(|f| f.kind == FunctionKind::Workflow)
800 .collect();
801 assert_eq!(workflows.len(), 1);
802
803 let crons: Vec<_> = all_fns
804 .iter()
805 .filter(|f| f.kind == FunctionKind::Cron)
806 .collect();
807 assert_eq!(crons.len(), 1);
808
809 let get_users = registry.get_function("get_users").expect("get_users");
811 assert!(
812 get_users.args.is_empty(),
813 "get_users has no user args (context stripped)"
814 );
815
816 let create_user = registry.get_function("create_user").expect("create_user");
817 assert_eq!(create_user.args.len(), 1, "create_user has one user arg");
818 assert_eq!(create_user.args.first().expect("arg").name, "args");
819
820 let send_email = registry
821 .get_function("send_welcome_email")
822 .expect("send_welcome_email");
823 assert_eq!(send_email.kind, FunctionKind::Job);
824 assert_eq!(send_email.args.len(), 1);
825 }
826
827 #[test]
829 fn binding_set_from_mixed_schema() {
830 use crate::binding::BindingSet;
831
832 let source = r#"
833 #[query]
834 async fn list_items(ctx: QueryContext) -> Result<Vec<Item>> { todo!() }
835
836 #[mutation]
837 async fn add_item(ctx: MutationContext, name: String) -> Result<Item> { todo!() }
838
839 #[job]
840 async fn process_item(ctx: JobContext, id: Uuid) -> Result<()> { todo!() }
841
842 #[cron]
843 async fn cleanup(ctx: CronContext) -> Result<()> { todo!() }
844
845 #[workflow]
846 async fn item_pipeline(ctx: WorkflowContext, id: Uuid) -> Result<String> { todo!() }
847 "#;
848
849 let registry = SchemaRegistry::new();
850 parse_file(source, ®istry).expect("parse");
851
852 let bindings = BindingSet::from_registry(®istry);
853 assert_eq!(bindings.queries.len(), 1);
854 assert_eq!(bindings.mutations.len(), 1);
855 assert_eq!(bindings.jobs.len(), 1);
856 assert_eq!(bindings.workflows.len(), 1);
857 }
859
860 #[test]
861 fn parse_function_with_multiple_args() {
862 let source = r#"
863 #[mutation]
864 async fn update_user(ctx: MutationContext, id: Uuid, name: String, email: Option<String>) -> Result<User> {
865 todo!()
866 }
867 "#;
868
869 let registry = SchemaRegistry::new();
870 parse_file(source, ®istry).expect("parse");
871
872 let func = registry.get_function("update_user").expect("update_user");
873 assert_eq!(func.args.len(), 3);
874 assert_eq!(func.args.first().expect("id").name, "id");
875 assert_eq!(func.args.get(1).expect("name").name, "name");
876 assert_eq!(func.args.get(2).expect("email").name, "email");
877 }
878
879 #[test]
880 fn parse_function_with_vec_return() {
881 let source = r#"
882 #[query]
883 async fn list_posts(ctx: QueryContext) -> Result<Vec<Post>> {
884 todo!()
885 }
886 "#;
887
888 let registry = SchemaRegistry::new();
889 parse_file(source, ®istry).expect("parse");
890
891 let func = registry.get_function("list_posts").expect("list_posts");
892 match &func.return_type {
893 RustType::Vec(inner) => match inner.as_ref() {
894 RustType::Custom(name) => assert_eq!(name, "Post"),
895 other => panic!("Expected Custom(Post), got: {other:?}"),
896 },
897 other => panic!("Expected Vec, got: {other:?}"),
898 }
899 }
900}