diff --git a/Cargo.lock b/Cargo.lock index eda7fa6..2e457e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -759,6 +759,7 @@ dependencies = [ "async-trait", "floxide", "floxide-core", + "floxide-macros-support", "heck 0.4.1", "proc-macro2", "quote", @@ -767,9 +768,17 @@ dependencies = [ "syn 2.0.98", "tokio", "tracing", + "trybuild", "uuid", ] +[[package]] +name = "floxide-macros-support" +version = "3.1.0" +dependencies = [ + "syn 2.0.98", +] + [[package]] name = "floxide-redis" version = "3.1.0" @@ -940,6 +949,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "h2" version = "0.4.9" @@ -2278,6 +2293,15 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "serde_spanned" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2535,6 +2559,12 @@ dependencies = [ "libc", ] +[[package]] +name = "target-triple" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac9aa371f599d22256307c24a9d748c041e548cbf599f35d890f9d365361790" + [[package]] name = "tempfile" version = "3.19.1" @@ -2548,6 +2578,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "testcontainers" version = "0.23.3" @@ -2781,6 +2820,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" +dependencies = [ + "indexmap 2.9.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" + [[package]] name = "tower" version = "0.5.2" @@ -2893,6 +2973,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ae08be68c056db96f0e6c6dd820727cca756ced9e1f4cc7fdd20e2a55e23898" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml", +] + [[package]] name = "unicode-ident" version = "1.0.17" @@ -3377,6 +3472,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.33.0" diff --git a/Cargo.toml b/Cargo.toml index 30f0935..c3a5113 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,9 @@ members = [ "crates/floxide-core", "crates/floxide-macros", + "crates/floxide-macros-support", "crates/floxide-redis", - "benches", + "benches", ] resolver = "2" diff --git a/crates/floxide-core/src/retry.rs b/crates/floxide-core/src/retry.rs index 37dafdd..5b23b02 100644 --- a/crates/floxide-core/src/retry.rs +++ b/crates/floxide-core/src/retry.rs @@ -141,7 +141,7 @@ impl RetryPolicy { /// /// # Example /// -/// ```rust +/// ```rust,ignore /// use floxide_core::*; /// use std::time::Duration; /// // Define a policy: up to 3 attempts, exponential backoff 100ms→200ms→400ms diff --git a/crates/floxide-macros-support/Cargo.toml b/crates/floxide-macros-support/Cargo.toml new file mode 100644 index 0000000..231f8c0 --- /dev/null +++ b/crates/floxide-macros-support/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "floxide-macros-support" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +readme.workspace = true +description.workspace = true +keywords.workspace = true +categories.workspace = true +homepage.workspace = true +documentation.workspace = true + +[dependencies] +syn = { version = "2", features = ["full", "parsing"] } diff --git a/crates/floxide-macros-support/src/lib.rs b/crates/floxide-macros-support/src/lib.rs new file mode 100644 index 0000000..6307d95 --- /dev/null +++ b/crates/floxide-macros-support/src/lib.rs @@ -0,0 +1,322 @@ +pub fn add(left: u64, right: u64) -> u64 { + left + right +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let result = add(2, 2); + assert_eq!(result, 4); + } +} + +/// AST and parsing logic for floxide-macros +use syn::{ + braced, bracketed, + parse::{Parse, ParseStream}, + Generics, Ident, Result, Token, Type, Visibility, +}; + +#[derive(Debug, Clone)] +pub struct CompositeArm { + pub action_path: Ident, + pub variant: Ident, + pub binding: Option, + pub is_wildcard: bool, + pub guard: Option, + pub succs: Vec, +} + +#[derive(Debug, Clone)] +pub enum EdgeKind { + Direct { + succs: Vec, + on_failure: Option>, + }, + Composite(Vec), +} + +#[derive(Debug, Clone)] +pub struct WorkflowDef { + pub vis: Visibility, + pub name: Ident, + pub generics: Generics, + pub fields: Vec<(Ident, Type, Option)>, + pub start: Ident, + pub context: Type, + pub edges: Vec<(Ident, EdgeKind)>, +} + +// Parsing logic for WorkflowDef (copy from floxide-macros/src/workflow.rs, adapted to use these types) +impl Parse for WorkflowDef { + fn parse(input: ParseStream) -> Result { + // parse optional visibility + let vis: Visibility = if input.peek(Token![pub]) { + input.parse()? // pub or pub(...) etc + } else { + Visibility::Inherited + }; + // parse struct definition + input.parse::()?; + let name: Ident = input.parse()?; + let generics: Generics = input.parse()?; + // parse struct fields + let content; + braced!(content in input); + let mut fields = Vec::new(); + while !content.is_empty() { + // Optional retry annotation: #[retry = policy] + let mut retry_policy: Option = None; + if content.peek(Token![#]) { + content.parse::()?; + let inner; + bracketed!(inner in content); + let attr_name: Ident = inner.parse()?; + if attr_name == "retry" { + inner.parse::()?; + let pol: Ident = inner.parse()?; + retry_policy = Some(pol); + } else { + return Err(inner.error("unknown attribute, expected `retry`")); + } + } + // Field name and type + let fld: Ident = content.parse()?; + content.parse::()?; + let ty: Type = content.parse()?; + if content.peek(Token![,]) { + content.parse::()?; + } + fields.push((fld, ty, retry_policy)); + } + // Now parse the rest in any order: start, context, edges + let mut start: Option = None; + let mut context: Option = None; + let mut edges: Option> = None; + let mut seen = std::collections::HashSet::new(); + while !input.is_empty() { + if input.peek(Ident) { + let fork = input.fork(); + let kw = fork.parse::()?; + match kw.to_string().as_str() { + "start" => { + if seen.contains("start") { + return Err( + input.error("Duplicate 'start' field in workflow definition.") + ); + } + input.parse::()?; // start + input.parse::()?; + let s: Ident = input.parse()?; + input.parse::()?; + start = Some(s); + seen.insert("start"); + } + "context" => { + if seen.contains("context") { + return Err( + input.error("Duplicate 'context' field in workflow definition.") + ); + } + input.parse::()?; // context + input.parse::()?; + let ty: Type = input.parse()?; + input.parse::()?; + context = Some(ty); + seen.insert("context"); + } + "edges" => { + if seen.contains("edges") { + return Err( + input.error("Duplicate 'edges' field in workflow definition.") + ); + } + input.parse::()?; // edges + let edges_content; + braced!(edges_content in input); + // Collect direct-success, direct-failure, and composite arms + let mut direct_success = + std::collections::HashMap::>::new(); + let mut direct_failure = + std::collections::HashMap::>::new(); + let mut composite_map = + std::collections::HashMap::>::new(); + while !edges_content.is_empty() { + let src: Ident = edges_content.parse()?; + if edges_content.peek(Ident) { + // on_failure clause + let kw: Ident = edges_content.parse()?; + if kw == "on_failure" { + edges_content.parse::]>()?; + let nested; + braced!(nested in edges_content); + // expect bracketed fallback list + let succs_content; + bracketed!(succs_content in nested); + let fails: Vec = succs_content + .parse_terminated(Ident::parse, Token![,])? + .into_iter() + .collect(); + edges_content.parse::()?; + direct_failure.insert(src.clone(), fails); + continue; + } else { + return Err(edges_content.error( + "Unexpected identifier. Expected `on_failure` or `=>`.", + )); + } + } + // success or composite entry + edges_content.parse::]>()?; + if edges_content.peek(syn::token::Bracket) { + // direct edge: foo => [bar]; + let succs_content; + bracketed!(succs_content in edges_content); + let succs: Vec = succs_content + .parse_terminated(Ident::parse, Token![,])? + .into_iter() + .collect(); + edges_content.parse::()?; + direct_success.insert(src.clone(), succs); + } else if edges_content.peek(syn::token::Brace) { + // direct or composite edge: foo => { ... }; + let nested; + braced!(nested in edges_content); + // Empty braces => direct edge with no successors + if nested.is_empty() { + edges_content.parse::()?; + direct_success.insert(src.clone(), Vec::new()); + } else if nested.peek(syn::token::Bracket) { + // legacy: foo => {[bar]}; + let succs_content; + bracketed!(succs_content in nested); + let succs: Vec = succs_content + .parse_terminated(Ident::parse, Token![,])? + .into_iter() + .collect(); + edges_content.parse::()?; + direct_success.insert(src.clone(), succs); + } else { + // composite arms + let mut arms = Vec::new(); + while !nested.is_empty() { + let action_path: Ident = nested.parse()?; + nested.parse::()?; + let variant: Ident = nested.parse()?; + let mut binding = None; + let mut is_wildcard = false; + if nested.peek(syn::token::Paren) { + let inner; + syn::parenthesized!(inner in nested); + if inner.peek(Token![_]) { + inner.parse::()?; + is_wildcard = true; + } else if inner.peek(Ident) { + binding = Some(inner.parse()?); + } else { + return Err(inner.error( + "Expected identifier or _ in variant binding", + )); + } + } else { + // No parens: always treat as wildcard for macro ergonomics + is_wildcard = true; + binding = None; + } + // Optional guard + let guard = if nested.peek(Token![if]) { + nested.parse::()?; + Some(nested.parse()?) + } else { + None + }; + nested.parse::]>()?; + let succs_content; + bracketed!(succs_content in nested); + let succs: Vec = succs_content + .parse_terminated(Ident::parse, Token![,])? + .into_iter() + .collect(); + nested.parse::()?; + let arm = CompositeArm { + action_path: action_path.clone(), + variant: variant.clone(), + binding: binding.clone(), + is_wildcard, + guard: guard.clone(), + succs: succs.clone(), + }; + // Debug log removed: parsed arm + arms.push(arm); + } + edges_content.parse::()?; + // After parsing all arms for a composite edge, print them for debugging + // Debug log removed: parsed composite arms for source + composite_map.insert(src.clone(), arms); + } + } else { + return Err(edges_content + .error("Expected [ or { after => in edge definition")); + } + } + // Merge into final edges vector + let mut edges_vec = Vec::new(); + // direct-success entries + for (src, succs) in direct_success.into_iter() { + let failure = direct_failure.remove(&src); + edges_vec.push(( + src, + EdgeKind::Direct { + succs, + on_failure: failure, + }, + )); + } + // direct-failure-only entries + for (src, fails) in direct_failure.into_iter() { + edges_vec.push(( + src, + EdgeKind::Direct { + succs: Vec::new(), + on_failure: Some(fails), + }, + )); + } + // composite entries + for (src, arms) in composite_map.into_iter() { + edges_vec.push((src, EdgeKind::Composite(arms))); + } + edges = Some(edges_vec); + seen.insert("edges"); + } + other => { + return Err(input.error(format!( + "Unexpected identifier '{}'. Expected one of: start, context, edges.", + other + ))); + } + } + } else { + return Err(input.error("Unexpected token in workflow definition. Expected 'start', 'context', or 'edges'.")); + } + } + // Check required fields + let start = start + .ok_or_else(|| input.error("Missing required 'start' field in workflow definition."))?; + let context = context.unwrap_or_else(|| syn::parse_quote! { () }); + let edges = edges + .ok_or_else(|| input.error("Missing required 'edges' field in workflow definition."))?; + Ok(WorkflowDef { + vis, + name, + generics, + fields, + start, + context, + edges, + }) + } +} diff --git a/crates/floxide-macros/Cargo.toml b/crates/floxide-macros/Cargo.toml index a3c382a..3e216d0 100644 --- a/crates/floxide-macros/Cargo.toml +++ b/crates/floxide-macros/Cargo.toml @@ -13,6 +13,8 @@ proc-macro = true [dependencies] floxide-core = { path = "../floxide-core", version = "=3.1.0" } +floxide-macros-support = { path = "../floxide-macros-support", version = "=3.1.0" } + syn = { version = "2", features = ["full", "extra-traits"] } quote = "1" proc-macro2 = "1" @@ -26,3 +28,4 @@ tokio.workspace = true [dev-dependencies] floxide = { path = "../.." } +trybuild = "1.0.85" diff --git a/crates/floxide-macros/src/workflow.rs b/crates/floxide-macros/src/workflow.rs index 1394ce4..52e0d16 100644 --- a/crates/floxide-macros/src/workflow.rs +++ b/crates/floxide-macros/src/workflow.rs @@ -1,261 +1,8 @@ +use floxide_macros_support::{CompositeArm, EdgeKind, WorkflowDef}; use proc_macro::TokenStream; use proc_macro2::Span; use quote::{format_ident, quote}; -use syn::{ - braced, bracketed, - parse::{Parse, ParseStream}, - parse_macro_input, Generics, Ident, LitStr, Result, Token, Type, Visibility, -}; - -/// AST for struct-based workflow: struct fields, start field, and per-node edges -// Internal representation of a composite edge arm: matches Output enum variant -struct CompositeArm { - action_path: Ident, - variant: Ident, - binding: Ident, - succs: Vec, -} -// AST for struct-based workflow: struct fields, start field, and routing edges -enum EdgeKind { - /// Direct edges: list of successor nodes on success, optional fallback on failure - Direct { - succs: Vec, - on_failure: Option>, - }, - /// Composite edges: match on enum variants - Composite(Vec), -} -struct WorkflowDef { - vis: Visibility, - name: Ident, - generics: Generics, - /// Workflow fields: (name, type, optional retry-policy variable) - fields: Vec<(Ident, Type, Option)>, - start: Ident, - context: Type, - // for each source field, direct successors or composite arms - edges: Vec<(Ident, EdgeKind)>, -} - -impl Parse for WorkflowDef { - fn parse(input: ParseStream) -> Result { - // parse optional visibility - let vis: Visibility = if input.peek(Token![pub]) { - input.parse()? // pub or pub(...) etc - } else { - Visibility::Inherited - }; - // parse struct definition - input.parse::()?; - let name: Ident = input.parse()?; - let generics: Generics = input.parse()?; - // parse struct fields - let content; - braced!(content in input); - let mut fields = Vec::new(); - while !content.is_empty() { - // Optional retry annotation: #[retry = policy] - let mut retry_policy: Option = None; - if content.peek(Token![#]) { - content.parse::()?; - let inner; - bracketed!(inner in content); - let attr_name: Ident = inner.parse()?; - if attr_name == "retry" { - inner.parse::()?; - let pol: Ident = inner.parse()?; - retry_policy = Some(pol); - } else { - return Err(inner.error("unknown attribute, expected `retry`")); - } - } - // Field name and type - let fld: Ident = content.parse()?; - content.parse::()?; - let ty: Type = content.parse()?; - if content.peek(Token![,]) { - content.parse::()?; - } - fields.push((fld, ty, retry_policy)); - } - // Now parse the rest in any order: start, context, edges - let mut start: Option = None; - let mut context: Option = None; - let mut edges: Option> = None; - let mut seen = std::collections::HashSet::new(); - while !input.is_empty() { - if input.peek(Ident) { - let fork = input.fork(); - let kw = fork.parse::()?; - match kw.to_string().as_str() { - "start" => { - if seen.contains("start") { - return Err( - input.error("Duplicate 'start' field in workflow definition.") - ); - } - input.parse::()?; // start - input.parse::()?; - let s: Ident = input.parse()?; - input.parse::()?; - start = Some(s); - seen.insert("start"); - } - "context" => { - if seen.contains("context") { - return Err( - input.error("Duplicate 'context' field in workflow definition.") - ); - } - input.parse::()?; // context - input.parse::()?; - let ty: Type = input.parse()?; - input.parse::()?; - context = Some(ty); - seen.insert("context"); - } - "edges" => { - if seen.contains("edges") { - return Err( - input.error("Duplicate 'edges' field in workflow definition.") - ); - } - input.parse::()?; // edges - let edges_content; - braced!(edges_content in input); - // Collect direct-success, direct-failure, and composite arms - let mut direct_success = - std::collections::HashMap::>::new(); - let mut direct_failure = - std::collections::HashMap::>::new(); - let mut composite_map = - std::collections::HashMap::>::new(); - while !edges_content.is_empty() { - let src: Ident = edges_content.parse()?; - if edges_content.peek(Ident) { - // on_failure clause - let kw: Ident = edges_content.parse()?; - if kw == "on_failure" { - edges_content.parse::]>()?; - let nested; - braced!(nested in edges_content); - // expect bracketed fallback list - let succs_content; - bracketed!(succs_content in nested); - let fails: Vec = succs_content - .parse_terminated(Ident::parse, Token![,])? - .into_iter() - .collect(); - edges_content.parse::()?; - direct_failure.insert(src.clone(), fails); - continue; - } else { - return Err(edges_content.error( - "Unexpected identifier. Expected `on_failure` or `=>`.", - )); - } - } - // success or composite entry - edges_content.parse::]>()?; - let nested; - braced!(nested in edges_content); - if nested.peek(syn::token::Bracket) { - // direct successors - let succs_content; - bracketed!(succs_content in nested); - let succs: Vec = succs_content - .parse_terminated(Ident::parse, Token![,])? - .into_iter() - .collect(); - edges_content.parse::()?; - direct_success.insert(src.clone(), succs); - } else { - // composite arms - let mut arms = Vec::new(); - while !nested.is_empty() { - let action_path: Ident = nested.parse()?; - nested.parse::()?; - let variant: Ident = nested.parse()?; - let inner; - syn::parenthesized!(inner in nested); - let binding: Ident = inner.parse()?; - nested.parse::]>()?; - let succs_content; - bracketed!(succs_content in nested); - let succs: Vec = succs_content - .parse_terminated(Ident::parse, Token![,])? - .into_iter() - .collect(); - nested.parse::()?; - arms.push(CompositeArm { - action_path, - variant, - binding, - succs, - }); - } - edges_content.parse::()?; - composite_map.insert(src.clone(), arms); - } - } - // Merge into final edges vector - let mut edges_vec = Vec::new(); - // direct-success entries - for (src, succs) in direct_success.into_iter() { - let failure = direct_failure.remove(&src); - edges_vec.push(( - src, - EdgeKind::Direct { - succs, - on_failure: failure, - }, - )); - } - // direct-failure-only entries - for (src, fails) in direct_failure.into_iter() { - edges_vec.push(( - src, - EdgeKind::Direct { - succs: Vec::new(), - on_failure: Some(fails), - }, - )); - } - // composite entries - for (src, arms) in composite_map.into_iter() { - edges_vec.push((src, EdgeKind::Composite(arms))); - } - edges = Some(edges_vec); - seen.insert("edges"); - } - other => { - return Err(input.error(format!( - "Unexpected identifier '{}'. Expected one of: start, context, edges.", - other - ))); - } - } - } else { - return Err(input.error("Unexpected token in workflow definition. Expected 'start', 'context', or 'edges'.")); - } - } - // Check required fields - let start = start - .ok_or_else(|| input.error("Missing required 'start' field in workflow definition."))?; - let context = context.unwrap_or_else(|| syn::parse_quote! { () }); - let edges = edges - .ok_or_else(|| input.error("Missing required 'edges' field in workflow definition."))?; - Ok(WorkflowDef { - vis, - name, - generics, - fields, - start, - context, - edges, - }) - } -} +use syn::{parse_macro_input, Ident, LitStr}; pub fn workflow(item: TokenStream) -> TokenStream { // parse the struct-based workflow definition @@ -349,6 +96,50 @@ pub fn workflow(item: TokenStream) -> TokenStream { quote! { #vis struct #name #generics { #(#field_defs),* } } }; + // Map node field name to the field type (not inner node type) + let mut node_field_types = std::collections::HashMap::new(); + for (fld, ty, _) in &fields { + node_field_types.insert(fld.to_string(), ty.clone()); + } + // For each direct edge, emit a type assertion comparing associated types + let mut type_asserts = Vec::new(); + for (src, kind) in &edges { + if let EdgeKind::Direct { succs, .. } = kind { + for succ in succs { + let src_ty = node_field_types.get(&src.to_string()); + let dst_ty = node_field_types.get(&succ.to_string()); + if let (Some(src_ty), Some(dst_ty)) = (src_ty, dst_ty) { + // Generate a compile-time trait-based assertion so errors mention the node names + // Generate CamelCase identifiers for trait to satisfy Rust naming conventions + let src_camel = to_camel_case(&src.to_string()); + let dst_camel = to_camel_case(&succ.to_string()); + let trait_ident = + format_ident!("AssertOutputOf{}MatchesInputOf{}", src_camel, dst_camel); + let fn_ident = format_ident!("assert_equal_{}_to_{}", src, succ); + type_asserts.push(quote! { + #[doc(hidden)] + pub trait #trait_ident {} + #[doc(hidden)] + impl #trait_ident for () {} + const _: () = { + #[allow(dead_code)] + #[doc(hidden)] + const fn #fn_ident<__Left, __Right>() + where + (): #trait_ident<__Left, __Right>, + {} + #fn_ident::< + <#src_ty as ::floxide::Node<#context>>::Output, + <#dst_ty as ::floxide::Node<#context>>::Input + >(); + }; + }); + } + } + } + } + let type_errors = quote! { #(#type_asserts)* }; + // Generate run method arms for each field // We collect into a Vec so we can reuse in multiple generated methods let run_arms: Vec<_> = node_fields.iter().map(|(fld, _ty, retry)| { @@ -454,95 +245,110 @@ pub fn workflow(item: TokenStream) -> TokenStream { } } EdgeKind::Composite(composite) => { - if composite.is_empty() { - // terminal composite branch: return the output value as Ok(Some(action)) - quote! { - #wrapper - let __store = &ctx.store; - let node_span = tracing::span!(tracing::Level::DEBUG, "node_execution", node = stringify!(#var_ident)); - let _node_enter = node_span.enter(); - tracing::debug!(store = ?ctx.store, ?input, "Node input and store"); - match ctx.run_future(__node.process(__store, input.clone())).await? { - // Hold: pause without emitting successors - Transition::Hold => { - tracing::debug!("Node produced Transition::Hold"); - return Ok(None); - } - Transition::Next(action) => { - tracing::debug!(?action, "Node produced Transition::Next (terminal composite)"); - return Ok(Some(action)); + let arm_tokens: Vec = composite.iter().map(|arm| { + let CompositeArm { action_path, variant, binding, is_wildcard, guard, succs } = arm; + let pat = if *is_wildcard { + let wildcard_ident = format_ident!("__wildcard_binding"); + if let Some(guard) = &guard { + quote! { #action_path :: #variant ( #wildcard_ident ) if #guard } + } else { + quote! { #action_path :: #variant ( #wildcard_ident ) } + } + } else if let Some(binding) = &binding { + if let Some(guard) = &guard { + quote! { #action_path :: #variant ( #binding ) if #guard } + } else { + quote! { #action_path :: #variant ( #binding ) } + } + } else if let Some(guard) = &guard { + quote! { #action_path :: #variant if #guard } + } else { + quote! { #action_path :: #variant } + }; + // Debug log removed: generated match pattern + let body = if succs.is_empty() { + if *is_wildcard { + let wildcard_ident = format_ident!("__wildcard_binding"); + quote! { + tracing::debug!(variant = stringify!(#variant), value = ?#wildcard_ident, "Composite arm: terminal variant (wildcard)"); + return Ok(Some(#wildcard_ident)); } - Transition::Abort(e) => { - tracing::warn!(error = ?e, "Node produced Transition::Abort (terminal composite)"); - return Err(e); + } else if let Some(binding) = &binding { + quote! { + tracing::debug!(variant = stringify!(#variant), value = ?#binding, "Composite arm: terminal variant"); + return Ok(Some(#binding)); } - Transition::NextAll(_) => unreachable!("Unexpected Transition::NextAll in terminal composite node"), - } - } - } else { - // composite edges: pattern-based - let pats_terminal = composite.iter().filter_map(|arm| { - let CompositeArm { action_path, variant, binding, succs } = arm; - if succs.is_empty() { - let pat = quote! { #action_path :: #variant (#binding) }; - Some(quote! { - #pat => { - tracing::debug!(variant = stringify!(#variant), value = ?#binding, "Composite arm: terminal variant"); - return Ok(Some(#binding)); - } - }) } else { - None + quote! { + tracing::debug!(variant = stringify!(#variant), "Composite arm: terminal variant (unit)"); + return Ok(Some(())); + } } - }); - let pats_non_terminal = composite.iter().filter_map(|arm| { - let CompositeArm { action_path, variant, binding, succs } = arm; - if !succs.is_empty() { - let pat = quote! { #action_path :: #variant (#binding) }; - let succ_pushes = succs.iter().map(|succ| { + } else { + let succ_pushes = if *is_wildcard { + let wildcard_ident = format_ident!("__wildcard_binding"); + succs.iter().map(|succ| { + let var_name = to_camel_case(&succ.to_string()); + let succ_var = format_ident!("{}", var_name); + quote! { __q.push_back(#work_item_ident::#succ_var(::uuid::Uuid::new_v4().to_string(), #wildcard_ident)); } + }).collect::>() + } else if let Some(binding) = &binding { + succs.iter().map(|succ| { let var_name = to_camel_case(&succ.to_string()); let succ_var = format_ident!("{}", var_name); quote! { __q.push_back(#work_item_ident::#succ_var(::uuid::Uuid::new_v4().to_string(), #binding)); } - }); - Some(quote! { - #pat => { - tracing::debug!(variant = stringify!(#variant), value = ?#binding, "Composite arm: scheduling successors"); - #(#succ_pushes)* - return Ok(None); - } - }) + }).collect::>() } else { - None - } - }); - quote! { - #wrapper - let __store = &ctx.store; - let node_span = tracing::span!(tracing::Level::DEBUG, "node_execution", node = stringify!(#var_ident)); - let _node_enter = node_span.enter(); - tracing::debug!(store = ?ctx.store, ?input, "Node input and store"); - match ctx.run_future(__node.process(__store, input.clone())).await? { - Transition::Hold => { - tracing::debug!("Node produced Transition::Hold"); + succs.iter().map(|succ| { + let var_name = to_camel_case(&succ.to_string()); + let succ_var = format_ident!("{}", var_name); + quote! { __q.push_back(#work_item_ident::#succ_var(::uuid::Uuid::new_v4().to_string(), Default::default())); } + }).collect::>() + }; + if *is_wildcard { + let wildcard_ident = format_ident!("__wildcard_binding"); + quote! { + tracing::debug!(variant = stringify!(#variant), value = ?#wildcard_ident, "Composite arm: scheduling successors (wildcard)"); + #(#succ_pushes)* return Ok(None); } - Transition::Next(action) => { - tracing::debug!(?action, "Node produced Transition::Next (composite)"); - match action { - #(#pats_terminal)* - #(#pats_non_terminal)* - _ => { - tracing::warn!("Composite arm: unmatched variant"); - return Ok(None); - } - } + } else if let Some(binding) = &binding { + quote! { + tracing::debug!(variant = stringify!(#variant), value = ?#binding, "Composite arm: scheduling successors"); + #(#succ_pushes)* + return Ok(None); } - Transition::Abort(e) => { - tracing::warn!(error = ?e, "Node produced Transition::Abort (composite)"); - return Err(e); + } else { + quote! { + tracing::debug!(variant = stringify!(#variant), "Composite arm: scheduling successors (unit)"); + #(#succ_pushes)* + return Ok(None); } - Transition::NextAll(_) => unreachable!("Unexpected Transition::NextAll in composite node"), } + }; + quote! { #pat => { #body } } + }).collect(); + quote! { + #wrapper + let __store = &ctx.store; + let node_span = tracing::span!(tracing::Level::DEBUG, "node_execution", node = stringify!(#var_ident)); + let _node_enter = node_span.enter(); + tracing::debug!(store = ?ctx.store, ?input, "Node input and store"); + match ctx.run_future(__node.process(__store, input.clone())).await? { + Transition::Hold => { + tracing::debug!("Node produced Transition::Hold"); + return Ok(None); + } + Transition::Next(action) => { + match action { + #(#arm_tokens)* + } + } + Transition::Abort(e) => { + tracing::warn!(error = ?e, "Node produced Transition::Abort (composite)"); + return Err(e); + } + Transition::NextAll(_) => unreachable!("Unexpected Transition::NextAll in composite node"), } } } @@ -631,7 +437,7 @@ pub fn workflow(item: TokenStream) -> TokenStream { // Assemble the expanded code let expanded = quote! { - + #type_errors #[derive(Debug, Clone)] #struct_def diff --git a/crates/floxide-macros/tests/compile_fail_tests.rs b/crates/floxide-macros/tests/compile_fail_tests.rs new file mode 100644 index 0000000..63d6130 --- /dev/null +++ b/crates/floxide-macros/tests/compile_fail_tests.rs @@ -0,0 +1,8 @@ +use trybuild::TestCases; + +#[test] +// Checks that a type mismatch between connected nodes produces an error mentioning both node names. +fn type_mismatch() { + let t = TestCases::new(); + t.compile_fail("tests/ui/type_mismatch.rs"); +} diff --git a/crates/floxide-macros/tests/composite_arms.rs b/crates/floxide-macros/tests/composite_arms.rs new file mode 100644 index 0000000..88f390d --- /dev/null +++ b/crates/floxide-macros/tests/composite_arms.rs @@ -0,0 +1,141 @@ +use floxide_macros_support::{EdgeKind, WorkflowDef}; +use syn::parse_str; + +fn parse_edges(input: &str) -> Vec<(syn::Ident, EdgeKind)> { + // Provide a minimal valid workflow definition for parsing + let src = format!( + r#" + pub struct Dummy {{ foo: usize }} + start = foo; + edges {{ {} }} + "#, + input + ); + let def: WorkflowDef = parse_str(&src).unwrap(); + def.edges +} + +#[test] +fn parses_unit_variant() { + let edges = parse_edges("foo => { MyEnum::Done => [bar]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms[0].variant, "Done"); + assert!(arms[0].binding.is_none()); + assert!(arms[0].is_wildcard); + assert!(arms[0].guard.is_none()); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_binding_variant() { + let edges = parse_edges("foo => { MyEnum::Valid(data) => [bar]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms[0].variant, "Valid"); + assert_eq!(arms[0].binding.as_ref().unwrap(), "data"); + assert!(!arms[0].is_wildcard); + assert!(arms[0].guard.is_none()); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_wildcard_variant() { + let edges = parse_edges("foo => { MyEnum::Valid(_) => [bar]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms[0].variant, "Valid"); + assert!(arms[0].binding.is_none()); + assert!(arms[0].is_wildcard); + assert!(arms[0].guard.is_none()); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_unit_variant_with_guard() { + let edges = parse_edges("foo => { MyEnum::Done if some_check() => [bar]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms[0].variant, "Done"); + assert!(arms[0].binding.is_none()); + assert!(arms[0].is_wildcard); + assert!(arms[0].guard.is_some()); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_binding_with_guard() { + let edges = parse_edges("foo => { MyEnum::Valid(data) if data.is_ok() => [bar]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms[0].variant, "Valid"); + assert_eq!(arms[0].binding.as_ref().unwrap(), "data"); + assert!(!arms[0].is_wildcard); + assert!(arms[0].guard.is_some()); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_wildcard_with_guard() { + let edges = parse_edges("foo => { MyEnum::Valid(_) if some_check() => [bar]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms[0].variant, "Valid"); + assert!(arms[0].binding.is_none()); + assert!(arms[0].is_wildcard); + assert!(arms[0].guard.is_some()); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_multiple_arms() { + let edges = parse_edges("foo => { MyEnum::Valid(data) => [next_node]; MyEnum::Invalid(_) => [error_node]; MyEnum::Done => [finish_node]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms.len(), 3); + assert_eq!(arms[0].variant, "Valid"); + assert_eq!(arms[1].variant, "Invalid"); + assert_eq!(arms[2].variant, "Done"); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_multiple_successors() { + let edges = parse_edges("foo => { MyEnum::Valid(data) => [a, b, c]; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert_eq!(arms[0].succs.len(), 3); + assert_eq!(arms[0].succs[0], "a"); + assert_eq!(arms[0].succs[1], "b"); + assert_eq!(arms[0].succs[2], "c"); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_terminal_composite_arm() { + let edges = parse_edges("foo => { MyEnum::Done => []; };"); + if let EdgeKind::Composite(arms) = &edges[0].1 { + assert!(arms[0].succs.is_empty()); + } else { + panic!("Expected composite edge"); + } +} + +#[test] +fn parses_direct_edge() { + let edges = parse_edges("foo => [bar];"); + if let EdgeKind::Direct { succs, on_failure } = &edges[0].1 { + assert_eq!(succs[0], "bar"); + assert!(on_failure.is_none()); + } else { + panic!("Expected direct edge"); + } +} diff --git a/crates/floxide-macros/tests/ui/type_mismatch.rs b/crates/floxide-macros/tests/ui/type_mismatch.rs new file mode 100644 index 0000000..c2e040a --- /dev/null +++ b/crates/floxide-macros/tests/ui/type_mismatch.rs @@ -0,0 +1,46 @@ +use async_trait::async_trait; +use floxide_core::*; +use floxide_macros::workflow; + +/// Define two nodes with mismatched types: FooNode outputs u32, BarNode expects i32 +#[derive(Clone, Debug)] +struct FooNode; + +#[async_trait] +impl Node for FooNode { + type Input = i32; + type Output = u32; + async fn process(&self, _ctx: &(), input: i32) -> Result, FloxideError> { + Ok(Transition::Next(input as u32)) + } +} + +#[derive(Clone, Debug)] +struct BarNode; + +#[async_trait] +impl Node for BarNode { + type Input = i32; // Intentionally mismatched: expects i32, but FooNode outputs u32 + type Output = i32; + async fn process(&self, _ctx: &(), input: i32) -> Result, FloxideError> { + Ok(Transition::Next(input)) + } +} + +// Declare a workflow connecting FooNode to BarNode +workflow! { + struct TestWorkflow { + foo: FooNode, + bar: BarNode, + } + start = foo; + context = (); + edges { + foo => { [ bar ] }; + bar => {}; + } +} +//~ ERROR assert_output_of_foo_matches_input_of_bar +//~ ERROR expected `i32`, found `u32` + +fn main() {} \ No newline at end of file diff --git a/crates/floxide-macros/tests/ui/type_mismatch.stderr b/crates/floxide-macros/tests/ui/type_mismatch.stderr new file mode 100644 index 0000000..e43ce9b --- /dev/null +++ b/crates/floxide-macros/tests/ui/type_mismatch.stderr @@ -0,0 +1,55 @@ +error[E0277]: the trait bound `(): AssertOutputOfFooMatchesInputOfBar` is not satisfied + --> tests/ui/type_mismatch.rs:31:1 + | +31 | / workflow! { +32 | | struct TestWorkflow { +33 | | foo: FooNode, +34 | | bar: BarNode, +... | +42 | | } + | |_^ the trait `AssertOutputOfFooMatchesInputOfBar` is not implemented for `()` + | + = help: the trait `AssertOutputOfFooMatchesInputOfBar` is not implemented for `()` + but trait `AssertOutputOfFooMatchesInputOfBar` is implemented for it + = help: for that trait implementation, expected `u32`, found `i32` +note: required by a bound in `assert_equal_foo_to_bar` + --> tests/ui/type_mismatch.rs:31:1 + | +31 | / workflow! { +32 | | struct TestWorkflow { +33 | | foo: FooNode, +34 | | bar: BarNode, +... | +42 | | } + | |_^ required by this bound in `assert_equal_foo_to_bar` + = note: this error originates in the macro `workflow` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0308]: mismatched types + --> tests/ui/type_mismatch.rs:31:1 + | +31 | / workflow! { +32 | | struct TestWorkflow { +33 | | foo: FooNode, +34 | | bar: BarNode, +... | +42 | | } + | | ^ + | | | + | |_expected `i32`, found `u32` + | arguments to this enum variant are incorrect + | +note: tuple variant defined here + --> tests/ui/type_mismatch.rs:31:1 + | +31 | / workflow! { +32 | | struct TestWorkflow { +33 | | foo: FooNode, +34 | | bar: BarNode, +... | +42 | | } + | |_^ + = note: this error originates in the macro `workflow` (in Nightly builds, run with -Z macro-backtrace for more info) +help: you can convert a `u32` to an `i32` and panic if the converted value doesn't fit + | +42 | }.try_into().unwrap() + | ++++++++++++++++++++ diff --git a/crates/floxide-macros/tests/workflow.rs b/crates/floxide-macros/tests/workflow.rs index 955df3d..fa3e1df 100644 --- a/crates/floxide-macros/tests/workflow.rs +++ b/crates/floxide-macros/tests/workflow.rs @@ -107,10 +107,10 @@ workflow! { context = (); edges { // direct edge: multiply outputs feed into branch - multiply => { [ branch ] }; + multiply => [ branch ] ; branch => { - BatchAction::Large(v) => [ large ]; - BatchAction::Small(v) => [ small ]; + BatchAction::Large(_) => [ large ]; + BatchAction::Small(_) => [ small ]; }; large => {}; small => {}; diff --git a/examples/branching_example.rs b/examples/branching_example.rs index bb5d135..eec88fd 100644 --- a/examples/branching_example.rs +++ b/examples/branching_example.rs @@ -53,7 +53,7 @@ pub struct BigNode; #[async_trait] impl Node for BigNode { type Input = u64; - type Output = (); + type Output = String; async fn process( &self, @@ -61,7 +61,7 @@ impl Node for BigNode { input: u64, ) -> Result, FloxideError> { println!("BigNode: handling value {}", input); - Ok(Transition::Next(())) + Ok(Transition::Next(format!("BigNode: {}", input))) } } @@ -72,7 +72,7 @@ pub struct SmallNode; #[async_trait] impl Node for SmallNode { type Input = String; - type Output = (); + type Output = String; async fn process( &self, @@ -80,7 +80,7 @@ impl Node for SmallNode { input: String, ) -> Result, FloxideError> { println!("SmallNode: handling message \"{}\"", input); - Ok(Transition::Next(())) + Ok(Transition::Next(format!("SmallNode: {}", input))) } } @@ -95,8 +95,8 @@ workflow! { context = MyCtx; edges { foo => { - FooAction::Above(v) => [ big ]; - FooAction::Below(s) => [ small ]; + FooAction::Above(_) => [ big ]; + FooAction::Below(_) => [ small ]; }; big => {}; small => {}; @@ -121,9 +121,11 @@ pub async fn run_threshold_workflow_example() -> Result<(), Box> } +//! #[derive(Clone, Debug, Serialize, Deserialize, Default)] +//! struct Ctx { counter: SharedState } //! //! // Define a node that increments the counter //! node! { @@ -29,7 +30,7 @@ //! input = (); //! output = (); //! |ctx, _input| { -//! let mut c = ctx.counter.lock().await; +//! let mut c = ctx.counter.get().await; //! *c += 1; //! Ok(Transition::Next(())) //! } @@ -40,11 +41,11 @@ //! pub struct BranchNode {}; //! context = Ctx; //! input = (); -//! output = &'static str; +//! output = String; //! |ctx, _input| { -//! let mut c = ctx.counter.lock().await; +//! let mut c = ctx.counter.get().await; //! *c += 10; -//! Ok(Transition::Next("done")) +//! Ok(Transition::Next(format!("done: {}", c))) //! } //! } //!