Skip to main content

forge_codegen/
parser.rs

1//! Rust source code parser for extracting FORGE schema definitions.
2//!
3//! Parses Rust source files using `syn` to extract model, enum, and function
4//! definitions without requiring compilation.
5//!
6//! Key design decisions:
7//! - Context arguments are detected structurally (type ends with "Context"),
8//!   not by string-searching the entire token stream.
9//! - Unparseable inner types become `RustType::Custom(original_string)` instead
10//!   of silently falling back to `String`.
11//! - `NaiveTime` correctly maps to `RustType::LocalTime`.
12
13use 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
40/// Parse all Rust source files in a directory and extract schema definitions.
41pub 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, &registry) {
51            tracing::debug!(file = ?path, error = %e, "Failed to parse file");
52        }
53    }
54
55    Ok(registry)
56}
57
58/// Parse a single Rust source file and extract schema definitions.
59fn 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
97// ---------------------------------------------------------------------------
98// Attribute detection
99// ---------------------------------------------------------------------------
100
101/// Check if attributes contain `#[forge::name]` or `#[name]`.
102fn 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
114/// Check if attributes contain `#[forge_enum]`, `#[enum_type]`, or `#[forge::enum_type]`.
115fn 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
129/// Check if attributes contain `#[derive(...Serialize...)]` or `#[derive(...Deserialize...)]`.
130fn 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
140// ---------------------------------------------------------------------------
141// Struct/model parsing
142// ---------------------------------------------------------------------------
143
144/// Parse a DTO struct (with Serialize/Deserialize) into a TableDef.
145fn 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
164/// Parse a struct with `#[model]` attribute into a TableDef.
165fn 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
196// ---------------------------------------------------------------------------
197// Enum parsing
198// ---------------------------------------------------------------------------
199
200fn 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
223// ---------------------------------------------------------------------------
224// Function parsing
225// ---------------------------------------------------------------------------
226
227/// Parse a function with a forge decorator attribute.
228fn 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    // Parse arguments, skipping the context parameter.
242    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
263/// Check if a type is a Forge context type by examining the base type name.
264///
265/// Handles references (`&Context`, `&mut Context`) and qualified paths
266/// (`forge::QueryContext`). Only matches types whose final segment ends
267/// with "Context" — won't match `ContextManager` or `NoContextHere`.
268fn is_context_type(ty: &syn::Type) -> bool {
269    // Get the type string, stripping whitespace for uniform matching.
270    let type_str = ty.to_token_stream().to_string().replace(' ', "");
271
272    // Strip leading references: &, &mut
273    let base = type_str.trim_start_matches('&').trim_start_matches("mut");
274
275    // Get the final path segment (after any :: qualifiers).
276    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
306// ---------------------------------------------------------------------------
307// Type conversion
308// ---------------------------------------------------------------------------
309
310/// Extract the inner type from `Result<T, E>`.
311fn 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        // Find the inner type (T) before the comma or closing bracket.
316        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
351/// Convert a `syn::Type` to `RustType`.
352fn 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
374/// Handle generic types (`Option<T>`, `Vec<T>`) and custom types.
375fn parse_generic_or_custom(type_str: &str) -> RustType {
376    // Option<T>
377    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    // Vec<T>
386    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    // Everything else is a custom type.
398    RustType::Custom(type_str.to_string())
399}
400
401/// Parse an inner type string, falling back to Custom on failure.
402fn 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
415// ---------------------------------------------------------------------------
416// Attribute value helpers
417// ---------------------------------------------------------------------------
418
419/// Get `#[table(name = "...")]` value from attributes.
420fn 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
434/// Get string value from attribute like `#[attr = "value"]`.
435fn 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
445/// Get documentation comment from attributes.
446fn 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
470/// Extract name value from `name = "value"` format.
471fn 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
481// ---------------------------------------------------------------------------
482// Pluralization
483// ---------------------------------------------------------------------------
484
485/// Simple English pluralization for table names.
486fn 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, &registry).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, &registry).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, &registry).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, &registry).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        // A type ending with "Context" should be detected.
620        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, &registry).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); // Only `id`, context was skipped.
632        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        // A type NOT ending with "Context" should not be skipped.
638        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, &registry).expect("non-context query should parse");
646        let func = registry
647            .get_function("test")
648            .expect("test function should be registered");
649        // "ContextManager" ends with "Manager", not "Context", so both args kept.
650        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, &registry).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    // --- End-to-end pipeline tests ---
677
678    /// Simulates a realistic Forge project with models, enums, queries, mutations,
679    /// jobs, and workflows, then verifies the full parse pipeline produces
680    /// consistent and complete schema.
681    #[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, &registry).expect("realistic project should parse");
758
759        // Models
760        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        // Enum
766        let role_enum = registry.get_enum("UserRole").expect("UserRole enum");
767        assert_eq!(role_enum.variants.len(), 3);
768
769        // DTO
770        let args = registry
771            .get_table("CreateUserArgs")
772            .expect("CreateUserArgs DTO");
773        assert_eq!(args.fields.len(), 3);
774
775        // Functions
776        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        // Verify function details
810        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    /// Verify that BindingSet correctly groups and filters functions.
828    #[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, &registry).expect("parse");
851
852        let bindings = BindingSet::from_registry(&registry);
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        // Crons must NOT appear in client bindings
858    }
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, &registry).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, &registry).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}