Skip to main content

ironflow_engine_macros/
lib.rs

1//! Derive macros for Ironflow workflow authors.
2//!
3//! Depend on `ironflow-engine`, not on this crate: it re-exports
4//! `DecisionAnswers` and `DecisionChoice` in `ironflow_engine::decision` and
5//! `AgentAnswer` in `ironflow_engine::config`, next to the traits they
6//! implement, and the generated code refers to `::ironflow_engine`. The
7//! derives are documented and tested there.
8
9use proc_macro::TokenStream;
10use proc_macro2::TokenStream as TokenStream2;
11use quote::{quote, quote_spanned};
12use syn::ext::IdentExt;
13use syn::parse::ParseStream;
14use syn::punctuated::Punctuated;
15use syn::spanned::Spanned;
16use syn::{
17    Attribute, Data, DeriveInput, Error, Field, Fields, Ident, LitStr, Result, Token, Type,
18    bracketed, parse_macro_input, parse_quote,
19};
20
21/// Derive `ironflow_engine::decision::DecisionAnswers` on a struct whose
22/// fields are the questions of a decision step.
23///
24/// Every field carries exactly one of `#[noul("..")]` (an `f64` probability
25/// of "yes", optionally `if_true = ".."` and `if_false = ".."`),
26/// `#[choice("..")]` (an enum deriving `DecisionChoice`) or
27/// `#[score("..", levels = [".."])]` (an `f64` weighted score). The question
28/// is named after the field.
29///
30/// # Examples
31///
32/// Compiled and run in `ironflow_engine::decision`:
33///
34/// ```ignore
35/// use ironflow_engine::decision::{DecisionAnswers, DecisionChoice};
36///
37/// #[derive(DecisionChoice)]
38/// enum Team { Billing, Technical }
39///
40/// #[derive(DecisionAnswers)]
41/// struct Triage {
42///     #[noul("Does this convey urgency?")]
43///     is_urgent: f64,
44///     #[choice("Which team should handle this?")]
45///     team: Team,
46///     #[score("How frustrated?", levels = ["Calm", "Angry"])]
47///     mood: f64,
48/// }
49/// ```
50#[proc_macro_derive(DecisionAnswers, attributes(noul, choice, score))]
51pub fn derive_decision_answers(input: TokenStream) -> TokenStream {
52    let input = parse_macro_input!(input as DeriveInput);
53    expand_answers(&input)
54        .unwrap_or_else(Error::into_compile_error)
55        .into()
56}
57
58/// Derive `ironflow_engine::decision::DecisionChoice` on an enum whose unit
59/// variants are the options of a choice question.
60///
61/// An option is labelled with its variant name in `snake_case`;
62/// `#[choice(rename = "..")]` overrides the label and
63/// `#[choice(description = "..")]` tells the model what the option means.
64/// Doc comments are never sent to the model.
65///
66/// # Examples
67///
68/// Compiled and run in `ironflow_engine::decision`:
69///
70/// ```ignore
71/// use ironflow_engine::decision::DecisionChoice;
72///
73/// #[derive(DecisionChoice)]
74/// enum Team {
75///     #[choice(description = "Payments and invoices")]
76///     Billing,
77///     Technical,
78/// }
79/// ```
80#[proc_macro_derive(DecisionChoice, attributes(choice))]
81pub fn derive_decision_choice(input: TokenStream) -> TokenStream {
82    let input = parse_macro_input!(input as DeriveInput);
83    expand_choice(&input)
84        .unwrap_or_else(Error::into_compile_error)
85        .into()
86}
87
88/// One question, as declared on a field.
89enum Question {
90    Noul {
91        instructions: LitStr,
92        if_true: Option<LitStr>,
93        if_false: Option<LitStr>,
94    },
95    Choice {
96        instructions: LitStr,
97    },
98    Score {
99        instructions: LitStr,
100        levels: Vec<LitStr>,
101    },
102}
103
104const QUESTION_KINDS: [&str; 3] = ["noul", "choice", "score"];
105
106fn expand_answers(input: &DeriveInput) -> Result<TokenStream2> {
107    let fields = match &input.data {
108        Data::Struct(data) => match &data.fields {
109            Fields::Named(fields) => &fields.named,
110            _ => {
111                return Err(Error::new(
112                    input.ident.span(),
113                    "`DecisionAnswers` derives on a struct with named fields: each field is one question",
114                ));
115            }
116        },
117        _ => {
118            return Err(Error::new(
119                input.ident.span(),
120                "`DecisionAnswers` derives on a struct with named fields: each field is one question",
121            ));
122        }
123    };
124    if fields.is_empty() {
125        return Err(Error::new(
126            input.ident.span(),
127            "a decision needs at least one question",
128        ));
129    }
130
131    let support = quote!(::ironflow_engine::decision::__private);
132    let mut inserts = Vec::with_capacity(fields.len());
133    let mut reads = Vec::with_capacity(fields.len());
134
135    for field in fields {
136        let ident = field
137            .ident
138            .as_ref()
139            .ok_or_else(|| Error::new(field.span(), "a question needs a named field"))?;
140        let name = ident.unraw().to_string();
141        let ty = &field.ty;
142
143        match parse_question(field)? {
144            Question::Noul {
145                instructions,
146                if_true,
147                if_false,
148            } => {
149                let if_true = optional(if_true.as_ref());
150                let if_false = optional(if_false.as_ref());
151                inserts.push(quote! {
152                    questions.insert(
153                        ::std::string::String::from(#name),
154                        #support::noul(#instructions, #if_true, #if_false),
155                    );
156                });
157                reads.push(read(&support, ident, ty, quote!(read_noul), &name));
158            }
159            Question::Choice { instructions } => {
160                inserts.push(quote_spanned! {ty.span()=>
161                    questions.insert(
162                        ::std::string::String::from(#name),
163                        #support::choice::<#ty>(#instructions),
164                    );
165                });
166                reads.push(read(&support, ident, ty, quote!(read_choice::<#ty>), &name));
167            }
168            Question::Score {
169                instructions,
170                levels,
171            } => {
172                inserts.push(quote! {
173                    questions.insert(
174                        ::std::string::String::from(#name),
175                        #support::score(#instructions, &[#(#levels),*]),
176                    );
177                });
178                reads.push(read(&support, ident, ty, quote!(read_score), &name));
179            }
180        }
181    }
182
183    let type_name = &input.ident;
184    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
185    Ok(quote! {
186        impl #impl_generics ::ironflow_engine::decision::DecisionAnswers
187            for #type_name #ty_generics #where_clause
188        {
189            fn questions() -> #support::Questions {
190                let mut questions = #support::Questions::new();
191                #(#inserts)*
192                questions
193            }
194
195            fn from_output(
196                output: &#support::DecisionOutput,
197            ) -> ::core::result::Result<Self, #support::DecisionError> {
198                ::core::result::Result::Ok(Self {
199                    #(#reads),*
200                })
201            }
202        }
203    })
204}
205
206/// `field: support::reader(output, "name")?`, spanned on the field type so a
207/// type that does not fit the question points at the field.
208fn read(
209    support: &TokenStream2,
210    ident: &Ident,
211    ty: &Type,
212    reader: TokenStream2,
213    name: &str,
214) -> TokenStream2 {
215    quote_spanned! {ty.span()=>
216        #ident: #support::#reader(output, #name)?
217    }
218}
219
220fn optional(value: Option<&LitStr>) -> TokenStream2 {
221    match value {
222        Some(value) => quote!(::core::option::Option::Some(#value)),
223        None => quote!(::core::option::Option::None),
224    }
225}
226
227/// The single question attribute of a field.
228fn parse_question(field: &Field) -> Result<Question> {
229    let attrs: Vec<(&str, &Attribute)> = field
230        .attrs
231        .iter()
232        .filter_map(|attr| {
233            QUESTION_KINDS
234                .iter()
235                .find(|kind| attr.path().is_ident(kind))
236                .map(|kind| (*kind, attr))
237        })
238        .collect();
239    let (kind, attr) = match attrs.as_slice() {
240        [question] => *question,
241        [] => {
242            return Err(Error::new(
243                field.span(),
244                "every field of a `DecisionAnswers` struct is a question: add `#[noul(\"..\")]`, \
245                 `#[choice(\"..\")]` or `#[score(\"..\", levels = [..])]`",
246            ));
247        }
248        [_, (_, extra), ..] => {
249            return Err(Error::new(
250                extra.span(),
251                "a field is one question: keep a single `#[noul]`, `#[choice]` or `#[score]`",
252            ));
253        }
254    };
255
256    attr.parse_args_with(|input: ParseStream| parse_question_args(input, kind, attr))
257}
258
259fn parse_question_args(input: ParseStream, kind: &str, attr: &Attribute) -> Result<Question> {
260    let instructions: LitStr = input.parse()?;
261    if instructions.value().trim().is_empty() {
262        return Err(Error::new(
263            instructions.span(),
264            "the instructions of a question must not be empty",
265        ));
266    }
267
268    let mut if_true = None;
269    let mut if_false = None;
270    let mut levels: Option<Vec<LitStr>> = None;
271
272    while !input.is_empty() {
273        input.parse::<Token![,]>()?;
274        if input.is_empty() {
275            break;
276        }
277        let key: Ident = input.parse()?;
278        input.parse::<Token![=]>()?;
279        match (kind, key.to_string().as_str()) {
280            ("noul", "if_true") => set_once(&mut if_true, &key, input.parse()?)?,
281            ("noul", "if_false") => set_once(&mut if_false, &key, input.parse()?)?,
282            ("score", "levels") => {
283                let content;
284                bracketed!(content in input);
285                let parsed = Punctuated::<LitStr, Token![,]>::parse_terminated(&content)?;
286                if let Some(blank) = parsed.iter().find(|level| level.value().trim().is_empty()) {
287                    return Err(Error::new(blank.span(), "a score level must not be empty"));
288                }
289                set_once(&mut levels, &key, parsed.into_iter().collect())?;
290            }
291            _ => {
292                return Err(Error::new(
293                    key.span(),
294                    format!("`#[{kind}]` has no option `{key}`"),
295                ));
296            }
297        }
298    }
299
300    match (kind, levels) {
301        ("noul", _) => Ok(Question::Noul {
302            instructions,
303            if_true,
304            if_false,
305        }),
306        ("choice", _) => Ok(Question::Choice { instructions }),
307        (_, Some(levels)) if !levels.is_empty() => Ok(Question::Score {
308            instructions,
309            levels,
310        }),
311        _ => Err(Error::new(
312            attr.span(),
313            "`#[score]` needs `levels = [\"..\", ..]`, from the lowest to the highest",
314        )),
315    }
316}
317
318fn set_once<T>(slot: &mut Option<T>, key: &Ident, value: T) -> Result<()> {
319    if slot.is_some() {
320        return Err(Error::new(key.span(), format!("`{key}` is set twice")));
321    }
322    *slot = Some(value);
323    Ok(())
324}
325
326fn expand_choice(input: &DeriveInput) -> Result<TokenStream2> {
327    let Data::Enum(data) = &input.data else {
328        return Err(Error::new(
329            input.ident.span(),
330            "`DecisionChoice` derives on an enum: each unit variant is one option",
331        ));
332    };
333    if data.variants.is_empty() {
334        return Err(Error::new(
335            input.ident.span(),
336            "a choice needs at least one option",
337        ));
338    }
339
340    let mut variants = Vec::with_capacity(data.variants.len());
341    let mut labels: Vec<String> = Vec::with_capacity(data.variants.len());
342    let mut descriptions = Vec::with_capacity(data.variants.len());
343
344    for variant in &data.variants {
345        if !matches!(variant.fields, Fields::Unit) {
346            return Err(Error::new(
347                variant.span(),
348                "`DecisionChoice` options are unit variants",
349            ));
350        }
351
352        let mut rename: Option<LitStr> = None;
353        let mut description: Option<LitStr> = None;
354        for attr in variant.attrs.iter().filter(|a| a.path().is_ident("choice")) {
355            attr.parse_nested_meta(|meta| {
356                let slot = if meta.path.is_ident("rename") {
357                    &mut rename
358                } else if meta.path.is_ident("description") {
359                    &mut description
360                } else {
361                    return Err(meta.error("expected `rename` or `description`"));
362                };
363                let value: LitStr = meta.value()?.parse()?;
364                if value.value().trim().is_empty() {
365                    return Err(Error::new(value.span(), "must not be empty"));
366                }
367                if slot.is_some() {
368                    return Err(meta.error("set twice"));
369                }
370                *slot = Some(value);
371                Ok(())
372            })?;
373        }
374
375        let label = rename
376            .map(|lit| lit.value())
377            .unwrap_or_else(|| snake_case(&variant.ident.unraw().to_string()));
378        if labels.contains(&label) {
379            return Err(Error::new(
380                variant.span(),
381                format!("two options are labelled `{label}`"),
382            ));
383        }
384
385        variants.push(&variant.ident);
386        labels.push(label);
387        descriptions.push(optional(description.as_ref()));
388    }
389
390    let type_name = &input.ident;
391    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
392    Ok(quote! {
393        impl #impl_generics ::ironflow_engine::decision::DecisionChoice
394            for #type_name #ty_generics #where_clause
395        {
396            fn options() -> ::std::vec::Vec<(&'static str, ::core::option::Option<&'static str>)> {
397                ::std::vec![#((#labels, #descriptions)),*]
398            }
399
400            fn from_label(label: &str) -> ::core::option::Option<Self> {
401                match label {
402                    #(#labels => ::core::option::Option::Some(Self::#variants),)*
403                    _ => ::core::option::Option::None,
404                }
405            }
406
407            fn label(&self) -> &'static str {
408                match self {
409                    #(Self::#variants => #labels,)*
410                }
411            }
412        }
413    })
414}
415
416/// Derive `ironflow_engine::config::AgentAnswer` on a struct or an enum whose
417/// fields all implement `AgentAnswer`: free text from the agent is
418/// `AgentText`, never `String`.
419///
420/// Every type parameter must implement `AgentAnswer` too. A field of another
421/// type fails to compile, pointing at that field.
422///
423/// # Examples
424///
425/// Compiled and run in `ironflow_engine::config`:
426///
427/// ```ignore
428/// use ironflow_engine::config::{AgentAnswer, AgentText};
429///
430/// #[derive(AgentAnswer)]
431/// struct Review {
432///     score: u8,
433///     summary: AgentText,
434/// }
435/// ```
436#[proc_macro_derive(AgentAnswer)]
437pub fn derive_agent_answer(input: TokenStream) -> TokenStream {
438    let input = parse_macro_input!(input as DeriveInput);
439    expand_agent_answer(&input)
440        .unwrap_or_else(Error::into_compile_error)
441        .into()
442}
443
444fn expand_agent_answer(input: &DeriveInput) -> Result<TokenStream2> {
445    let field_types: Vec<&Type> = match &input.data {
446        Data::Struct(data) => data.fields.iter().map(|field| &field.ty).collect(),
447        Data::Enum(data) => data
448            .variants
449            .iter()
450            .flat_map(|variant| variant.fields.iter().map(|field| &field.ty))
451            .collect(),
452        Data::Union(_) => {
453            return Err(Error::new(
454                input.ident.span(),
455                "`AgentAnswer` derives on a struct or an enum",
456            ));
457        }
458    };
459
460    let support = quote!(::ironflow_engine::config::AgentAnswer);
461    let mut generics = input.generics.clone();
462    for param in generics.type_params_mut() {
463        param.bounds.push(parse_quote!(#support));
464    }
465    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
466
467    let asserts = field_types.iter().map(|ty| {
468        quote_spanned! {ty.span()=>
469            __assert_agent_answer::<#ty>();
470        }
471    });
472
473    let type_name = &input.ident;
474    Ok(quote! {
475        impl #impl_generics #support for #type_name #ty_generics #where_clause {}
476
477        const _: () = {
478            #[allow(dead_code, clippy::extra_unused_type_parameters)]
479            fn __assert_agent_answer<__T: #support + ?Sized>() {}
480
481            // An inherent impl, not a free fn, so a field naming the type as
482            // `Self` (a recursive answer) resolves.
483            impl #impl_generics #type_name #ty_generics #where_clause {
484                #[allow(dead_code)]
485                fn __ironflow_assert_agent_answer_fields() {
486                    #(#asserts)*
487                }
488            }
489        };
490    })
491}
492
493/// `OnCallSRE` -> `on_call_sre`, `HTTPError` -> `http_error`.
494fn snake_case(name: &str) -> String {
495    let chars: Vec<char> = name.chars().collect();
496    let mut out = String::with_capacity(name.len() + 4);
497    for (i, &ch) in chars.iter().enumerate() {
498        if ch.is_uppercase() {
499            let prev = i.checked_sub(1).map(|p| chars[p]);
500            let next = chars.get(i + 1).copied();
501            let starts_word = match prev {
502                None => false,
503                Some(prev) => {
504                    prev.is_lowercase()
505                        || prev.is_ascii_digit()
506                        || (prev.is_uppercase() && next.is_some_and(char::is_lowercase))
507                }
508            };
509            if starts_word {
510                out.push('_');
511            }
512            out.extend(ch.to_lowercase());
513        } else {
514            out.push(ch);
515        }
516    }
517    out
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn snake_case_splits_words() {
526        assert_eq!(snake_case("Billing"), "billing");
527        assert_eq!(snake_case("OnCall"), "on_call");
528        assert_eq!(snake_case("OnCallSRE"), "on_call_sre");
529        assert_eq!(snake_case("HTTPError"), "http_error");
530        assert_eq!(snake_case("Tier2Support"), "tier2_support");
531        assert_eq!(snake_case("Été"), "été");
532    }
533
534    #[test]
535    fn snake_case_keeps_an_already_snake_name() {
536        assert_eq!(snake_case("billing"), "billing");
537        assert_eq!(snake_case("on_call"), "on_call");
538    }
539}