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().expect("ToolAttrKey::ALL is non-empty");
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)]
295pub(crate) struct 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        self.parse_by_key(&ident, key, input)
378    }
379
380    pub(crate) fn parse_by_key(
381        &mut self,
382        ident: &syn::Ident,
383        key: ToolAttrKey,
384        input: syn::parse::ParseStream,
385    ) -> syn::Result<()> {
386        match key {
387            ToolAttrKey::Description => {
388                let _: syn::Token![=] = input.parse()?;
389                if self.description_inline.is_some() {
390                    return Err(syn::Error::new(
391                        ident.span(),
392                        format!("duplicate `{}` attribute", key.as_str()),
393                    ));
394                }
395                self.description_inline = Some(input.parse::<syn::LitStr>()?);
396            }
397            ToolAttrKey::DescriptionFile => {
398                let _: syn::Token![=] = input.parse()?;
399                if self.description_file_path.is_some() {
400                    return Err(syn::Error::new(
401                        ident.span(),
402                        format!("duplicate `{}` attribute", key.as_str()),
403                    ));
404                }
405                self.description_file_path = Some(input.parse::<syn::LitStr>()?);
406            }
407            ToolAttrKey::ResponseFile => {
408                let _: syn::Token![=] = input.parse()?;
409                if self.response_file_path.is_some() {
410                    return Err(syn::Error::new(
411                        ident.span(),
412                        format!("duplicate `{}` attribute", key.as_str()),
413                    ));
414                }
415                self.response_file_path = Some(input.parse::<syn::LitStr>()?);
416            }
417            ToolAttrKey::Response => {
418                let _: syn::Token![=] = input.parse()?;
419                if self.response_inline.is_some() {
420                    return Err(syn::Error::new(
421                        ident.span(),
422                        format!("duplicate `{}` attribute", key.as_str()),
423                    ));
424                }
425                self.response_inline = Some(input.parse::<syn::LitStr>()?);
426            }
427            ToolAttrKey::Params => {
428                self.parse_params_attr(input)?;
429            }
430            ToolAttrKey::Env => {
431                self.parse_env_attr(input)?;
432            }
433            ToolAttrKey::Context => {
434                let _: syn::Token![=] = input.parse()?;
435                #[cfg(feature = "md-tmpl")]
436                {
437                    if self.context_fn.is_some() {
438                        return Err(syn::Error::new(
439                            ident.span(),
440                            format!("duplicate `{}` attribute", key.as_str()),
441                        ));
442                    }
443                    self.context_fn = Some(input.parse::<syn::Path>()?);
444                }
445                #[cfg(not(feature = "md-tmpl"))]
446                {
447                    let _path: syn::Path = input.parse()?;
448                    if self.has_context_fn {
449                        return Err(syn::Error::new(
450                            ident.span(),
451                            format!("duplicate `{}` attribute", key.as_str()),
452                        ));
453                    }
454                    self.has_context_fn = true;
455                }
456            }
457        }
458        Ok(())
459    }
460
461    pub(crate) fn build(self) -> syn::Result<ToolAttr> {
462        #[cfg(feature = "md-tmpl")]
463        let has_inline_params = !self.inline_params.is_empty();
464        #[cfg(not(feature = "md-tmpl"))]
465        let has_inline_params = self.has_inline_params;
466
467        #[cfg(feature = "md-tmpl")]
468        let has_context_fn = self.context_fn.is_some();
469        #[cfg(not(feature = "md-tmpl"))]
470        let has_context_fn = self.has_context_fn;
471
472        validate_tool_attr(&self)?;
473
474        Ok(ToolAttr {
475            description_inline: self.description_inline,
476            description_file_path: self.description_file_path,
477            response_file_path: self.response_file_path,
478            response_inline: self.response_inline,
479            #[cfg(feature = "md-tmpl")]
480            inline_params: self.inline_params,
481            #[cfg(feature = "md-tmpl")]
482            env_vars: self.env_vars,
483            #[cfg(feature = "md-tmpl")]
484            context_fn: self.context_fn,
485            has_inline_params,
486            has_context_fn,
487        })
488    }
489}
490
491impl syn::parse::Parse for ToolAttr {
492    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
493        let mut builder = ToolAttrBuilder::default();
494
495        while !input.is_empty() {
496            builder.parse_single(input)?;
497            if !input.is_empty() {
498                let _: syn::Token![,] = input.parse()?;
499            }
500        }
501
502        builder.build()
503    }
504}
505
506fn validate_tool_attr(builder: &ToolAttrBuilder) -> syn::Result<()> {
507    if builder.description_inline.is_some() && builder.description_file_path.is_some() {
508        return Err(syn::Error::new(
509            proc_macro2::Span::call_site(),
510            "`description` and `description_file` are mutually exclusive",
511        ));
512    }
513
514    if builder.response_file_path.is_some() && builder.response_inline.is_some() {
515        return Err(syn::Error::new(
516            proc_macro2::Span::call_site(),
517            "`response` and `response_file` are mutually exclusive",
518        ));
519    }
520
521    #[cfg(feature = "md-tmpl")]
522    let has_inline_params = !builder.inline_params.is_empty();
523    #[cfg(not(feature = "md-tmpl"))]
524    let has_inline_params = builder.has_inline_params;
525
526    #[cfg(feature = "md-tmpl")]
527    let has_context_fn = builder.context_fn.is_some();
528    #[cfg(not(feature = "md-tmpl"))]
529    let has_context_fn = builder.has_context_fn;
530
531    #[cfg(feature = "md-tmpl")]
532    let has_env = !builder.env_vars.is_empty();
533    #[cfg(not(feature = "md-tmpl"))]
534    let has_env = builder.has_env;
535
536    if has_inline_params && has_context_fn {
537        return Err(syn::Error::new(
538            proc_macro2::Span::call_site(),
539            "`params(...)` and `context = ...` are mutually exclusive; \
540             use `params` for compile-time values or `context` for runtime values",
541        ));
542    }
543
544    if has_inline_params
545        && builder.description_file_path.is_none()
546        && builder.description_inline.is_none()
547    {
548        return Err(syn::Error::new(
549            proc_macro2::Span::call_site(),
550            "`params(...)` requires `description_file = \"...\"` or `description = \"...\"`",
551        ));
552    }
553
554    if has_context_fn
555        && builder.description_file_path.is_none()
556        && builder.description_inline.is_none()
557    {
558        return Err(syn::Error::new(
559            proc_macro2::Span::call_site(),
560            "`context = ...` requires `description_file = \"...\"` or `description = \"...\"`",
561        ));
562    }
563
564    if has_env && builder.description_file_path.is_none() && builder.description_inline.is_none() {
565        return Err(syn::Error::new(
566            proc_macro2::Span::call_site(),
567            "`env(...)` requires `description_file = \"...\"` or `description = \"...\"`",
568        ));
569    }
570
571    #[cfg(not(feature = "md-tmpl"))]
572    if builder.description_file_path.is_some() {
573        return Err(syn::Error::new(
574            proc_macro2::Span::call_site(),
575            "`description_file` requires the `md-tmpl` feature of `llm-tool`",
576        ));
577    }
578
579    #[cfg(not(feature = "md-tmpl"))]
580    if builder.response_file_path.is_some() {
581        return Err(syn::Error::new(
582            proc_macro2::Span::call_site(),
583            "`response_file` requires the `md-tmpl` feature of `llm-tool`",
584        ));
585    }
586
587    #[cfg(not(feature = "md-tmpl"))]
588    if builder.response_inline.is_some() {
589        return Err(syn::Error::new(
590            proc_macro2::Span::call_site(),
591            "`response` requires the `md-tmpl` feature of `llm-tool`",
592        ));
593    }
594
595    Ok(())
596}
597
598// ── Implementation ──────────────────────────────────────────────────────────
599
600/// Parsed information about a single function parameter.
601struct ParamInfo {
602    name: syn::Ident,
603    ty: Box<syn::Type>,
604    doc_attrs: Vec<syn::Attribute>,
605    is_context: bool,
606    is_mut: bool,
607}
608
609/// Information about the function's return type.
610enum ReturnInfo {
611    /// `Result<T, E>` — fallible tool.
612    ResultType {
613        ok_type: Box<syn::Type>,
614        err_type: Box<syn::Type>,
615    },
616    /// Bare `T` — infallible tool.
617    BareType,
618}
619
620fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
621    let crate_path = quote! { ::llm_tool };
622    let fn_name = &func.sig.ident;
623    reject_generic_signature(func, MACRO_LLM_TOOL)?;
624    let tool_name_str = fn_name.to_string();
625    let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
626    let params_name = format_ident!("{}Params", struct_name);
627
628    // Resolve description: template file OR doc comment.
629    let DescriptionInfo {
630        static_description,
631        helper_tokens,
632        description_method,
633        dep_tracking,
634    } = resolve_description(func, attr)?;
635
636    // Resolve response template (if provided).
637    let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
638
639    // Extract parameters, separating ToolContext from regular params.
640    let all_params = extract_params(func, MACRO_LLM_TOOL)?;
641    let ctx_count = all_params.iter().filter(|p| p.is_context).count();
642    if ctx_count > 1 {
643        return Err(syn::Error::new_spanned(
644            &func.sig,
645            "#[llm_tool] functions can accept at most one ToolContext parameter",
646        ));
647    }
648    let ctx_param = all_params.iter().find(|p| p.is_context);
649    let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
650
651    // Enforce doc comments on every non-ToolContext parameter.
652    for param in &params {
653        if param.doc_attrs.is_empty() {
654            return Err(syn::Error::new_spanned(
655                &param.name,
656                format!(
657                    "#[llm_tool] parameter `{}` must have a doc comment \
658                      (used as the parameter description in the JSON schema)",
659                    param.name
660                ),
661            ));
662        }
663    }
664
665    // Parse return type: either Result<T, E> or bare T.
666    let return_info = parse_return_type(func, MACRO_LLM_TOOL)?;
667
668    let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
669    let param_descriptions: Vec<String> = params
670        .iter()
671        .map(|p| extract_doc_string(&p.doc_attrs))
672        .collect();
673
674    let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(&params);
675    let serde_defaults = build_serde_defaults(&params);
676    let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
677
678    let vis = &func.vis;
679
680    let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
681    let struct_doc = format!(
682        "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
683    );
684
685    // If the user's function takes a ToolContext parameter, bind it from the
686    // `_ctx` reference provided by the RustTool::call signature.
687    let ctx_binding = if let Some(cp) = ctx_param {
688        let ctx_name = &cp.name;
689        quote! { let #ctx_name = _ctx; }
690    } else {
691        quote! {}
692    };
693
694    let mut_tokens: Vec<proc_macro2::TokenStream> = params
695        .iter()
696        .map(|p| {
697            if p.is_mut {
698                quote! { mut }
699            } else {
700                quote! {}
701            }
702        })
703        .collect();
704
705    let response_dep_tracking = &response_info.dep_tracking;
706    let response_helper_tokens = &response_info.helper_tokens;
707
708    Ok(quote! {
709        #dep_tracking
710        #response_dep_tracking
711        #helper_tokens
712        #response_helper_tokens
713
714        #[doc = #params_doc]
715        #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
716        #vis struct #params_name {
717            #(
718                #[schemars(description = #param_descriptions)]
719                #serde_defaults
720                pub #param_names: #param_struct_types,
721            )*
722        }
723
724        #[doc = #struct_doc]
725        #vis struct #struct_name;
726
727        impl #crate_path::RustTool for #struct_name {
728            type Params = #params_name;
729            const NAME: &'static str = #tool_name_str;
730            const DESCRIPTION: &'static str = #static_description;
731
732            #description_method
733
734
735            // NOLINT: macro-generated code — the impl may not be async depending on user's function
736            #[allow(unknown_lints, clippy::unused_async_trait_impl)]
737            async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
738
739                // Import the fallback trait so `Wrap<T>::__convert()` resolves
740                // for `T: Serialize` types that lack an inherent `__convert`.
741                use #crate_path::__private::SerializeFallback as _;
742                // Destructure params into local bindings matching the original
743                // function signature.
744                let #params_name { #( #mut_tokens #param_names, )* } = params;
745                // Auto-borrow &str params from their owned String fields.
746                #( #borrow_bindings )*
747                #ctx_binding
748                #body_tokens
749            }
750        }
751    })
752}
753
754// ── Description Resolution ──────────────────────────────────────────────────
755
756/// Structured output from description resolution.
757struct DescriptionInfo {
758    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
759    static_description: String,
760    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
761    helper_tokens: proc_macro2::TokenStream,
762    /// Implementation of the `description(&self)` method if dynamic.
763    description_method: Option<proc_macro2::TokenStream>,
764    /// Cargo dependency-tracking tokens.
765    dep_tracking: proc_macro2::TokenStream,
766}
767
768pub(crate) mod desc;
769pub(crate) mod helpers;
770pub(crate) use desc::resolve_description;
771pub(crate) use helpers::{
772    build_body_tokens, build_param_types_and_borrows, build_serde_defaults, extract_doc_string,
773    extract_params, parse_return_type, reject_generic_signature, resolve_response_template,
774};