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 `prompt-templates` feature enabled, tool descriptions can be
8//! loaded from `.tmpl.md` template files via `prompt_file = "..."`, and tool
9//! responses can be auto-rendered through templates via
10//! `response_file = "..."`.
11#[cfg(feature = "prompt-templates")]
12mod response_struct_gen;
13#[cfg(feature = "prompt-templates")]
14mod template_codegen;
15#[cfg(feature = "prompt-templates")]
16mod template_compile;
17
18use convert_case::{Case, Casing};
19use proc_macro::TokenStream;
20use quote::{format_ident, quote};
21#[cfg(feature = "prompt-templates")]
22use syn::Ident;
23use syn::{
24    FnArg, GenericArgument, ItemFn, LitStr, Pat, PatType, PathArguments, Type, parse_macro_input,
25};
26
27/// Transforms a function into a `RustTool` implementation.
28///
29/// The macro generates:
30/// - A `{FnName}Params` struct deriving `Deserialize` and `JsonSchema`
31/// - A `{FnName}` unit struct (`PascalCase`) implementing `RustTool`
32///
33/// The tool **name** is the function name (`snake_case`).
34/// The tool **description** comes from one of the sources below.
35/// Parameter names and types come from the function signature.
36/// Doc comments on parameters become schema descriptions.
37///
38/// # Description sources (in priority order)
39///
40/// | Syntax | Cost | Feature |
41/// |--------|------|---------|
42/// | `#[llm_tool]` + doc comment | Zero (static `&str`) | — |
43/// | `#[llm_tool(prompt = "inline text")]` | Zero (static `&str`) | — |
44/// | `#[llm_tool(response_file = "...")]` | Runtime render | `prompt-templates` |
45/// | `#[llm_tool(prompt_file = "tools/x.tmpl.md")]` | Zero (compiled) | `prompt-templates` |
46/// | `#[llm_tool(prompt_file = "...", params(k = "v"))]` | Zero (compiled) | `prompt-templates` |
47/// | `#[llm_tool(prompt_file = "...", context = fn)]` | Runtime `Cow::Owned` | `prompt-templates` |
48///
49/// ## Inline description
50///
51/// Override or replace the doc comment with an inline string:
52///
53/// ```text
54/// #[llm_tool(prompt = "Get the current weather for a city.")]
55/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
56/// ```
57///
58/// ## Template descriptions (feature: `prompt-templates`)
59///
60/// Load the description from a `.tmpl.md` file:
61///
62/// ```text
63/// #[llm_tool(prompt_file = "tools/weather.tmpl.md")]
64/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
65/// ```
66///
67/// For templates with variables, provide **compile-time** key-value pairs:
68///
69/// ```text
70/// #[llm_tool(prompt_file = "tools/weather.tmpl.md", params(api = "v3", env = "prod"))]
71/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
72/// ```
73///
74/// The macro reads the template, validates all declared variables are
75/// provided, renders the description, and embeds the result as a static
76/// string — **zero runtime cost**.
77///
78/// For **runtime** context (e.g. values from config), provide a context function:
79///
80/// ```text
81/// #[llm_tool(prompt_file = "tools/weather.tmpl.md", context = build_ctx)]
82/// fn get_weather(/* … */) -> Result<String, ToolError> { /* … */ }
83/// ```
84///
85/// The context function signature is `fn(&ToolStruct) -> Context`.
86/// Templates are parsed once at startup via `LazyLock`.
87///
88/// # Typed parameters
89///
90/// Parameters may use `&str` — the generated params struct stores an owned
91/// `String` and the macro auto-borrows it before passing to your function body.
92///
93/// # Response templates
94///
95/// When `response_file = "path/to/response.tmpl.md"` is provided, the
96/// tool's return value (`T: Serialize`) is used to build a template context
97/// via `Context::from_serialize`, rendered through the template, and returned
98/// as `ToolOutput`. The struct is also attached as metadata.
99///
100/// # Return types
101///
102/// The return type can be `Result<T, E>` or just `T` (infallible):
103///
104/// - **`T`**: `String` (wrapped as-is), `ToolOutput` (passed through), any
105///   `T: Serialize` (auto-serialized to JSON), or any `T: Into<ToolOutput>`
106/// - **`E`**: any `E: Into<ToolError>` — built-in for `String`, `ToolError`,
107///   `std::io::Error`, `serde_json::Error`
108#[proc_macro_attribute]
109pub fn llm_tool(attr: TokenStream, item: TokenStream) -> TokenStream {
110    let func = parse_macro_input!(item as ItemFn);
111    let tool_attr = if attr.is_empty() {
112        None
113    } else {
114        match syn::parse::<ToolAttr>(attr) {
115            Ok(parsed) => Some(parsed),
116            Err(err) => return err.to_compile_error().into(),
117        }
118    };
119    match tool_impl(&func, tool_attr.as_ref()) {
120        Ok(tokens) => tokens.into(),
121        Err(err) => err.to_compile_error().into(),
122    }
123}
124
125// ── Attribute Parsing ───────────────────────────────────────────────────────
126
127/// Parsed `#[llm_tool(...)]` attribute.
128///
129/// Supports:
130/// - `prompt = "inline text"` — static inline description
131/// - `prompt_file = "path.tmpl.md"` — template file (requires `prompt-templates`)
132/// - `params(key = "value", ...)` — compile-time template variables
133/// - `context = path::to::fn` — runtime template context function
134/// - `response_file = "path.tmpl.md"` — response rendering template
135struct ToolAttr {
136    /// Inline description string (mutually exclusive with `prompt_file_path`).
137    prompt_inline: Option<LitStr>,
138    /// Path to a `.tmpl.md` file (mutually exclusive with `prompt_inline`).
139    prompt_file_path: Option<LitStr>,
140    /// Path to a response `.tmpl.md` file for auto-rendering tool output.
141    response_file_path: Option<LitStr>,
142    /// Inline response template string (mutually exclusive with `response_file_path`).
143    response_inline: Option<LitStr>,
144    /// Compile-time key-value pairs for template rendering.
145    /// Mutually exclusive with `context_fn`.
146    #[cfg(feature = "prompt-templates")]
147    inline_params: Vec<(Ident, LitStr)>,
148    /// Runtime context function (mutually exclusive with `inline_params`).
149    #[cfg(feature = "prompt-templates")]
150    context_fn: Option<syn::Path>,
151    has_inline_params: bool,
152    has_context_fn: bool,
153}
154
155const ATTR_PROMPT: &str = "prompt";
156const ATTR_PROMPT_FILE: &str = "prompt_file";
157const ATTR_RESPONSE_FILE: &str = "response_file";
158const ATTR_RESPONSE: &str = "response";
159const ATTR_PARAMS: &str = "params";
160const ATTR_CONTEXT: &str = "context";
161const TYPE_OPTION: &str = "Option";
162const TYPE_TOOL_CONTEXT: &str = "ToolContext";
163const TYPE_STR: &str = "str";
164const ATTR_LLM_TOOL: &str = "llm_tool";
165
166#[derive(Default)]
167struct ToolAttrBuilder {
168    prompt_inline: Option<syn::LitStr>,
169    prompt_file_path: Option<syn::LitStr>,
170    response_file_path: Option<syn::LitStr>,
171    response_inline: Option<syn::LitStr>,
172    #[cfg(feature = "prompt-templates")]
173    inline_params: Vec<(syn::Ident, syn::LitStr)>,
174    #[cfg(feature = "prompt-templates")]
175    context_fn: Option<syn::Path>,
176    #[cfg(not(feature = "prompt-templates"))]
177    has_inline_params: bool,
178    #[cfg(not(feature = "prompt-templates"))]
179    has_context_fn: bool,
180}
181
182impl ToolAttrBuilder {
183    fn parse_single(&mut self, input: syn::parse::ParseStream) -> syn::Result<()> {
184        let ident: syn::Ident = input.parse()?;
185        if ident == ATTR_PROMPT {
186            let _: syn::Token![=] = input.parse()?;
187            self.prompt_inline = Some(input.parse::<syn::LitStr>()?);
188        } else if ident == ATTR_PROMPT_FILE {
189            let _: syn::Token![=] = input.parse()?;
190            self.prompt_file_path = Some(input.parse::<syn::LitStr>()?);
191        } else if ident == ATTR_RESPONSE_FILE {
192            let _: syn::Token![=] = input.parse()?;
193            self.response_file_path = Some(input.parse::<syn::LitStr>()?);
194        } else if ident == ATTR_RESPONSE {
195            let _: syn::Token![=] = input.parse()?;
196            self.response_inline = Some(input.parse::<syn::LitStr>()?);
197        } else if ident == ATTR_PARAMS {
198            let content;
199            syn::parenthesized!(content in input);
200            while !content.is_empty() {
201                let key: syn::Ident = content.parse()?;
202                let _: syn::Token![=] = content.parse()?;
203                let value: syn::LitStr = content.parse()?;
204                #[cfg(feature = "prompt-templates")]
205                self.inline_params.push((key, value));
206                #[cfg(not(feature = "prompt-templates"))]
207                {
208                    drop(key);
209                    drop(value);
210                }
211                if !content.is_empty() {
212                    let _: syn::Token![,] = content.parse()?;
213                }
214            }
215            #[cfg(not(feature = "prompt-templates"))]
216            {
217                self.has_inline_params = true;
218            }
219        } else if ident == ATTR_CONTEXT {
220            let _: syn::Token![=] = input.parse()?;
221            #[cfg(feature = "prompt-templates")]
222            {
223                self.context_fn = Some(input.parse::<syn::Path>()?);
224            }
225            #[cfg(not(feature = "prompt-templates"))]
226            {
227                let _path: syn::Path = input.parse()?;
228                self.has_context_fn = true;
229            }
230        } else {
231            return Err(syn::Error::new(
232                ident.span(),
233                "expected `prompt`, `prompt_file`, `response`, `response_file`, `params`, or `context`",
234            ));
235        }
236        Ok(())
237    }
238}
239
240impl syn::parse::Parse for ToolAttr {
241    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
242        let mut builder = ToolAttrBuilder::default();
243
244        while !input.is_empty() {
245            builder.parse_single(input)?;
246            if !input.is_empty() {
247                let _: syn::Token![,] = input.parse()?;
248            }
249        }
250
251        #[cfg(feature = "prompt-templates")]
252        let (has_inline_params, has_context_fn) = (
253            !builder.inline_params.is_empty(),
254            builder.context_fn.is_some(),
255        );
256        #[cfg(not(feature = "prompt-templates"))]
257        let (has_inline_params, has_context_fn) =
258            (builder.has_inline_params, builder.has_context_fn);
259
260        validate_tool_attr(
261            builder.prompt_inline.as_ref(),
262            builder.prompt_file_path.as_ref(),
263            has_inline_params,
264            has_context_fn,
265        )?;
266
267        if builder.response_inline.is_some() && builder.response_file_path.is_some() {
268            return Err(syn::Error::new(
269                proc_macro2::Span::call_site(),
270                "cannot specify both `response` and `response_file`",
271            ));
272        }
273
274        // Validate response_file requires prompt-templates feature.
275        #[cfg(not(feature = "prompt-templates"))]
276        if builder.response_file_path.is_some() || builder.response_inline.is_some() {
277            return Err(syn::Error::new(
278                proc_macro2::Span::call_site(),
279                "the `prompt-templates` feature must be enabled to use `response = \"...\"` or `response_file = \"...\"`",
280            ));
281        }
282
283        Ok(Self {
284            prompt_inline: builder.prompt_inline,
285            prompt_file_path: builder.prompt_file_path,
286            response_file_path: builder.response_file_path,
287            response_inline: builder.response_inline,
288            #[cfg(feature = "prompt-templates")]
289            inline_params: builder.inline_params,
290            #[cfg(feature = "prompt-templates")]
291            context_fn: builder.context_fn,
292            has_inline_params,
293            has_context_fn,
294        })
295    }
296}
297
298/// Validate mutual-exclusion and presence constraints for parsed `#[llm_tool(...)]`
299/// attribute fields.
300fn validate_tool_attr(
301    prompt_inline: Option<&LitStr>,
302    prompt_file_path: Option<&LitStr>,
303    has_inline_params: bool,
304    has_context_fn: bool,
305) -> syn::Result<()> {
306    // Mutual exclusion: prompt vs prompt_file.
307    if prompt_inline.is_some() && prompt_file_path.is_some() {
308        return Err(syn::Error::new(
309            proc_macro2::Span::call_site(),
310            "`prompt` and `prompt_file` are mutually exclusive",
311        ));
312    }
313
314    // params/context only make sense with prompt_file.
315    if prompt_file_path.is_none() && has_inline_params {
316        return Err(syn::Error::new(
317            proc_macro2::Span::call_site(),
318            "`params(...)` requires `prompt_file = \"...\"`",
319        ));
320    }
321    if prompt_file_path.is_none() && has_context_fn {
322        return Err(syn::Error::new(
323            proc_macro2::Span::call_site(),
324            "`context = ...` requires `prompt_file = \"...\"`",
325        ));
326    }
327
328    // params and context are mutually exclusive.
329    if has_inline_params && has_context_fn {
330        return Err(syn::Error::new(
331            proc_macro2::Span::call_site(),
332            "`params(...)` and `context = ...` are mutually exclusive; \
333             use `params` for compile-time values or `context` for runtime values",
334        ));
335    }
336
337    // Must have at least prompt or prompt_file (unless only response_file
338    // is set, in which case doc comments serve as the description).
339    if prompt_inline.is_none()
340        && prompt_file_path.is_none()
341        && !has_inline_params
342        && !has_context_fn
343    {
344        // This is fine — doc comments will be used as fallback.
345    }
346
347    Ok(())
348}
349
350// ── Implementation ──────────────────────────────────────────────────────────
351
352/// Parsed information about a single function parameter.
353struct ParamInfo {
354    name: syn::Ident,
355    ty: Box<syn::Type>,
356    doc_attrs: Vec<syn::Attribute>,
357    is_context: bool,
358}
359
360/// Information about the function's return type.
361enum ReturnInfo {
362    /// `Result<T, E>` — fallible tool.
363    ResultType {
364        ok_type: Box<syn::Type>,
365        err_type: Box<syn::Type>,
366    },
367    /// Bare `T` — infallible tool.
368    BareType,
369}
370
371fn tool_impl(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<proc_macro2::TokenStream> {
372    let crate_path = quote! { ::llm_tool };
373    let fn_name = &func.sig.ident;
374    let tool_name_str = fn_name.to_string();
375    let struct_name = format_ident!("{}", tool_name_str.to_case(Case::Pascal));
376    let params_name = format_ident!("{}Params", struct_name);
377
378    // Resolve description: template file OR doc comment.
379    let DescriptionInfo {
380        static_description,
381        helper_tokens,
382        description_method,
383        dep_tracking,
384    } = resolve_description(func, attr)?;
385
386    // Resolve response template (if provided).
387    let response_info = resolve_response_template(attr, &struct_name, fn_name)?;
388
389    // Extract parameters, separating ToolContext from regular params.
390    let all_params = extract_params(func)?;
391    let ctx_param = all_params.iter().find(|p| p.is_context);
392    let params: Vec<&ParamInfo> = all_params.iter().filter(|p| !p.is_context).collect();
393
394    // Enforce doc comments on every non-ToolContext parameter.
395    for param in &params {
396        if param.doc_attrs.is_empty() {
397            return Err(syn::Error::new_spanned(
398                &param.name,
399                format!(
400                    "#[llm_tool] parameter `{}` must have a doc comment \
401                      (used as the parameter description in the JSON schema)",
402                    param.name
403                ),
404            ));
405        }
406    }
407
408    // Parse return type: either Result<T, E> or bare T.
409    let return_info = parse_return_type(func)?;
410
411    let param_names: Vec<_> = params.iter().map(|p| &p.name).collect();
412    let param_descriptions: Vec<String> = params
413        .iter()
414        .map(|p| extract_doc_string(&p.doc_attrs))
415        .collect();
416
417    let (param_struct_types, borrow_bindings) = build_param_types_and_borrows(&params);
418    let serde_defaults = build_serde_defaults(&params);
419    let body_tokens = build_body_tokens(func, &return_info, &crate_path, &response_info);
420
421    let vis = &func.vis;
422
423    let params_doc = format!("Auto-generated parameters for the [`{struct_name}`] tool.");
424    let struct_doc = format!(
425        "Auto-generated tool struct. See the `#[llm_tool]`-annotated function `{fn_name}` for the implementation."
426    );
427
428    // If the user's function takes a ToolContext parameter, bind it from the
429    // `_ctx` reference provided by the RustTool::call signature.
430    let ctx_binding = if let Some(cp) = ctx_param {
431        let ctx_name = &cp.name;
432        quote! { let #ctx_name = _ctx; }
433    } else {
434        quote! {}
435    };
436
437    let response_dep_tracking = &response_info.dep_tracking;
438    let response_helper_tokens = &response_info.helper_tokens;
439
440    Ok(quote! {
441        #dep_tracking
442        #response_dep_tracking
443        #helper_tokens
444        #response_helper_tokens
445
446        #[doc = #params_doc]
447        #[derive(::serde::Deserialize, ::schemars::JsonSchema)]
448        #vis struct #params_name {
449            #(
450                #[schemars(description = #param_descriptions)]
451                #serde_defaults
452                pub #param_names: #param_struct_types,
453            )*
454        }
455
456        #[doc = #struct_doc]
457        #vis struct #struct_name;
458
459        impl #crate_path::RustTool for #struct_name {
460            type Params = #params_name;
461            const NAME: &'static str = #tool_name_str;
462            const DESCRIPTION: &'static str = #static_description;
463
464            #description_method
465
466            async fn call(&self, params: Self::Params, _ctx: &#crate_path::ToolContext) -> ::core::result::Result<#crate_path::ToolOutput, #crate_path::ToolError> {
467                // Import the fallback trait so `Wrap<T>::__convert()` resolves
468                // for `T: Serialize` types that lack an inherent `__convert`.
469                use #crate_path::__private::SerializeFallback as _;
470                // Destructure params into local bindings matching the original
471                // function signature.
472                let #params_name { #( #param_names, )* } = params;
473                // Auto-borrow &str params from their owned String fields.
474                #( #borrow_bindings )*
475                #ctx_binding
476                #body_tokens
477            }
478        }
479    })
480}
481
482// ── Description Resolution ──────────────────────────────────────────────────
483
484/// Structured output from description resolution.
485struct DescriptionInfo {
486    /// Value for `const DESCRIPTION`. For dynamic descriptions, this contains the raw template body.
487    static_description: String,
488    /// Helper tokens to emit in the crate scope (e.g. `static TEMPLATE`).
489    helper_tokens: proc_macro2::TokenStream,
490    /// Implementation of the `description(&self)` method if dynamic.
491    description_method: Option<proc_macro2::TokenStream>,
492    /// Cargo dependency-tracking tokens.
493    dep_tracking: proc_macro2::TokenStream,
494}
495
496/// Resolve the tool description from attribute or doc comments.
497fn resolve_description(func: &ItemFn, attr: Option<&ToolAttr>) -> syn::Result<DescriptionInfo> {
498    match attr {
499        // Inline prompt template or string.
500        Some(
501            tool_attr @ ToolAttr {
502                prompt_inline: Some(_),
503                ..
504            },
505        ) => resolve_inline_description(tool_attr),
506        // Template file.
507        Some(
508            tool_attr @ ToolAttr {
509                prompt_file_path: Some(_),
510                ..
511            },
512        ) => resolve_template_description(tool_attr),
513        // No attribute, or attribute with only response_file — use doc comment.
514        _ => {
515            let desc = extract_doc_string(&func.attrs);
516            if desc.is_empty() {
517                return Err(syn::Error::new_spanned(
518                    &func.sig.ident,
519                    "#[llm_tool] functions must have a doc comment \
520                     (used as the tool description), or use \
521                     #[llm_tool(prompt = \"...\")]",
522                ));
523            }
524            Ok(DescriptionInfo {
525                static_description: desc,
526                helper_tokens: quote! {},
527                description_method: None,
528                dep_tracking: quote! {},
529            })
530        }
531    }
532}
533
534/// Resolve dynamic/static description from inline template string.
535fn resolve_inline_description(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
536    #[cfg(not(feature = "prompt-templates"))]
537    {
538        let span = attr
539            .prompt_inline
540            .as_ref()
541            .map_or(proc_macro2::Span::call_site(), LitStr::span);
542        if attr.has_inline_params || attr.has_context_fn {
543            return Err(syn::Error::new(
544                span,
545                "the `prompt-templates` feature must be enabled to use dynamic inline prompts",
546            ));
547        }
548        let desc = attr.prompt_inline.as_ref().unwrap().value();
549        Ok(DescriptionInfo {
550            static_description: desc,
551            helper_tokens: quote! {},
552            description_method: None,
553            dep_tracking: quote! {},
554        })
555    }
556
557    #[cfg(feature = "prompt-templates")]
558    resolve_inline_description_impl(attr)
559}
560
561/// Read a `.tmpl.md` template file and extract its body as the tool description.
562fn resolve_template_description(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
563    #[cfg(not(feature = "prompt-templates"))]
564    {
565        let span = attr
566            .prompt_file_path
567            .as_ref()
568            .map_or(proc_macro2::Span::call_site(), LitStr::span);
569        Err(syn::Error::new(
570            span,
571            "the `prompt-templates` feature must be enabled to use \
572             `#[llm_tool(prompt_file = \"...\")]`. \
573             Add `features = [\"prompt-templates\"]` to your llm-tool dependency.",
574        ))
575    }
576
577    #[cfg(feature = "prompt-templates")]
578    resolve_template_description_impl(attr)
579}
580
581/// Implementation of template description resolution (feature-gated).
582///
583/// Handles three sub-cases:
584/// 1. Static template (no declared variables) → `const DESCRIPTION`
585/// 2. Template + `params(...)` → compile-time render → `const DESCRIPTION`
586/// 3. Template + `context = fn` → runtime render via `description()` method
587#[cfg(feature = "prompt-templates")]
588fn resolve_template_description_impl(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
589    let template_lit = attr
590        .prompt_file_path
591        .as_ref()
592        .expect("prompt_file_path validated");
593    let rel_path = template_lit.value();
594    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
595    let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
596
597    let source = std::fs::read_to_string(&full_path).map_err(|e| {
598        syn::Error::new(
599            template_lit.span(),
600            format!("failed to read template '{}': {e}", full_path.display()),
601        )
602    })?;
603    let source = template_compile::normalize_and_validate_syntax(&source, &rel_path)
604        .map_err(|e| syn::Error::new(template_lit.span(), e))?;
605
606    let cur_dir = template_compile::REL_PREFIX_CUR.trim_end_matches(template_compile::CHAR_SLASH);
607    let base_dir = full_path.parent().unwrap_or(std::path::Path::new(cur_dir));
608    let (fm, body) =
609        prompt_templates::parse_frontmatter_with_base_dir(&source, base_dir).map_err(|e| {
610            syn::Error::new(
611                template_lit.span(),
612                format!("template '{rel_path}' error: {e}"),
613            )
614        })?;
615
616    let body_str = body.trim().to_string();
617    let path_str = full_path.to_string_lossy().to_string();
618
619    // include_str! establishes a file dependency so cargo rebuilds
620    // when the template changes.
621    let dep_tracking = quote! {
622        const _: &str = include_str!(#path_str);
623    };
624
625    let has_params = !attr.inline_params.is_empty();
626    let has_context = attr.context_fn.is_some();
627    let has_declarations = !fm.declarations.is_empty();
628
629    if !has_declarations && !has_params && !has_context {
630        // Case 1: Static template — no variables, no params, no context.
631        Ok(DescriptionInfo {
632            static_description: body_str,
633            helper_tokens: quote! {},
634            description_method: None,
635            dep_tracking,
636        })
637    } else if has_params {
638        // Case 2: Compile-time params — render at build time.
639        resolve_template_with_params(
640            attr,
641            &fm,
642            &source,
643            &rel_path,
644            template_lit.span(),
645            dep_tracking,
646        )
647    } else if has_context {
648        // Case 3: Runtime context function.
649        resolve_context_description(ResolveContextArgs {
650            attr,
651            rel_path: &rel_path,
652            template_lit,
653            source: &source,
654            full_path: &full_path,
655            body_str: &body_str,
656            has_declarations,
657            dep_tracking,
658        })
659    } else {
660        // Template declares variables but neither params nor context provided.
661        let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
662        Err(syn::Error::new(
663            template_lit.span(),
664            format!(
665                "template '{rel_path}' declares parameters ({}) but neither \
666                 `params(...)` nor `context = ...` was provided",
667                declared.join(", ")
668            ),
669        ))
670    }
671}
672
673/// Implementation of inline template description resolution (feature-gated).
674#[cfg(feature = "prompt-templates")]
675fn resolve_inline_description_impl(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
676    let template_lit = attr
677        .prompt_inline
678        .as_ref()
679        .expect("prompt_inline validated");
680    let source = template_lit.value();
681    let trimmed = source.trim_start();
682    if !trimmed.starts_with(template_compile::FRONTMATTER_DELIM) {
683        return Ok(DescriptionInfo {
684            static_description: source,
685            helper_tokens: quote! {},
686            description_method: None,
687            dep_tracking: quote! {},
688        });
689    }
690
691    let source =
692        template_compile::normalize_and_validate_syntax(&source, template_compile::LABEL_INLINE)
693            .map_err(|e| syn::Error::new(template_lit.span(), e))?;
694    let (fm, body) = prompt_templates::parse_frontmatter(&source)
695        .map_err(|e| syn::Error::new(template_lit.span(), format!("inline template error: {e}")))?;
696
697    let body_str = body.trim().to_string();
698
699    let has_params = attr.has_inline_params;
700    let has_context = attr.has_context_fn;
701    let has_declarations = !fm.declarations.is_empty();
702
703    if !has_declarations && !has_params && !has_context {
704        // Case 1: Static template — no variables, no params, no context.
705        Ok(DescriptionInfo {
706            static_description: body_str,
707            helper_tokens: quote! {},
708            description_method: None,
709            dep_tracking: quote! {},
710        })
711    } else if has_params {
712        // Case 2: Compile-time params — render at build time.
713        resolve_template_with_params(
714            attr,
715            &fm,
716            &source,
717            "<inline>",
718            template_lit.span(),
719            quote! {},
720        )
721    } else if has_context {
722        // Case 3: Runtime context function.
723        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
724        let base_dir = std::path::Path::new(&manifest_dir);
725        let ast = template_compile::compile_template_to_ast(&source, base_dir).map_err(|e| {
726            syn::Error::new(
727                template_lit.span(),
728                format!("inline template compilation error: {e}"),
729            )
730        })?;
731        let tmpl_tokens = template_codegen::codegen_template(&ast);
732
733        let context_fn = attr.context_fn.as_ref().unwrap();
734
735        let description_method = quote! {
736            fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
737                static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__prompt_templates::Template> =
738                    ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
739                let ctx = #context_fn(self);
740                let rendered = TEMPLATE.render_ctx(&ctx)
741                    .expect("Failed to render tool description template");
742                ::llm_tool::__private::Cow::Owned(rendered)
743            }
744        };
745
746        Ok(DescriptionInfo {
747            static_description: body_str.clone(),
748            helper_tokens: quote! {},
749            description_method: Some(description_method),
750            dep_tracking: quote! {},
751        })
752    } else {
753        let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
754        Err(syn::Error::new(
755            template_lit.span(),
756            format!(
757                "inline template declares parameters ({}) but neither \
758                 `params(...)` nor `context = ...` was provided",
759                declared.join(", ")
760            ),
761        ))
762    }
763}
764
765#[cfg(feature = "prompt-templates")]
766struct ResolveContextArgs<'a> {
767    attr: &'a ToolAttr,
768    rel_path: &'a str,
769    template_lit: &'a LitStr,
770    source: &'a str,
771    full_path: &'a std::path::Path,
772    body_str: &'a str,
773    has_declarations: bool,
774    dep_tracking: proc_macro2::TokenStream,
775}
776
777/// Resolve a template description with a runtime context function.
778///
779/// Generates a `description(&self)` method that uses `LazyLock` to parse
780/// the template once, then renders it with the user-provided context function
781/// on every call.
782#[cfg(feature = "prompt-templates")]
783fn resolve_context_description(args: ResolveContextArgs<'_>) -> syn::Result<DescriptionInfo> {
784    let ResolveContextArgs {
785        attr,
786        rel_path,
787        template_lit,
788        source,
789        full_path,
790        body_str,
791        has_declarations,
792        dep_tracking,
793    } = args;
794    let context_fn = attr.context_fn.as_ref().ok_or_else(|| {
795        syn::Error::new(
796            template_lit.span(),
797            "internal error: resolve_context_description called without context_fn",
798        )
799    })?;
800
801    if !has_declarations {
802        return Err(syn::Error::new(
803            template_lit.span(),
804            format!(
805                "template '{rel_path}' has no declared parameters, \
806                 so `context = ...` is unnecessary. Remove `context` \
807                 or add params to the template."
808            ),
809        ));
810    }
811
812    let base_dir = full_path.parent().unwrap_or(std::path::Path::new("."));
813    let ast = template_compile::compile_template_to_ast(source, base_dir).map_err(|e| {
814        syn::Error::new(
815            template_lit.span(),
816            format!("template '{rel_path}' compilation error: {e}"),
817        )
818    })?;
819    let tmpl_tokens = template_codegen::codegen_template(&ast);
820
821    // Generate LazyLock inside description() to avoid name collisions
822    // when multiple dynamic-description tools exist in the same module.
823    let description_method = quote! {
824        fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
825            static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__prompt_templates::Template> =
826                ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
827            let ctx = #context_fn(self);
828            let rendered = TEMPLATE.render_ctx(&ctx)
829                .expect("Failed to render tool description template");
830            ::llm_tool::__private::Cow::Owned(rendered)
831        }
832    };
833
834    Ok(DescriptionInfo {
835        static_description: body_str.to_string(),
836        helper_tokens: quote! {},
837        description_method: Some(description_method),
838        dep_tracking,
839    })
840}
841
842/// Render a template with compile-time `params(...)` values.
843///
844/// Validates:
845/// - Every declared template variable has a matching `params(...)` key
846/// - Every `params(...)` key matches a declared template variable
847/// - The template renders without errors
848#[cfg(feature = "prompt-templates")]
849fn resolve_template_with_params(
850    attr: &ToolAttr,
851    fm: &prompt_templates::Frontmatter,
852    source: &str,
853    rel_path: &str,
854    span: proc_macro2::Span,
855    dep_tracking: proc_macro2::TokenStream,
856) -> syn::Result<DescriptionInfo> {
857    let mut expected_names = std::collections::HashSet::new();
858    let mut struct_fields: std::collections::HashMap<String, String> =
859        std::collections::HashMap::new();
860
861    for decl in &fm.declarations {
862        if let prompt_templates::VarType::Struct(fields) = &decl.var_type {
863            for f in fields {
864                expected_names.insert(f.name.as_str());
865                struct_fields.insert(f.name.clone(), decl.name.clone());
866            }
867        } else {
868            expected_names.insert(decl.name.as_str());
869        }
870    }
871
872    let provided_names: std::collections::HashSet<String> = attr
873        .inline_params
874        .iter()
875        .map(|(k, _)| k.to_string())
876        .collect();
877
878    // Check for missing params (declared but not provided).
879    let missing: Vec<&str> = expected_names
880        .iter()
881        .filter(|n| !provided_names.contains(**n))
882        .copied()
883        .collect();
884    if !missing.is_empty() {
885        return Err(syn::Error::new(
886            span,
887            format!(
888                "template '{rel_path}' declares parameters not provided in `params(...)`: {}",
889                missing.join(", ")
890            ),
891        ));
892    }
893
894    // Check for extra params (provided but not declared).
895    for (key, _) in &attr.inline_params {
896        let key_str = key.to_string();
897        if !expected_names.contains(key_str.as_str()) {
898            return Err(syn::Error::new(
899                key.span(),
900                format!(
901                    "param `{key_str}` is not declared in template '{rel_path}'. \
902                     Declared params: {}",
903                    expected_names.into_iter().collect::<Vec<_>>().join(", ")
904                ),
905            ));
906        }
907    }
908
909    // Build context and render at compile time.
910    let template = prompt_templates::Template::from_source(source)
911        .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' parse error: {e}")))?;
912
913    let mut root_values: std::collections::HashMap<String, prompt_templates::Value> =
914        std::collections::HashMap::new();
915    let mut struct_maps: std::collections::HashMap<
916        String,
917        std::collections::HashMap<String, prompt_templates::Value>,
918    > = std::collections::HashMap::new();
919
920    for (key, value) in &attr.inline_params {
921        let key_str = key.to_string();
922        if let Some(parent_struct) = struct_fields.get(&key_str) {
923            struct_maps
924                .entry(parent_struct.clone())
925                .or_default()
926                .insert(key_str, prompt_templates::Value::Str(value.value()));
927        } else {
928            root_values.insert(key_str, prompt_templates::Value::Str(value.value()));
929        }
930    }
931
932    for (struct_name, s_map) in struct_maps {
933        root_values.insert(
934            struct_name,
935            prompt_templates::Value::Struct(std::sync::Arc::new(s_map.into_iter().collect())),
936        );
937    }
938
939    let mut ctx = prompt_templates::Context::new();
940    for (k, v) in root_values {
941        ctx.set(k, v);
942    }
943
944    let rendered = template
945        .render_ctx(&ctx)
946        .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' render error: {e}")))?;
947
948    Ok(DescriptionInfo {
949        static_description: rendered,
950        helper_tokens: quote! {},
951        description_method: None,
952        dep_tracking,
953    })
954}
955
956/// Build the struct field types and any auto-borrow bindings for `&str` params.
957fn build_param_types_and_borrows(
958    params: &[&ParamInfo],
959) -> (Vec<proc_macro2::TokenStream>, Vec<proc_macro2::TokenStream>) {
960    params
961        .iter()
962        .map(|p| {
963            if is_str_ref(&p.ty) {
964                // &str → String in struct, auto-borrow in body
965                let name = &p.name;
966                (quote! { String }, quote! { let #name: &str = &#name; })
967            } else {
968                let ty = &p.ty;
969                (quote! { #ty }, quote! {})
970            }
971        })
972        .unzip()
973}
974
975/// Build `#[serde(default)]` annotations for `Option<T>` params.
976fn build_serde_defaults(params: &[&ParamInfo]) -> Vec<proc_macro2::TokenStream> {
977    params
978        .iter()
979        .map(|p| {
980            if is_option_type(&p.ty) {
981                quote! { #[serde(default)] }
982            } else {
983                quote! {}
984            }
985        })
986        .collect()
987}
988
989/// Build the body tokens that wrap the user's function body.
990///
991/// Uses compile-time dispatch via `__private::Wrap(v).__convert()` —
992/// the compiler resolves the correct conversion (inherent method for
993/// `String`/`ToolOutput`/`Json<T>`, or `SerializeFallback` trait for
994/// `T: Serialize`) without any proc-macro type-name matching.
995///
996/// When a `response_template` is specified, the return value is instead
997/// rendered through the template and returned as `ToolOutput` with the
998/// struct attached as metadata.
999fn build_body_tokens(
1000    func: &ItemFn,
1001    return_info: &ReturnInfo,
1002    crate_path: &proc_macro2::TokenStream,
1003    response_info: &ResponseTemplateInfo,
1004) -> proc_macro2::TokenStream {
1005    let is_async = func.sig.asyncness.is_some();
1006    let body_stmts = &func.block.stmts;
1007
1008    match return_info {
1009        ReturnInfo::ResultType { ok_type, err_type } => {
1010            let inner = if is_async {
1011                quote! {
1012                    let __r: ::core::result::Result<#ok_type, #err_type> = async move {
1013                        #( #body_stmts )*
1014                    }.await;
1015                }
1016            } else {
1017                quote! {
1018                    let __r: ::core::result::Result<#ok_type, #err_type> = (|| { #( #body_stmts )* })();
1019                }
1020            };
1021            let ok_branch = build_ok_branch(crate_path, response_info);
1022            quote! {
1023                #inner
1024                match __r {
1025                    ::core::result::Result::Ok(__v) => { #ok_branch },
1026                    ::core::result::Result::Err(__e) => ::core::result::Result::Err(::core::convert::Into::into(__e)),
1027                }
1028            }
1029        }
1030        ReturnInfo::BareType => {
1031            let inner = if is_async {
1032                quote! {
1033                    let __v = async move { #( #body_stmts )* }.await;
1034                }
1035            } else {
1036                quote! {
1037                    let __v = (|| { #( #body_stmts )* })();
1038                }
1039            };
1040            let ok_branch = build_ok_branch(crate_path, response_info);
1041            quote! {
1042                #inner
1043                #ok_branch
1044            }
1045        }
1046    }
1047}
1048
1049/// Build the Ok-branch conversion: either the standard `Wrap(v).__convert()`
1050/// or template-based rendering when `response_template` is set.
1051fn build_ok_branch(
1052    crate_path: &proc_macro2::TokenStream,
1053    response_info: &ResponseTemplateInfo,
1054) -> proc_macro2::TokenStream {
1055    if let Some(ref render_tokens) = response_info.render_tokens {
1056        render_tokens.clone()
1057    } else {
1058        quote! { #crate_path::__private::Wrap(__v).__convert() }
1059    }
1060}
1061
1062// ── Response Template Resolution ────────────────────────────────────────────
1063
1064/// Structured output from response template resolution.
1065struct ResponseTemplateInfo {
1066    /// Cargo dependency-tracking tokens.
1067    dep_tracking: proc_macro2::TokenStream,
1068    /// Helper tokens (e.g. static `LazyLock` declarations).
1069    helper_tokens: proc_macro2::TokenStream,
1070    /// Token stream that converts `__v` into `Result<ToolOutput, ToolError>`
1071    /// via template rendering. `None` = use default `__convert()` path.
1072    render_tokens: Option<proc_macro2::TokenStream>,
1073}
1074
1075impl Default for ResponseTemplateInfo {
1076    fn default() -> Self {
1077        Self {
1078            dep_tracking: quote! {},
1079            helper_tokens: quote! {},
1080            render_tokens: None,
1081        }
1082    }
1083}
1084
1085#[allow(unused_variables)]
1086fn resolve_response_template(
1087    attr: Option<&ToolAttr>,
1088    struct_name: &syn::Ident,
1089    fn_name: &syn::Ident,
1090) -> syn::Result<ResponseTemplateInfo> {
1091    let Some(attr) = attr else {
1092        return Ok(ResponseTemplateInfo::default());
1093    };
1094
1095    if let Some(response_path) = &attr.response_file_path {
1096        #[cfg(not(feature = "prompt-templates"))]
1097        {
1098            return Err(syn::Error::new(
1099                response_path.span(),
1100                "the `prompt-templates` feature must be enabled to use `response_file`",
1101            ));
1102        }
1103        #[cfg(feature = "prompt-templates")]
1104        {
1105            return resolve_response_template_file(response_path, struct_name, fn_name);
1106        }
1107    }
1108    if let Some(response_inline) = &attr.response_inline {
1109        #[cfg(not(feature = "prompt-templates"))]
1110        {
1111            return Err(syn::Error::new(
1112                response_inline.span(),
1113                "the `prompt-templates` feature must be enabled to use `response`",
1114            ));
1115        }
1116        #[cfg(feature = "prompt-templates")]
1117        {
1118            return resolve_response_template_inline(response_inline, struct_name, fn_name);
1119        }
1120    }
1121    Ok(ResponseTemplateInfo::default())
1122}
1123
1124/// Feature-gated implementation of response template resolution from file.
1125#[cfg(feature = "prompt-templates")]
1126fn resolve_response_template_file(
1127    response_path: &LitStr,
1128    struct_name: &syn::Ident,
1129    fn_name: &syn::Ident,
1130) -> syn::Result<ResponseTemplateInfo> {
1131    let rel_path = response_path.value();
1132    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
1133    let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
1134    let path_str = full_path.to_string_lossy().to_string();
1135
1136    // Validate the template file exists and parses at compile time.
1137    let source = std::fs::read_to_string(&full_path).map_err(|e| {
1138        syn::Error::new(
1139            response_path.span(),
1140            format!(
1141                "failed to read response template '{}': {e}",
1142                full_path.display()
1143            ),
1144        )
1145    })?;
1146    let source = template_compile::normalize_and_validate_syntax(&source, &rel_path)
1147        .map_err(|e| syn::Error::new(response_path.span(), e))?;
1148
1149    let dep_tracking = quote! {
1150        const _: &str = include_str!(#path_str);
1151    };
1152
1153    let cur_dir = template_compile::REL_PREFIX_CUR.trim_end_matches(template_compile::CHAR_SLASH);
1154    let base_dir = full_path.parent().unwrap_or(std::path::Path::new(cur_dir));
1155    let (fm, _) =
1156        prompt_templates::parse_frontmatter_with_base_dir(&source, base_dir).map_err(|e| {
1157            syn::Error::new(
1158                response_path.span(),
1159                format!("response template '{rel_path}' frontmatter error: {e}"),
1160            )
1161        })?;
1162
1163    let response_struct_name_str = format!("{struct_name}Response");
1164    let generated_idents = response_struct_gen::collect_generated_type_names(
1165        &response_struct_name_str,
1166        &fm.declarations,
1167    );
1168
1169    let response_struct_name = format_ident!("{}", response_struct_name_str);
1170    let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1171
1172    let helper_tokens = quote! {
1173        ::llm_tool::__prompt_templates_macros::template!(
1174            #source as #response_struct_name => #response_mod_name,
1175            crate = ::llm_tool::__prompt_templates
1176        );
1177        pub use #response_mod_name::{ #( #generated_idents ),* };
1178    };
1179
1180    let render_tokens = quote! {
1181        {
1182            let __rendered = #response_mod_name::template().render(&__v)
1183                .map_err(|e| ::llm_tool::ToolError::new(
1184                    format!("response template render error: {e}")
1185                ))?;
1186            ::llm_tool::ToolOutput::new(__rendered)
1187                .with_metadata(&__v)
1188                .map_err(|e| ::llm_tool::ToolError::new(
1189                    format!("response metadata error: {e}")
1190                ))
1191        }
1192    };
1193
1194    Ok(ResponseTemplateInfo {
1195        dep_tracking,
1196        helper_tokens,
1197        render_tokens: Some(render_tokens),
1198    })
1199}
1200
1201/// Feature-gated implementation of response template resolution from inline string.
1202#[cfg(feature = "prompt-templates")]
1203fn resolve_response_template_inline(
1204    response_inline: &LitStr,
1205    struct_name: &syn::Ident,
1206    fn_name: &syn::Ident,
1207) -> syn::Result<ResponseTemplateInfo> {
1208    let source = response_inline.value();
1209    let source = template_compile::normalize_and_validate_syntax(
1210        &source,
1211        template_compile::LABEL_INLINE_RESP,
1212    )
1213    .map_err(|e| syn::Error::new(response_inline.span(), e))?;
1214
1215    // Validate the inline template parses at compile time.
1216    let fm = match prompt_templates::parse_frontmatter(&source) {
1217        Ok((fm, _)) => fm,
1218        Err(e) => {
1219            return Err(syn::Error::new(
1220                response_inline.span(),
1221                format!("inline response template error: {e}"),
1222            ));
1223        }
1224    };
1225
1226    let response_struct_name_str = format!("{struct_name}Response");
1227    let generated_idents = response_struct_gen::collect_generated_type_names(
1228        &response_struct_name_str,
1229        &fm.declarations,
1230    );
1231
1232    let response_struct_name = format_ident!("{}", response_struct_name_str);
1233    let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1234
1235    let helper_tokens = quote! {
1236        ::llm_tool::__prompt_templates_macros::template!(
1237            #source as #response_struct_name => #response_mod_name,
1238            crate = ::llm_tool::__prompt_templates
1239        );
1240        pub use #response_mod_name::{ #( #generated_idents ),* };
1241    };
1242
1243    let render_tokens = quote! {
1244        {
1245            let __rendered = #response_mod_name::template().render(&__v)
1246                .map_err(|e| ::llm_tool::ToolError::new(
1247                    format!("response template render error: {e}")
1248                ))?;
1249            ::llm_tool::ToolOutput::new(__rendered)
1250                .with_metadata(&__v)
1251                .map_err(|e| ::llm_tool::ToolError::new(
1252                    format!("response metadata error: {e}")
1253                ))
1254        }
1255    };
1256
1257    Ok(ResponseTemplateInfo {
1258        dep_tracking: quote! {},
1259        helper_tokens,
1260        render_tokens: Some(render_tokens),
1261    })
1262}
1263
1264/// Check whether `ty` is `Option<T>` (or `std::option::Option<T>`).
1265fn is_option_type(ty: &syn::Type) -> bool {
1266    let Type::Path(type_path) = ty else {
1267        return false;
1268    };
1269    let Some(last_seg) = type_path.path.segments.last() else {
1270        return false;
1271    };
1272    if last_seg.ident != TYPE_OPTION {
1273        return false;
1274    }
1275    matches!(&last_seg.arguments, PathArguments::AngleBracketed(args)
1276        if args.args.len() == 1
1277            && matches!(args.args.first(), Some(GenericArgument::Type(_))))
1278}
1279
1280/// Check whether `ty` is `ToolContext`, `&ToolContext`, or a qualified path
1281/// ending in `ToolContext`.
1282fn is_tool_context_type(ty: &syn::Type) -> bool {
1283    let inner = match ty {
1284        Type::Reference(r) => r.elem.as_ref(),
1285        other => other,
1286    };
1287    let Type::Path(type_path) = inner else {
1288        return false;
1289    };
1290    type_path
1291        .path
1292        .segments
1293        .last()
1294        .is_some_and(|seg| seg.ident == TYPE_TOOL_CONTEXT)
1295}
1296
1297/// Check whether `ty` is `&str`.
1298fn is_str_ref(ty: &syn::Type) -> bool {
1299    let Type::Reference(ref_type) = ty else {
1300        return false;
1301    };
1302    if ref_type.mutability.is_some() {
1303        return false;
1304    }
1305    let Type::Path(type_path) = ref_type.elem.as_ref() else {
1306        return false;
1307    };
1308    type_path
1309        .path
1310        .segments
1311        .last()
1312        .is_some_and(|seg| seg.ident == TYPE_STR && seg.arguments.is_none())
1313}
1314
1315fn is_explicit_context_attr(attr: &syn::Attribute) -> syn::Result<bool> {
1316    if !attr.path().is_ident(ATTR_LLM_TOOL) {
1317        return Ok(false);
1318    }
1319    let mut is_context = false;
1320    attr.parse_nested_meta(|meta| {
1321        if meta.path.is_ident(ATTR_CONTEXT) {
1322            is_context = true;
1323            Ok(())
1324        } else {
1325            Err(meta.error("unsupported llm_tool attribute"))
1326        }
1327    })?;
1328    Ok(is_context)
1329}
1330
1331fn extract_params(func: &ItemFn) -> syn::Result<Vec<ParamInfo>> {
1332    let mut params = Vec::new();
1333    for arg in &func.sig.inputs {
1334        match arg {
1335            FnArg::Receiver(r) => {
1336                return Err(syn::Error::new_spanned(
1337                    r,
1338                    "#[llm_tool] functions must be free functions (no `self`)",
1339                ));
1340            }
1341            FnArg::Typed(PatType { pat, ty, attrs, .. }) => {
1342                let name = match pat.as_ref() {
1343                    Pat::Ident(ident) => ident.ident.clone(),
1344                    other => {
1345                        return Err(syn::Error::new_spanned(
1346                            other,
1347                            "#[llm_tool] parameters must be simple identifiers",
1348                        ));
1349                    }
1350                };
1351
1352                let mut has_context_attr = false;
1353                for a in attrs {
1354                    has_context_attr |= is_explicit_context_attr(a)?;
1355                }
1356                let is_tool_context = is_tool_context_type(ty);
1357                let is_context = has_context_attr || is_tool_context;
1358
1359                if is_tool_context && !matches!(ty.as_ref(), syn::Type::Reference(_)) {
1360                    return Err(syn::Error::new_spanned(
1361                        ty,
1362                        "ToolContext parameter must be a reference type (e.g., `&ToolContext` or `&'a ToolContext`)",
1363                    ));
1364                }
1365
1366                let doc_attrs: Vec<syn::Attribute> = attrs
1367                    .iter()
1368                    .filter(|a| a.path().is_ident("doc"))
1369                    .cloned()
1370                    .collect();
1371                params.push(ParamInfo {
1372                    name,
1373                    ty: ty.clone(),
1374                    doc_attrs,
1375                    is_context,
1376                });
1377            }
1378        }
1379    }
1380    Ok(params)
1381}
1382
1383fn extract_doc_string(attrs: &[syn::Attribute]) -> String {
1384    let lines: Vec<String> = attrs
1385        .iter()
1386        .filter_map(|attr| {
1387            if !attr.path().is_ident("doc") {
1388                return None;
1389            }
1390            if let syn::Meta::NameValue(nv) = &attr.meta
1391                && let syn::Expr::Lit(lit) = &nv.value
1392                && let syn::Lit::Str(s) = &lit.lit
1393            {
1394                return Some(s.value());
1395            }
1396            None
1397        })
1398        .collect();
1399    lines
1400        .iter()
1401        .map(|l| l.trim())
1402        .collect::<Vec<_>>()
1403        .join("\n")
1404        .trim()
1405        .to_string()
1406}
1407
1408/// Parse the return type — either `Result<T, E>` or a bare type `T`.
1409fn parse_return_type(func: &ItemFn) -> syn::Result<ReturnInfo> {
1410    let syn::ReturnType::Type(_, ty) = &func.sig.output else {
1411        return Err(syn::Error::new_spanned(
1412            &func.sig,
1413            "#[llm_tool] functions must have an explicit return type",
1414        ));
1415    };
1416
1417    // Try to parse as Result<T, E>.
1418    if let Some(result_types) = try_extract_result_types(ty) {
1419        return Ok(result_types);
1420    }
1421
1422    // Not a Result — treat as infallible bare type.
1423    Ok(ReturnInfo::BareType)
1424}
1425
1426/// Try to extract `T` and `E` from a `Result<T, E>` return type.
1427/// Returns `None` if the type is not a `Result`.
1428fn try_extract_result_types(ty: &syn::Type) -> Option<ReturnInfo> {
1429    let Type::Path(type_path) = ty else {
1430        return None;
1431    };
1432
1433    let last_seg = type_path.path.segments.last()?;
1434
1435    if last_seg.ident != "Result" {
1436        return None;
1437    }
1438
1439    let PathArguments::AngleBracketed(args) = &last_seg.arguments else {
1440        return None;
1441    };
1442
1443    if args.args.len() != 2 {
1444        return None;
1445    }
1446
1447    let GenericArgument::Type(ok_type) = &args.args[0] else {
1448        return None;
1449    };
1450
1451    let GenericArgument::Type(err_type) = &args.args[1] else {
1452        return None;
1453    };
1454
1455    Some(ReturnInfo::ResultType {
1456        ok_type: Box::new(ok_type.clone()),
1457        err_type: Box::new(err_type.clone()),
1458    })
1459}