1use 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#[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#[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
88enum 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
206fn 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
227fn 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#[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 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
493fn 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}