Skip to main content

llm_tool_macros/
lib.rs

1//! Proc-macro crate for `llm-tool`.
2//!
3//! Provides the `#[llm_tool]` attribute macro that transforms a plain function
4//! into a strongly-typed [`RustTool`](https://docs.rs/llm-tool/latest/llm_tool/trait.RustTool.html)
5//! implementation.
6//!
7//! With the `md-tmpl` feature enabled, tool descriptions can be
8//! loaded from `.tmpl.md` template files via `description_file = "..."`, and tool
9//! responses can be auto-rendered through templates via
10//! `response_file = "..."`.
11mod prompt_macro;
12mod resource_macro;
13#[cfg(feature = "md-tmpl")]
14mod response_struct_gen;
15
16use convert_case::{Case, Casing};
17use proc_macro::TokenStream;
18use quote::{format_ident, quote};
19#[cfg(feature = "md-tmpl")]
20use syn::Ident;
21use syn::{ItemFn, LitStr, parse_macro_input};
22
23/// Transforms a function into a `RustTool` implementation.
24///
25/// The macro generates:
26/// - A `{FnName}Params` struct deriving `Deserialize` and `JsonSchema`
27/// - A `{FnName}` unit struct (`PascalCase`) implementing `RustTool`
28///
29/// The tool **name** is the function name (`snake_case`).
30/// The tool **description** comes from one of the sources below.
31/// Parameter names and types come from the function signature.
32/// Doc comments on parameters become schema descriptions.
33///
34/// # Description sources (in priority order)
35///
36/// | Syntax | Cost | Feature |
37/// |--------|------|---------|
38/// | `#[llm_tool]` + doc comment | Zero (static `&str`) | — |
39/// | `#[llm_tool(description = "inline text")]` | Zero (static `&str`) | — |
40/// | `#[llm_tool(response_file = "...")]` | Runtime render | `md-tmpl` |
41/// | `#[llm_tool(description_file = "tools/x.tmpl.md")]` | Zero (compiled) | `md-tmpl` |
42/// | `#[llm_tool(description_file = "...", params(k = "v"))]` | Zero (compiled) | `md-tmpl` |
43/// | `#[llm_tool(description_file = "...", env(K = "v"))]` | Zero (compiled) | `md-tmpl` |
44/// | `#[llm_tool(description_file = "...", context = fn)]` | Runtime `Cow::Owned` | `md-tmpl` |
45///
46/// ## Inline description
47///
48/// Override or replace the doc comment with an inline string:
49///
50/// ```text
51/// #[llm_tool(description = "Get the current weather for a city.")]
52/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
53/// ```
54///
55/// ## Template descriptions (feature: `md-tmpl`)
56///
57/// Load the description from a `.tmpl.md` file:
58///
59/// ```text
60/// #[llm_tool(description_file = "tools/weather.tmpl.md")]
61/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
62/// ```
63///
64/// For templates with variables, provide **compile-time** key-value pairs:
65///
66/// ```text
67/// #[llm_tool(description_file = "tools/weather.tmpl.md", params(api = "v3", env = "prod"))]
68/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
69/// ```
70///
71/// The macro reads the template, validates all declared variables are
72/// provided, renders the description, and embeds the result as a static
73/// string — **zero runtime cost**.
74///
75/// For **runtime** context (e.g. values from config), provide a context function:
76///
77/// ```text
78/// #[llm_tool(description_file = "tools/weather.tmpl.md", context = build_ctx)]
79/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
80/// ```
81///
82/// The context function signature is `fn(&ToolStruct) -> Context`.
83/// Templates are parsed once at startup via `LazyLock`.
84///
85/// ## Environment variables (feature: `md-tmpl`)
86///
87/// Templates can declare `env:` variables in their frontmatter. These are
88/// separate from `params:` — they represent build-time configuration
89/// (deployment environment, API version, etc.) rather than template parameters.
90///
91/// In the template:
92/// ```text
93/// ---
94/// env:
95///   - API_VERSION = str
96///   - MAX_RETRIES = int := 3
97/// ---
98/// Uses API {{ API_VERSION }} with {{ MAX_RETRIES }} retries.
99/// ```
100///
101/// Supply values via the `env(...)` attribute:
102/// ```text
103/// #[llm_tool(description_file = "tools/api.tmpl.md", env(API_VERSION = "v5"))]
104/// fn query_api(/* … */) -> Result<String, ToolError> { /* … */ }
105/// ```
106///
107/// Env values are resolved at compile time, producing a zero-cost static
108/// description. They can be combined with `params(...)` or `context = fn`.
109///
110/// # Typed parameters
111///
112/// Parameters may use `&str` — the generated params struct stores an owned
113/// `String` and the macro auto-borrows it before passing to your function body.
114///
115/// # Response templates
116///
117/// When `response_file = "path/to/response.tmpl.md"` is provided, the
118/// tool's return value (`T: Serialize`) is used to build a template context
119/// via `Context::from_serialize`, rendered through the template, and returned
120/// as `ToolOutput`. The struct is also attached as metadata.
121///
122/// # Return types
123///
124/// The return type can be `Result<T, E>` or just `T` (infallible):
125///
126/// - **`T`**: `String` (wrapped as-is), `ToolOutput` (passed through), any
127///   `T: Serialize` (auto-serialized to JSON), or any `T: Into<ToolOutput>`
128/// - **`E`**: any `E: Into<ToolError>` — built-in for `String`, `ToolError`,
129///   `std::io::Error`, `serde_json::Error`
130#[proc_macro_attribute]
131pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
132    let func = parse_macro_input!(item as ItemFn);
133    let tool_attr = if attr.is_empty() {
134        None
135    } else {
136        match syn::parse::<ToolAttr>(attr) {
137            Ok(parsed) => Some(parsed),
138            Err(err) => return err.to_compile_error().into(),
139        }
140    };
141    match tool_impl(&func, tool_attr.as_ref()) {
142        Ok(tokens) => tokens.into(),
143        Err(err) => err.to_compile_error().into(),
144    }
145}
146
147/// Transforms a function into a `RustPrompt` implementation.
148#[proc_macro_attribute]
149pub fn llm_prompt(attr: TokenStream, item: TokenStream) -> TokenStream {
150    let func = parse_macro_input!(item as ItemFn);
151    let tool_attr = if attr.is_empty() {
152        None
153    } else {
154        match syn::parse::<ToolAttr>(attr) {
155            Ok(parsed) => Some(parsed),
156            Err(err) => return err.to_compile_error().into(),
157        }
158    };
159    match prompt_macro::prompt_impl(&func, tool_attr.as_ref()) {
160        Ok(tokens) => tokens.into(),
161        Err(err) => err.to_compile_error().into(),
162    }
163}
164
165/// Transforms a function into a `RustResource` implementation.
166#[proc_macro_attribute]
167pub fn llm_resource(attr: TokenStream, item: TokenStream) -> TokenStream {
168    let func = parse_macro_input!(item as ItemFn);
169    let res_attr = match syn::parse::<resource_macro::ResourceAttr>(attr) {
170        Ok(parsed) => parsed,
171        Err(err) => return err.to_compile_error().into(),
172    };
173    match resource_macro::resource_impl(&func, &res_attr) {
174        Ok(tokens) => tokens.into(),
175        Err(err) => err.to_compile_error().into(),
176    }
177}
178
179// ── Attribute Parsing ───────────────────────────────────────────────────────
180
181/// Parsed `#[llm_tool(...)]` attribute.
182///
183/// Supports:
184/// - `description = "inline text"` — static inline description
185/// - `description_file = "path.tmpl.md"` — template file (requires `md-tmpl`)
186/// - `params(key = "value", ...)` — compile-time template variables
187/// - `env(KEY = "value", ...)` — compile-time environment variables for `env:` frontmatter
188/// - `context = path::to::fn` — runtime template context function
189/// - `response_file = "path.tmpl.md"` — response rendering template
190struct ToolAttr {
191    /// Inline description string (mutually exclusive with `description_file_path`).
192    description_inline: Option<LitStr>,
193    /// Path to a `.tmpl.md` file (mutually exclusive with `description_inline`).
194    description_file_path: Option<LitStr>,
195    /// Path to a response `.tmpl.md` file for auto-rendering tool output.
196    response_file_path: Option<LitStr>,
197    /// Inline response template string (mutually exclusive with `response_file_path`).
198    response_inline: Option<LitStr>,
199    /// Compile-time key-value pairs for template rendering.
200    /// Mutually exclusive with `context_fn`.
201    #[cfg(feature = "md-tmpl")]
202    inline_params: Vec<(Ident, LitStr)>,
203    /// Compile-time environment variables for `env:` frontmatter declarations.
204    #[cfg(feature = "md-tmpl")]
205    env_vars: Vec<(Ident, syn::Lit)>,
206    /// Runtime context function (mutually exclusive with `inline_params`).
207    #[cfg(feature = "md-tmpl")]
208    context_fn: Option<syn::Path>,
209    has_inline_params: bool,
210    has_context_fn: bool,
211}
212
213pub(crate) const MACRO_LLM_TOOL: &str = "llm_tool";
214pub(crate) const MACRO_LLM_PROMPT: &str = "llm_prompt";
215pub(crate) const MACRO_LLM_RESOURCE: &str = "llm_resource";
216
217pub(crate) const ATTR_DESCRIPTION: &str = "description";
218pub(crate) const ATTR_DESCRIPTION_FILE: &str = "description_file";
219pub(crate) const ATTR_RESPONSE_FILE: &str = "response_file";
220pub(crate) const ATTR_RESPONSE: &str = "response";
221pub(crate) const ATTR_PARAMS: &str = "params";
222pub(crate) const ATTR_CONTEXT: &str = "context";
223pub(crate) const ATTR_ENV: &str = "env";
224pub(crate) const ATTR_DOC: &str = "doc";
225
226pub(crate) const TYPE_OPTION: &str = "Option";
227pub(crate) const TYPE_TOOL_CONTEXT: &str = "ToolContext";
228pub(crate) const TYPE_STR: &str = "str";
229pub(crate) const TYPE_RESULT: &str = "Result";
230
231#[derive(Copy, Clone, PartialEq, Eq, Debug)]
232pub(crate) enum ToolAttrKey {
233    Description,
234    DescriptionFile,
235    ResponseFile,
236    Response,
237    Params,
238    Env,
239    Context,
240}
241
242impl ToolAttrKey {
243    pub(crate) const ALL: &'static [Self] = &[
244        Self::Description,
245        Self::DescriptionFile,
246        Self::Response,
247        Self::ResponseFile,
248        Self::Params,
249        Self::Env,
250        Self::Context,
251    ];
252
253    pub(crate) const fn as_str(self) -> &'static str {
254        match self {
255            Self::Description => ATTR_DESCRIPTION,
256            Self::DescriptionFile => ATTR_DESCRIPTION_FILE,
257            Self::ResponseFile => ATTR_RESPONSE_FILE,
258            Self::Response => ATTR_RESPONSE,
259            Self::Params => ATTR_PARAMS,
260            Self::Env => ATTR_ENV,
261            Self::Context => ATTR_CONTEXT,
262        }
263    }
264
265    pub(crate) fn expected_keys_error(span: proc_macro2::Span) -> syn::Error {
266        let mut parts: Vec<String> = Self::ALL
267            .iter()
268            .map(|k| format!("`{}`", k.as_str()))
269            .collect();
270        let last = parts.pop().unwrap_or_default();
271        let formatted = if parts.is_empty() {
272            last
273        } else {
274            format!("{}, or {last}", parts.join(", "))
275        };
276        syn::Error::new(span, format!("expected {formatted}"))
277    }
278}
279
280impl TryFrom<&syn::Ident> for ToolAttrKey {
281    type Error = syn::Error;
282
283    fn try_from(ident: &syn::Ident) -> Result<Self, Self::Error> {
284        let s = ident.to_string();
285        for &variant in Self::ALL {
286            if s == variant.as_str() {
287                return Ok(variant);
288            }
289        }
290        Err(Self::expected_keys_error(ident.span()))
291    }
292}
293
294#[derive(Default)]
295struct ToolAttrBuilder {
296    description_inline: Option<syn::LitStr>,
297    description_file_path: Option<syn::LitStr>,
298    response_file_path: Option<syn::LitStr>,
299    response_inline: Option<syn::LitStr>,
300    #[cfg(feature = "md-tmpl")]
301    inline_params: Vec<(syn::Ident, syn::LitStr)>,
302    #[cfg(feature = "md-tmpl")]
303    env_vars: Vec<(syn::Ident, syn::Lit)>,
304    #[cfg(feature = "md-tmpl")]
305    context_fn: Option<syn::Path>,
306    #[cfg(not(feature = "md-tmpl"))]
307    has_inline_params: bool,
308    #[cfg(not(feature = "md-tmpl"))]
309    has_context_fn: bool,
310    #[cfg(not(feature = "md-tmpl"))]
311    has_env: bool,
312}
313
314impl ToolAttrBuilder {
315    fn parse_params_attr(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
316        let content;
317        syn::parenthesized!(content in input);
318        while !content.is_empty() {
319            let key: syn::Ident = content.parse()?;
320            let _: syn::Token![=] = content.parse()?;
321            let value: syn::LitStr = content.parse()?;
322            #[cfg(feature = "md-tmpl")]
323            self.inline_params.push((key, value));
324            #[cfg(not(feature = "md-tmpl"))]
325            {
326                drop(key);
327                drop(value);
328            }
329            if !content.is_empty() {
330                let _: syn::Token![,] = content.parse()?;
331            }
332        }
333        #[cfg(not(feature = "md-tmpl"))]
334        {
335            self.has_inline_params = true;
336        }
337        Ok(())
338    }
339
340    fn parse_env_attr(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
341        let content;
342        syn::parenthesized!(content in input);
343        while !content.is_empty() {
344            let key: syn::Ident = content.parse()?;
345            let _: syn::Token![=] = content.parse()?;
346            let value: syn::Lit = content.parse()?;
347            match &value {
348                syn::Lit::Str(_) | syn::Lit::Int(_) | syn::Lit::Float(_) | syn::Lit::Bool(_) => {}
349                other => {
350                    return Err(syn::Error::new(
351                        other.span(),
352                        "env values must be string, integer, float, or bool literals",
353                    ));
354                }
355            }
356            #[cfg(feature = "md-tmpl")]
357            self.env_vars.push((key, value));
358            #[cfg(not(feature = "md-tmpl"))]
359            {
360                drop(key);
361                drop(value);
362            }
363            if !content.is_empty() {
364                let _: syn::Token![,] = content.parse()?;
365            }
366        }
367        #[cfg(not(feature = "md-tmpl"))]
368        {
369            self.has_env = true;
370        }
371        Ok(())
372    }
373
374    fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
375        let ident: syn::Ident = input.parse()?;
376        let key = ToolAttrKey::try_from(&ident)?;
377
378        match key {
379            ToolAttrKey::Description => {
380                let _: syn::Token![=] = input.parse()?;
381                if self.description_inline.is_some() {
382                    return Err(syn::Error::new(
383                        ident.span(),
384                        format!("duplicate `{}` attribute", key.as_str()),
385                    ));
386                }
387                self.description_inline = Some(input.parse::<syn::LitStr>()?);
388            }
389            ToolAttrKey::DescriptionFile => {
390                let _: syn::Token![=] = input.parse()?;
391                if self.description_file_path.is_some() {
392                    return Err(syn::Error::new(
393                        ident.span(),
394                        format!("duplicate `{}` attribute", key.as_str()),
395                    ));
396                }
397                self.description_file_path = Some(input.parse::<syn::LitStr>()?);
398            }
399            ToolAttrKey::ResponseFile => {
400                let _: syn::Token![=] = input.parse()?;
401                if self.response_file_path.is_some() {
402                    return Err(syn::Error::new(
403                        ident.span(),
404                        format!("duplicate `{}` attribute", key.as_str()),
405                    ));
406                }
407                self.response_file_path = Some(input.parse::<syn::LitStr>()?);
408            }
409            ToolAttrKey::Response => {
410                let _: syn::Token![=] = input.parse()?;
411                if self.response_inline.is_some() {
412                    return Err(syn::Error::new(
413                        ident.span(),
414                        format!("duplicate `{}` attribute", key.as_str()),
415                    ));
416                }
417                self.response_inline = Some(input.parse::<syn::LitStr>()?);
418            }
419            ToolAttrKey::Params => {
420                self.parse_params_attr(input)?;
421            }
422            ToolAttrKey::Env => {
423                self.parse_env_attr(input)?;
424            }
425            ToolAttrKey::Context => {
426                let _: syn::Token![=] = input.parse()?;
427                #[cfg(feature = "md-tmpl")]
428                {
429                    if self.context_fn.is_some() {
430                        return Err(syn::Error::new(
431                            ident.span(),
432                            format!("duplicate `{}` attribute", key.as_str()),
433                        ));
434                    }
435                    self.context_fn = Some(input.parse::<syn::Path>()?);
436                }
437                #[cfg(not(feature = "md-tmpl"))]
438                {
439                    let _path: syn::Path = input.parse()?;
440                    if self.has_context_fn {
441                        return Err(syn::Error::new(
442                            ident.span(),
443                            format!("duplicate `{}` attribute", key.as_str()),
444                        ));
445                    }
446                    self.has_context_fn = true;
447                }
448            }
449        }
450        Ok(())
451    }
452}
453
454impl syn::parse::Parse for ToolAttr {
455    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
456        let mut builder = ToolAttrBuilder::default();
457
458        while !input.is_empty() {
459            builder.parse_single(input)?;
460            if !input.is_empty() {
461                let _: syn::Token![,] = input.parse()?;
462            }
463        }
464
465        #[cfg(feature = "md-tmpl")]
466        let has_inline_params = !builder.inline_params.is_empty();
467        #[cfg(not(feature = "md-tmpl"))]
468        let has_inline_params = builder.has_inline_params;
469
470        #[cfg(feature = "md-tmpl")]
471        let has_context_fn = builder.context_fn.is_some();
472        #[cfg(not(feature = "md-tmpl"))]
473        let has_context_fn = builder.has_context_fn;
474
475        validate_tool_attr(&builder)?;
476
477        Ok(Self {
478            description_inline: builder.description_inline,
479            description_file_path: builder.description_file_path,
480            response_file_path: builder.response_file_path,
481            response_inline: builder.response_inline,
482            #[cfg(feature = "md-tmpl")]
483            inline_params: builder.inline_params,
484            #[cfg(feature = "md-tmpl")]
485            env_vars: builder.env_vars,
486            #[cfg(feature = "md-tmpl")]
487            context_fn: builder.context_fn,
488            has_inline_params,
489            has_context_fn,
490        })
491    }
492}
493
494fn validate_tool_attr(builder: &ToolAttrBuilder) -> syn::Result<()> {
495    if builder.description_inline.is_some() && builder.description_file_path.is_some() {
496        return Err(syn::Error::new(
497            proc_macro2::Span::call_site(),
498            "`description` and `description_file` are mutually exclusive",
499        ));
500    }
501
502    if builder.response_file_path.is_some() && builder.response_inline.is_some() {
503        return Err(syn::Error::new(
504            proc_macro2::Span::call_site(),
505            "`response` and `response_file` are mutually exclusive",
506        ));
507    }
508
509    #[cfg(feature = "md-tmpl")]
510    let has_inline_params = !builder.inline_params.is_empty();
511    #[cfg(not(feature = "md-tmpl"))]
512    let has_inline_params = builder.has_inline_params;
513
514    #[cfg(feature = "md-tmpl")]
515    let has_context_fn = builder.context_fn.is_some();
516    #[cfg(not(feature = "md-tmpl"))]
517    let has_context_fn = builder.has_context_fn;
518
519    #[cfg(feature = "md-tmpl")]
520    let has_env = !builder.env_vars.is_empty();
521    #[cfg(not(feature = "md-tmpl"))]
522    let has_env = builder.has_env;
523
524    if has_inline_params && has_context_fn {
525        return Err(syn::Error::new(
526            proc_macro2::Span::call_site(),
527            "`params(...)` and `context = ...` are mutually exclusive; \
528             use `params` for compile-time values or `context` for runtime values",
529        ));
530    }
531
532    if has_inline_params
533        && builder.description_file_path.is_none()
534        && builder.description_inline.is_none()
535    {
536        return Err(syn::Error::new(
537            proc_macro2::Span::call_site(),
538            "`params(...)` requires `description_file = \"...\"` or `description = \"...\"`",
539        ));
540    }
541
542    if has_context_fn
543        && builder.description_file_path.is_none()
544        && builder.description_inline.is_none()
545    {
546        return Err(syn::Error::new(
547            proc_macro2::Span::call_site(),
548            "`context = ...` requires `description_file = \"...\"` or `description = \"...\"`",
549        ));
550    }
551
552    if has_env && builder.description_file_path.is_none() && builder.description_inline.is_none() {
553        return Err(syn::Error::new(
554            proc_macro2::Span::call_site(),
555            "`env(...)` requires `description_file = \"...\"` or `description = \"...\"`",
556        ));
557    }
558
559    #[cfg(not(feature = "md-tmpl"))]
560    if builder.description_file_path.is_some() {
561        return Err(syn::Error::new(
562            proc_macro2::Span::call_site(),
563            "`description_file` requires the `md-tmpl` feature of `llm-tool`",
564        ));
565    }
566
567    #[cfg(not(feature = "md-tmpl"))]
568    if builder.response_file_path.is_some() {
569        return Err(syn::Error::new(
570            proc_macro2::Span::call_site(),
571            "`response_file` requires the `md-tmpl` feature of `llm-tool`",
572        ));
573    }
574
575    #[cfg(not(feature = "md-tmpl"))]
576    if builder.response_inline.is_some() {
577        return Err(syn::Error::new(
578            proc_macro2::Span::call_site(),
579            "`response` requires the `md-tmpl` feature of `llm-tool`",
580        ));
581    }
582
583    Ok(())
584}
585
586// ── Implementation ──────────────────────────────────────────────────────────
587
588/// Parsed information about a single function parameter.
589struct ParamInfo {
590    name: syn::Ident,
591    ty: Box<syn::Type>,
592    doc_attrs: Vec<syn::Attribute>,
593    is_context: bool,
594    is_mut: bool,
595}
596
597/// Information about the function's return type.
598enum ReturnInfo {
599    /// `Result<T, E>` — fallible tool.
600    ResultType {
601        ok_type: Box<syn::Type>,
602        err_type: Box<syn::Type>,
603    },
604    /// Bare `T` — infallible tool.
605    BareType,
606}
607
608fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
609    let crate_path = quote! { ::llm_tool };
610    let fn_name = &func.sig.ident;
611    reject_generic_signature(func, MACRO_LLM_TOOL)?;
612    let tool_name_str = fn_name.to_string();
613    let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
614    let params_name = format_ident!("{}Params", struct_name);
615
616    // Resolve description: template file OR doc comment.
617    let DescriptionInfo {
618        static_description,
619        helper_tokens,
620        description_method,
621        dep_tracking,
622    } = resolve_description(func, attr)?;
623
624    // Resolve response template (if provided).
625    let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
626
627    // Extract parameters, separating ToolContext from regular params.
628    let all_params = extract_params(func, MACRO_LLM_TOOL)?;
629    let ctx_count = all_params.iter().filter(|p| p.is_context).count();
630    if ctx_count > 1 {
631        return Err(syn::Error::new_spanned(
632            &func.sig,
633            "#[llm_tool] functions can accept at most one ToolContext parameter",
634        ));
635    }
636    let ctx_param = all_params.iter().find(|p| p.is_context);
637    let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
638
639    // Enforce doc comments on every non-ToolContext parameter.
640    for param in &params {
641        if param.doc_attrs.is_empty() {
642            return Err(syn::Error::new_spanned(
643                &param.name,
644                format!(
645                    "#[llm_tool] parameter `{}` must have a doc comment \
646                      (used as the parameter description in the JSON schema)",
647                    param.name
648                ),
649            ));
650        }
651    }
652
653    // Parse return type: either Result<T, E> or bare T.
654    let return_info = parse_return_type(func, MACRO_LLM_TOOL)?;
655
656    let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
657    let param_descriptions: Vec<String> = params
658        .iter()
659        .map(|p| extract_doc_string(&p.doc_attrs))
660        .collect();
661
662    let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(&params);
663    let serde_defaults = build_serde_defaults(&params);
664    let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
665
666    let vis = &func.vis;
667
668    let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
669    let struct_doc = format!(
670        "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
671    );
672
673    // If the user's function takes a ToolContext parameter, bind it from the
674    // `_ctx` reference provided by the RustTool::call signature.
675    let ctx_binding = if let Some(cp) = ctx_param {
676        let ctx_name = &cp.name;
677        quote! { let #ctx_name = _ctx; }
678    } else {
679        quote! {}
680    };
681
682    let mut_tokens: Vec<proc_macro2::TokenStream> = params
683        .iter()
684        .map(|p| {
685            if p.is_mut {
686                quote! { mut }
687            } else {
688                quote! {}
689            }
690        })
691        .collect();
692
693    let response_dep_tracking = &response_info.dep_tracking;
694    let response_helper_tokens = &response_info.helper_tokens;
695
696    Ok(quote! {
697        #dep_tracking
698        #response_dep_tracking
699        #helper_tokens
700        #response_helper_tokens
701
702        #[doc = #params_doc]
703        #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
704        #vis struct #params_name {
705            #(
706                #[schemars(description = #param_descriptions)]
707                #serde_defaults
708                pub #param_names: #param_struct_types,
709            )*
710        }
711
712        #[doc = #struct_doc]
713        #vis struct #struct_name;
714
715        impl #crate_path::RustTool for #struct_name {
716            type Params = #params_name;
717            const NAME: &'static str = #tool_name_str;
718            const DESCRIPTION: &'static str = #static_description;
719
720            #description_method
721
722
723            // NOLINT: macro-generated code — the impl may not be async depending on user's function
724            #[allow(unknown_lints, clippy::unused_async_trait_impl)]
725            async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
726
727                // Import the fallback trait so `Wrap<T>::__convert()` resolves
728                // for `T: Serialize` types that lack an inherent `__convert`.
729                use #crate_path::__private::SerializeFallback as _;
730                // Destructure params into local bindings matching the original
731                // function signature.
732                let #params_name { #( #mut_tokens #param_names, )* } = params;
733                // Auto-borrow &str params from their owned String fields.
734                #( #borrow_bindings )*
735                #ctx_binding
736                #body_tokens
737            }
738        }
739    })
740}
741
742// ── Description Resolution ──────────────────────────────────────────────────
743
744/// Structured output from description resolution.
745struct DescriptionInfo {
746    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
747    static_description: String,
748    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
749    helper_tokens: proc_macro2::TokenStream,
750    /// Implementation of the `description(&self)` method if dynamic.
751    description_method: Option<proc_macro2::TokenStream>,
752    /// Cargo dependency-tracking tokens.
753    dep_tracking: proc_macro2::TokenStream,
754}
755
756pub(crate) mod desc;
757pub(crate) mod helpers;
758pub(crate) use desc::resolve_description;
759pub(crate) use helpers::{
760    build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
761    extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
762};