askit_macros/
lib.rs

1//! Procedural macros for agent-stream-kit.
2//!
3//! Provides an attribute to declare agent metadata alongside the agent type and
4//! generate the registration boilerplate.
5
6use proc_macro::TokenStream;
7use proc_macro2::Span;
8use quote::quote;
9use syn::{
10    spanned::Spanned,
11    parse_macro_input, parse_quote,
12    punctuated::Punctuated,
13    Expr, ItemStruct, Lit, Meta, MetaList, token::Comma,
14};
15
16#[proc_macro_attribute]
17pub fn askit(attr: TokenStream, item: TokenStream) -> TokenStream {
18    askit_agent(attr, item)
19}
20
21/// Declare agent metadata and generate `agent_definition` / `register` helpers.
22///
23/// Example:
24/// ```rust,ignore
25/// #[askit_agent(
26///     kind = "Board",
27///     title = "Board In",
28///     category = "Core",
29///     inputs = ["*"],
30///     string_config(
31///         name = CONFIG_BOARD_NAME,
32///         default = "",
33///         title = "Board Name",
34///         description = "* = source kind"
35///     )
36/// )]
37/// struct BoardInAgent { /* ... */ }
38/// ```
39#[proc_macro_attribute]
40pub fn askit_agent(attr: TokenStream, item: TokenStream) -> TokenStream {
41    let args = parse_macro_input!(attr with Punctuated<Meta, Comma>::parse_terminated);
42    let item_struct = parse_macro_input!(item as ItemStruct);
43
44    match expand_askit_agent(args, item_struct) {
45        Ok(tokens) => tokens.into(),
46        Err(err) => err.into_compile_error().into(),
47    }
48}
49
50struct AgentArgs {
51    kind: Option<Expr>,
52    name: Option<Expr>,
53    title: Option<Expr>,
54    description: Option<Expr>,
55    category: Option<Expr>,
56    inputs: Vec<Expr>,
57    outputs: Vec<Expr>,
58    configs: Vec<ConfigSpec>,
59    displays: Vec<DisplaySpec>,
60}
61
62#[derive(Default)]
63struct CommonConfig {
64    name: Option<Expr>,
65    default: Option<Expr>,
66    title: Option<Expr>,
67    description: Option<Expr>,
68}
69
70enum ConfigSpec {
71    Unit(CommonConfig),
72    Boolean(CommonConfig),
73    Integer(CommonConfig),
74    Number(CommonConfig),
75    String(CommonConfig),
76    Text(CommonConfig),
77    Object(CommonConfig),
78}
79
80enum DisplaySpec {
81    Unit(CommonDisplay),
82    Boolean(CommonDisplay),
83    Integer(CommonDisplay),
84    Number(CommonDisplay),
85    String(CommonDisplay),
86    Text(CommonDisplay),
87    Object(CommonDisplay),
88    Any(CommonDisplay),
89}
90
91#[derive(Default)]
92struct CommonDisplay {
93    name: Option<Expr>,
94    title: Option<Expr>,
95    description: Option<Expr>,
96    hide_title: bool,
97}
98
99fn expand_askit_agent(
100    args: Punctuated<Meta, Comma>,
101    item: ItemStruct,
102) -> syn::Result<proc_macro2::TokenStream> {
103    let mut parsed = AgentArgs {
104        kind: None,
105        name: None,
106        title: None,
107        description: None,
108        category: None,
109        inputs: Vec::new(),
110        outputs: Vec::new(),
111        configs: Vec::new(),
112        displays: Vec::new(),
113    };
114
115    for meta in args {
116        match meta {
117            Meta::NameValue(nv) if nv.path.is_ident("kind") => {
118                parsed.kind = Some(nv.value);
119            }
120            Meta::NameValue(nv) if nv.path.is_ident("name") => {
121                parsed.name = Some(nv.value);
122            }
123            Meta::NameValue(nv) if nv.path.is_ident("title") => {
124                parsed.title = Some(nv.value);
125            }
126            Meta::NameValue(nv) if nv.path.is_ident("description") => {
127                parsed.description = Some(nv.value);
128            }
129            Meta::NameValue(nv) if nv.path.is_ident("category") => {
130                parsed.category = Some(nv.value);
131            }
132            Meta::NameValue(nv) if nv.path.is_ident("inputs") => {
133                parsed.inputs = parse_expr_array(nv.value)?;
134            }
135            Meta::NameValue(nv) if nv.path.is_ident("outputs") => {
136                parsed.outputs = parse_expr_array(nv.value)?;
137            }
138            Meta::List(ml) if ml.path.is_ident("inputs") => {
139                parsed.inputs = collect_exprs(ml)?;
140            }
141            Meta::List(ml) if ml.path.is_ident("outputs") => {
142                parsed.outputs = collect_exprs(ml)?;
143            }
144            Meta::List(ml) if ml.path.is_ident("string_config") => {
145                parsed.configs.push(ConfigSpec::String(parse_common_config(ml)?));
146            }
147            Meta::List(ml) if ml.path.is_ident("text_config") => {
148                parsed.configs.push(ConfigSpec::Text(parse_common_config(ml)?));
149            }
150            Meta::List(ml) if ml.path.is_ident("boolean_config") => {
151                parsed.configs.push(ConfigSpec::Boolean(parse_common_config(ml)?));
152            }
153            Meta::List(ml) if ml.path.is_ident("integer_config") => {
154                parsed.configs.push(ConfigSpec::Integer(parse_common_config(ml)?));
155            }
156            Meta::List(ml) if ml.path.is_ident("number_config") => {
157                parsed.configs.push(ConfigSpec::Number(parse_common_config(ml)?));
158            }
159            Meta::List(ml) if ml.path.is_ident("object_config") => {
160                parsed.configs.push(ConfigSpec::Object(parse_common_config(ml)?));
161            }
162            Meta::List(ml) if ml.path.is_ident("unit_config") => {
163                parsed.configs.push(ConfigSpec::Unit(parse_common_config(ml)?));
164            }
165            Meta::List(ml) if ml.path.is_ident("unit_display") => {
166                parsed.displays.push(DisplaySpec::Unit(parse_common_display(ml)?));
167            }
168            Meta::List(ml) if ml.path.is_ident("boolean_display") => {
169                parsed.displays.push(DisplaySpec::Boolean(parse_common_display(ml)?));
170            }
171            Meta::List(ml) if ml.path.is_ident("integer_display") => {
172                parsed.displays.push(DisplaySpec::Integer(parse_common_display(ml)?));
173            }
174            Meta::List(ml) if ml.path.is_ident("number_display") => {
175                parsed.displays.push(DisplaySpec::Number(parse_common_display(ml)?));
176            }
177            Meta::List(ml) if ml.path.is_ident("string_display") => {
178                parsed.displays.push(DisplaySpec::String(parse_common_display(ml)?));
179            }
180            Meta::List(ml) if ml.path.is_ident("text_display") => {
181                parsed.displays.push(DisplaySpec::Text(parse_common_display(ml)?));
182            }
183            Meta::List(ml) if ml.path.is_ident("object_display") => {
184                parsed.displays.push(DisplaySpec::Object(parse_common_display(ml)?));
185            }
186            Meta::List(ml) if ml.path.is_ident("any_display") => {
187                parsed.displays.push(DisplaySpec::Any(parse_common_display(ml)?));
188            }
189            other => {
190                return Err(syn::Error::new_spanned(
191                    other,
192                    "unsupported askit_agent argument",
193                ));
194            }
195        }
196    }
197
198    let ident = &item.ident;
199    let generics = item.generics.clone();
200    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
201
202    let kind = parsed.kind.unwrap_or_else(|| parse_quote! { "Agent" });
203    let name_tokens = parsed.name.map(|n| quote! { #n }).unwrap_or_else(|| {
204        quote! { concat!(module_path!(), "::", stringify!(#ident)) }
205    });
206
207    let title = parsed
208        .title
209        .ok_or_else(|| syn::Error::new(Span::call_site(), "askit_agent: missing `title`"))?;
210    let category = parsed
211        .category
212        .ok_or_else(|| syn::Error::new(Span::call_site(), "askit_agent: missing `category`"))?;
213    let title = quote! { .title(#title) };
214    let description = parsed.description.map(|d| quote! { .description(#d) });
215    let category = quote! { .category(#category) };
216
217    let inputs = if parsed.inputs.is_empty() {
218        quote! {}
219    } else {
220        let values = parsed.inputs;
221        quote! { .inputs(vec![#(#values),*]) }
222    };
223
224    let outputs = if parsed.outputs.is_empty() {
225        quote! {}
226    } else {
227        let values = parsed.outputs;
228        quote! { .outputs(vec![#(#values),*]) }
229    };
230
231    let config_calls = parsed
232        .configs
233        .into_iter()
234        .map(|cfg| match cfg {
235            ConfigSpec::Unit(c) => {
236                let name = c.name.ok_or_else(|| {
237                    syn::Error::new(Span::call_site(), "unit_config missing `name`")
238                })?;
239                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
240                let description = c
241                    .description
242                    .map(|d| quote! { let entry = entry.description(#d); });
243                Ok(quote! {
244                    .unit_config_with(#name, |entry| {
245                        let entry = entry;
246                        #title
247                        #description
248                        entry
249                    })
250                })
251            }
252            ConfigSpec::Boolean(c) => {
253                let name = c.name.ok_or_else(|| {
254                    syn::Error::new(Span::call_site(), "boolean_config missing `name`")
255                })?;
256                let default = c.default.unwrap_or_else(|| parse_quote! { false });
257                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
258                let description = c
259                    .description
260                    .map(|d| quote! { let entry = entry.description(#d); });
261                Ok(quote! {
262                    .boolean_config_with(#name, #default, |entry| {
263                        let entry = entry;
264                        #title
265                        #description
266                        entry
267                    })
268                })
269            }
270            ConfigSpec::Integer(c) => {
271                let name = c.name.ok_or_else(|| {
272                    syn::Error::new(Span::call_site(), "integer_config missing `name`")
273                })?;
274                let default = c.default.unwrap_or_else(|| parse_quote! { 0i64 });
275                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
276                let description = c
277                    .description
278                    .map(|d| quote! { let entry = entry.description(#d); });
279                Ok(quote! {
280                    .integer_config_with(#name, #default, |entry| {
281                        let entry = entry;
282                        #title
283                        #description
284                        entry
285                    })
286                })
287            }
288            ConfigSpec::Number(c) => {
289                let name = c.name.ok_or_else(|| {
290                    syn::Error::new(Span::call_site(), "number_config missing `name`")
291                })?;
292                let default = c.default.unwrap_or_else(|| parse_quote! { 0.0f64 });
293                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
294                let description = c
295                    .description
296                    .map(|d| quote! { let entry = entry.description(#d); });
297                Ok(quote! {
298                    .number_config_with(#name, #default, |entry| {
299                        let entry = entry;
300                        #title
301                        #description
302                        entry
303                    })
304                })
305            }
306            ConfigSpec::String(c) => {
307                let name = c.name.ok_or_else(|| {
308                    syn::Error::new(Span::call_site(), "string_config missing `name`")
309                })?;
310                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
311                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
312                let description = c
313                    .description
314                    .map(|d| quote! { let entry = entry.description(#d); });
315                Ok(quote! {
316                    .string_config_with(#name, #default, |entry| {
317                        let entry = entry;
318                        #title
319                        #description
320                        entry
321                    })
322                })
323            }
324            ConfigSpec::Text(c) => {
325                let name = c.name.ok_or_else(|| {
326                    syn::Error::new(Span::call_site(), "text_config missing `name`")
327                })?;
328                let default = c.default.unwrap_or_else(|| parse_quote! { "" });
329                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
330                let description = c
331                    .description
332                    .map(|d| quote! { let entry = entry.description(#d); });
333                Ok(quote! {
334                    .text_config_with(#name, #default, |entry| {
335                        let entry = entry;
336                        #title
337                        #description
338                        entry
339                    })
340                })
341            }
342            ConfigSpec::Object(c) => {
343                let name = c.name.ok_or_else(|| {
344                    syn::Error::new(Span::call_site(), "object_config missing `name`")
345                })?;
346                let default = c.default.unwrap_or_else(|| {
347                    parse_quote! { ::agent_stream_kit::AgentValue::object_default() }
348                });
349                let title = c.title.map(|t| quote! { let entry = entry.title(#t); });
350                let description = c
351                    .description
352                    .map(|d| quote! { let entry = entry.description(#d); });
353                Ok(quote! {
354                    .object_config_with(#name, #default, |entry| {
355                        let entry = entry;
356                        #title
357                        #description
358                        entry
359                    })
360                })
361            }
362        })
363        .collect::<syn::Result<Vec<_>>>()?;
364
365    let display_calls = parsed
366        .displays
367        .into_iter()
368        .map(|disp| match disp {
369            DisplaySpec::Unit(c) => display_call("unit", c),
370            DisplaySpec::Boolean(c) => display_call("boolean", c),
371            DisplaySpec::Integer(c) => display_call("integer", c),
372            DisplaySpec::Number(c) => display_call("number", c),
373            DisplaySpec::String(c) => display_call("string", c),
374            DisplaySpec::Text(c) => display_call("text", c),
375            DisplaySpec::Object(c) => display_call("object", c),
376            DisplaySpec::Any(c) => display_call("*", c),
377        })
378        .collect::<syn::Result<Vec<_>>>()?;
379
380    let definition_builder = quote! {
381        ::agent_stream_kit::AgentDefinition::new(
382            #kind,
383            #name_tokens,
384            Some(::agent_stream_kit::new_agent_boxed::<#ident>),
385        )
386        #title
387        #description
388        #category
389        #inputs
390        #outputs
391        #(#config_calls)*
392        #(#display_calls)*
393    };
394
395    let expanded = quote! {
396        #item
397
398        impl #impl_generics #ident #ty_generics #where_clause {
399            pub fn agent_definition() -> ::agent_stream_kit::AgentDefinition {
400                #definition_builder
401            }
402
403            pub fn register(askit: &::agent_stream_kit::ASKit) {
404                askit.register_agent(Self::agent_definition());
405            }
406        }
407
408        ::agent_stream_kit::inventory::submit! {
409            ::agent_stream_kit::AgentRegistration {
410                build: || #definition_builder,
411            }
412        }
413    };
414
415    Ok(expanded)
416}
417
418fn collect_exprs(list: MetaList) -> syn::Result<Vec<Expr>> {
419    let values = list.parse_args_with(Punctuated::<Expr, Comma>::parse_terminated)?;
420    Ok(values.into_iter().collect())
421}
422
423fn parse_expr_array(expr: Expr) -> syn::Result<Vec<Expr>> {
424    if let Expr::Array(arr) = expr {
425        Ok(arr.elems.into_iter().collect())
426    } else {
427        Err(syn::Error::new_spanned(
428            expr,
429            "inputs/outputs expect array expressions",
430        ))
431    }
432}
433
434fn parse_common_config(list: MetaList) -> syn::Result<CommonConfig> {
435    let mut cfg = CommonConfig::default();
436    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
437
438    for meta in nested {
439        match meta {
440            Meta::NameValue(nv) if nv.path.is_ident("name") => {
441                cfg.name = Some(match &nv.value {
442                    Expr::Lit(expr_lit) => match &expr_lit.lit {
443                        Lit::Str(s) => syn::parse_str::<Expr>(&s.value())?,
444                        _ => nv.value.clone(),
445                    },
446                    _ => nv.value.clone(),
447                });
448            }
449            Meta::NameValue(nv) if nv.path.is_ident("default") => {
450                cfg.default = Some(nv.value.clone());
451            }
452            Meta::NameValue(nv) if nv.path.is_ident("title") => {
453                cfg.title = Some(nv.value.clone());
454            }
455            Meta::NameValue(nv) if nv.path.is_ident("description") => {
456                cfg.description = Some(nv.value.clone());
457            }
458            other => {
459                return Err(syn::Error::new_spanned(
460                    other,
461                    "config supports name, default, title, description",
462                ));
463            }
464        }
465    }
466
467    if cfg.name.is_none() {
468        return Err(syn::Error::new(
469            list.span(),
470            "config missing `name`",
471        ));
472    }
473    Ok(cfg)
474}
475
476fn parse_common_display(list: MetaList) -> syn::Result<CommonDisplay> {
477    let mut cfg = CommonDisplay::default();
478    let nested = list.parse_args_with(Punctuated::<Meta, Comma>::parse_terminated)?;
479
480    for meta in nested {
481        match meta {
482            Meta::NameValue(nv) if nv.path.is_ident("name") => {
483                cfg.name = Some(match &nv.value {
484                    Expr::Lit(expr_lit) => match &expr_lit.lit {
485                        Lit::Str(s) => syn::parse_str::<Expr>(&s.value())?,
486                        _ => nv.value.clone(),
487                    },
488                    _ => nv.value.clone(),
489                });
490            }
491            Meta::NameValue(nv) if nv.path.is_ident("title") => {
492                cfg.title = Some(nv.value.clone());
493            }
494            Meta::NameValue(nv) if nv.path.is_ident("description") => {
495                cfg.description = Some(nv.value.clone());
496            }
497            Meta::Path(p) if p.is_ident("hide_title") => {
498                cfg.hide_title = true;
499            }
500            other => {
501                return Err(syn::Error::new_spanned(
502                    other,
503                    "display supports name, title, description, hide_title",
504                ));
505            }
506        }
507    }
508
509    if cfg.name.is_none() {
510        return Err(syn::Error::new(list.span(), "display missing `name`"));
511    }
512    Ok(cfg)
513}
514
515fn display_call(type_name: &str, cfg: CommonDisplay) -> syn::Result<proc_macro2::TokenStream> {
516    let name = cfg
517        .name
518        .ok_or_else(|| syn::Error::new(Span::call_site(), "display missing `name`"))?;
519    let title = cfg.title.map(|t| quote! { let entry = entry.title(#t); });
520    let description = cfg
521        .description
522        .map(|d| quote! { let entry = entry.description(#d); });
523    let hide_title = if cfg.hide_title {
524        quote! { let entry = entry.hide_title(); }
525    } else {
526        quote! {}
527    };
528
529    Ok(quote! {
530        .custom_display_config_with(#name, #type_name, |entry| {
531            let entry = entry;
532            #title
533            #description
534            #hide_title
535            entry
536        })
537    })
538}