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 `prompt_file = "..."`, and tool
9//! responses can be auto-rendered through templates via
10//! `response_file = "..."`.
11#[cfg(feature = "md-tmpl")]
12mod response_struct_gen;
13#[cfg(feature = "md-tmpl")]
14mod template_codegen;
15#[cfg(feature = "md-tmpl")]
16mod template_compile;
17
18use convert_case::{Case, Casing};
19use proc_macro::TokenStream;
20use quote::{format_ident, quote};
21#[cfg(feature = "md-tmpl")]
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 | `md-tmpl` |
45/// | `#[llm_tool(prompt_file = "tools/x.tmpl.md")]` | Zero (compiled) | `md-tmpl` |
46/// | `#[llm_tool(prompt_file = "...", params(k = "v"))]` | Zero (compiled) | `md-tmpl` |
47/// | `#[llm_tool(prompt_file = "...", context = fn)]` | Runtime `Cow::Owned` | `md-tmpl` |
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: `md-tmpl`)
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 `md-tmpl`)
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 = "md-tmpl")]
147    inline_params: Vec<(Ident, LitStr)>,
148    /// Runtime context function (mutually exclusive with `inline_params`).
149    #[cfg(feature = "md-tmpl")]
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 = "md-tmpl")]
173    inline_params: Vec<(syn::Ident, syn::LitStr)>,
174    #[cfg(feature = "md-tmpl")]
175    context_fn: Option<syn::Path>,
176    #[cfg(not(feature = "md-tmpl"))]
177    has_inline_params: bool,
178    #[cfg(not(feature = "md-tmpl"))]
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 = "md-tmpl")]
205                self.inline_params.push((key, value));
206                #[cfg(not(feature = "md-tmpl"))]
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 = "md-tmpl"))]
216            {
217                self.has_inline_params = true;
218            }
219        } else if ident == ATTR_CONTEXT {
220            let _: syn::Token![=] = input.parse()?;
221            #[cfg(feature = "md-tmpl")]
222            {
223                self.context_fn = Some(input.parse::<syn::Path>()?);
224            }
225            #[cfg(not(feature = "md-tmpl"))]
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 = "md-tmpl")]
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 = "md-tmpl"))]
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 md-tmpl feature.
275        #[cfg(not(feature = "md-tmpl"))]
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 `md-tmpl` 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 = "md-tmpl")]
289            inline_params: builder.inline_params,
290            #[cfg(feature = "md-tmpl")]
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 = "md-tmpl"))]
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 `md-tmpl` 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 = "md-tmpl")]
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 = "md-tmpl"))]
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 `md-tmpl` feature must be enabled to use \
572             `#[llm_tool(prompt_file = \"...\")]`. \
573             Add `features = [\"md-tmpl\"]` to your llm-tool dependency.",
574        ))
575    }
576
577    #[cfg(feature = "md-tmpl")]
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 = "md-tmpl")]
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) = md_tmpl::parse_frontmatter_with_base_dir(&source, base_dir).map_err(|e| {
609        syn::Error::new(
610            template_lit.span(),
611            format!("template '{rel_path}' error: {e}"),
612        )
613    })?;
614
615    let body_str = body.trim().to_string();
616    let path_str = full_path.to_string_lossy().to_string();
617
618    // include_str! establishes a file dependency so cargo rebuilds
619    // when the template changes.
620    let dep_tracking = quote! {
621        const _: &str = include_str!(#path_str);
622    };
623
624    let has_params = !attr.inline_params.is_empty();
625    let has_context = attr.context_fn.is_some();
626    let has_declarations = !fm.declarations.is_empty();
627
628    if !has_declarations && !has_params && !has_context {
629        // Case 1: Static template — no variables, no params, no context.
630        Ok(DescriptionInfo {
631            static_description: body_str,
632            helper_tokens: quote! {},
633            description_method: None,
634            dep_tracking,
635        })
636    } else if has_params {
637        // Case 2: Compile-time params — render at build time.
638        resolve_template_with_params(
639            attr,
640            &fm,
641            &source,
642            &rel_path,
643            template_lit.span(),
644            dep_tracking,
645        )
646    } else if has_context {
647        // Case 3: Runtime context function.
648        resolve_context_description(ResolveContextArgs {
649            attr,
650            rel_path: &rel_path,
651            template_lit,
652            source: &source,
653            full_path: &full_path,
654            body_str: &body_str,
655            has_declarations,
656            dep_tracking,
657        })
658    } else {
659        // Template declares variables but neither params nor context provided.
660        let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
661        Err(syn::Error::new(
662            template_lit.span(),
663            format!(
664                "template '{rel_path}' declares parameters ({}) but neither \
665                 `params(...)` nor `context = ...` was provided",
666                declared.join(", ")
667            ),
668        ))
669    }
670}
671
672/// Implementation of inline template description resolution (feature-gated).
673#[cfg(feature = "md-tmpl")]
674fn resolve_inline_description_impl(attr: &ToolAttr) -> syn::Result<DescriptionInfo> {
675    let template_lit = attr
676        .prompt_inline
677        .as_ref()
678        .expect("prompt_inline validated");
679    let source = template_lit.value();
680    let trimmed = source.trim_start();
681    if !trimmed.starts_with(template_compile::FRONTMATTER_DELIM) {
682        return Ok(DescriptionInfo {
683            static_description: source,
684            helper_tokens: quote! {},
685            description_method: None,
686            dep_tracking: quote! {},
687        });
688    }
689
690    let source =
691        template_compile::normalize_and_validate_syntax(&source, template_compile::LABEL_INLINE)
692            .map_err(|e| syn::Error::new(template_lit.span(), e))?;
693    let (fm, body) = md_tmpl::parse_frontmatter(&source)
694        .map_err(|e| syn::Error::new(template_lit.span(), format!("inline template error: {e}")))?;
695
696    let body_str = body.trim().to_string();
697
698    let has_params = attr.has_inline_params;
699    let has_context = attr.has_context_fn;
700    let has_declarations = !fm.declarations.is_empty();
701
702    if !has_declarations && !has_params && !has_context {
703        // Case 1: Static template — no variables, no params, no context.
704        Ok(DescriptionInfo {
705            static_description: body_str,
706            helper_tokens: quote! {},
707            description_method: None,
708            dep_tracking: quote! {},
709        })
710    } else if has_params {
711        // Case 2: Compile-time params — render at build time.
712        resolve_template_with_params(
713            attr,
714            &fm,
715            &source,
716            "<inline>",
717            template_lit.span(),
718            quote! {},
719        )
720    } else if has_context {
721        // Case 3: Runtime context function.
722        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
723        let base_dir = std::path::Path::new(&manifest_dir);
724        let ast = template_compile::compile_template_to_ast(&source, base_dir).map_err(|e| {
725            syn::Error::new(
726                template_lit.span(),
727                format!("inline template compilation error: {e}"),
728            )
729        })?;
730        let tmpl_tokens = template_codegen::codegen_template(&ast);
731
732        let context_fn = attr.context_fn.as_ref().unwrap();
733
734        let description_method = quote! {
735            fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
736                static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__md_tmpl::Template> =
737                    ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
738                let ctx = #context_fn(self);
739                let rendered = TEMPLATE.render_ctx(&ctx)
740                    .expect("Failed to render tool description template");
741                ::llm_tool::__private::Cow::Owned(rendered)
742            }
743        };
744
745        Ok(DescriptionInfo {
746            static_description: body_str.clone(),
747            helper_tokens: quote! {},
748            description_method: Some(description_method),
749            dep_tracking: quote! {},
750        })
751    } else {
752        let declared: Vec<&str> = fm.declarations.iter().map(|d| d.name.as_str()).collect();
753        Err(syn::Error::new(
754            template_lit.span(),
755            format!(
756                "inline template declares parameters ({}) but neither \
757                 `params(...)` nor `context = ...` was provided",
758                declared.join(", ")
759            ),
760        ))
761    }
762}
763
764#[cfg(feature = "md-tmpl")]
765struct ResolveContextArgs<'a> {
766    attr: &'a ToolAttr,
767    rel_path: &'a str,
768    template_lit: &'a LitStr,
769    source: &'a str,
770    full_path: &'a std::path::Path,
771    body_str: &'a str,
772    has_declarations: bool,
773    dep_tracking: proc_macro2::TokenStream,
774}
775
776/// Resolve a template description with a runtime context function.
777///
778/// Generates a `description(&self)` method that uses `LazyLock` to parse
779/// the template once, then renders it with the user-provided context function
780/// on every call.
781#[cfg(feature = "md-tmpl")]
782fn resolve_context_description(args: ResolveContextArgs<'_>) -> syn::Result<DescriptionInfo> {
783    let ResolveContextArgs {
784        attr,
785        rel_path,
786        template_lit,
787        source,
788        full_path,
789        body_str,
790        has_declarations,
791        dep_tracking,
792    } = args;
793    let context_fn = attr.context_fn.as_ref().ok_or_else(|| {
794        syn::Error::new(
795            template_lit.span(),
796            "internal error: resolve_context_description called without context_fn",
797        )
798    })?;
799
800    if !has_declarations {
801        return Err(syn::Error::new(
802            template_lit.span(),
803            format!(
804                "template '{rel_path}' has no declared parameters, \
805                 so `context = ...` is unnecessary. Remove `context` \
806                 or add params to the template."
807            ),
808        ));
809    }
810
811    let base_dir = full_path.parent().unwrap_or(std::path::Path::new("."));
812    let ast = template_compile::compile_template_to_ast(source, base_dir).map_err(|e| {
813        syn::Error::new(
814            template_lit.span(),
815            format!("template '{rel_path}' compilation error: {e}"),
816        )
817    })?;
818    let tmpl_tokens = template_codegen::codegen_template(&ast);
819
820    // Generate LazyLock inside description() to avoid name collisions
821    // when multiple dynamic-description tools exist in the same module.
822    let description_method = quote! {
823        fn description(&self) -> ::llm_tool::__private::Cow<'static, str> {
824            static TEMPLATE: ::llm_tool::__private::Lazy<::llm_tool::__md_tmpl::Template> =
825                ::llm_tool::__private::Lazy::new(|| #tmpl_tokens);
826            let ctx = #context_fn(self);
827            let rendered = TEMPLATE.render_ctx(&ctx)
828                .expect("Failed to render tool description template");
829            ::llm_tool::__private::Cow::Owned(rendered)
830        }
831    };
832
833    Ok(DescriptionInfo {
834        static_description: body_str.to_string(),
835        helper_tokens: quote! {},
836        description_method: Some(description_method),
837        dep_tracking,
838    })
839}
840
841/// Render a template with compile-time `params(...)` values.
842///
843/// Validates:
844/// - Every declared template variable has a matching `params(...)` key
845/// - Every `params(...)` key matches a declared template variable
846/// - The template renders without errors
847#[cfg(feature = "md-tmpl")]
848fn resolve_template_with_params(
849    attr: &ToolAttr,
850    fm: &md_tmpl::Frontmatter,
851    source: &str,
852    rel_path: &str,
853    span: proc_macro2::Span,
854    dep_tracking: proc_macro2::TokenStream,
855) -> syn::Result<DescriptionInfo> {
856    let mut expected_names = std::collections::HashSet::new();
857    let mut struct_fields: std::collections::HashMap<String, String> =
858        std::collections::HashMap::new();
859
860    for decl in &fm.declarations {
861        if let md_tmpl::VarType::Struct(fields) = &decl.var_type {
862            for f in fields {
863                expected_names.insert(f.name.as_str());
864                struct_fields.insert(f.name.clone(), decl.name.clone());
865            }
866        } else {
867            expected_names.insert(decl.name.as_str());
868        }
869    }
870
871    let provided_names: std::collections::HashSet<String> = attr
872        .inline_params
873        .iter()
874        .map(|(k, _)| k.to_string())
875        .collect();
876
877    // Check for missing params (declared but not provided).
878    let missing: Vec<&str> = expected_names
879        .iter()
880        .filter(|n| !provided_names.contains(**n))
881        .copied()
882        .collect();
883    if !missing.is_empty() {
884        return Err(syn::Error::new(
885            span,
886            format!(
887                "template '{rel_path}' declares parameters not provided in `params(...)`: {}",
888                missing.join(", ")
889            ),
890        ));
891    }
892
893    // Check for extra params (provided but not declared).
894    for (key, _) in &attr.inline_params {
895        let key_str = key.to_string();
896        if !expected_names.contains(key_str.as_str()) {
897            return Err(syn::Error::new(
898                key.span(),
899                format!(
900                    "param `{key_str}` is not declared in template '{rel_path}'. \
901                     Declared params: {}",
902                    expected_names.into_iter().collect::<Vec<_>>().join(", ")
903                ),
904            ));
905        }
906    }
907
908    // Build context and render at compile time.
909    let template = md_tmpl::Template::from_source(source)
910        .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' parse error: {e}")))?;
911
912    let mut root_values: std::collections::HashMap<String, md_tmpl::Value> =
913        std::collections::HashMap::new();
914    let mut struct_maps: std::collections::HashMap<
915        String,
916        std::collections::HashMap<String, md_tmpl::Value>,
917    > = std::collections::HashMap::new();
918
919    for (key, value) in &attr.inline_params {
920        let key_str = key.to_string();
921        if let Some(parent_struct) = struct_fields.get(&key_str) {
922            struct_maps
923                .entry(parent_struct.clone())
924                .or_default()
925                .insert(key_str, md_tmpl::Value::Str(value.value()));
926        } else {
927            root_values.insert(key_str, md_tmpl::Value::Str(value.value()));
928        }
929    }
930
931    for (struct_name, s_map) in struct_maps {
932        root_values.insert(
933            struct_name,
934            md_tmpl::Value::Struct(std::sync::Arc::new(s_map.into_iter().collect())),
935        );
936    }
937
938    let mut ctx = md_tmpl::Context::new();
939    for (k, v) in root_values {
940        ctx.set(k, v);
941    }
942
943    let rendered = template
944        .render_ctx(&ctx)
945        .map_err(|e| syn::Error::new(span, format!("template '{rel_path}' render error: {e}")))?;
946
947    Ok(DescriptionInfo {
948        static_description: rendered,
949        helper_tokens: quote! {},
950        description_method: None,
951        dep_tracking,
952    })
953}
954
955/// Build the struct field types and any auto-borrow bindings for `&str` params.
956fn build_param_types_and_borrows(
957    params: &[&ParamInfo],
958) -> (Vec<proc_macro2::TokenStream>, Vec<proc_macro2::TokenStream>) {
959    params
960        .iter()
961        .map(|p| {
962            if is_str_ref(&p.ty) {
963                // &str → String in struct, auto-borrow in body
964                let name = &p.name;
965                (quote! { String }, quote! { let #name: &str = &#name; })
966            } else {
967                let ty = &p.ty;
968                (quote! { #ty }, quote! {})
969            }
970        })
971        .unzip()
972}
973
974/// Build `#[serde(default)]` annotations for `Option<T>` params.
975fn build_serde_defaults(params: &[&ParamInfo]) -> Vec<proc_macro2::TokenStream> {
976    params
977        .iter()
978        .map(|p| {
979            if is_option_type(&p.ty) {
980                quote! { #[serde(default)] }
981            } else {
982                quote! {}
983            }
984        })
985        .collect()
986}
987
988/// Build the body tokens that wrap the user's function body.
989///
990/// Uses compile-time dispatch via `__private::Wrap(v).__convert()` —
991/// the compiler resolves the correct conversion (inherent method for
992/// `String`/`ToolOutput`/`Json<T>`, or `SerializeFallback` trait for
993/// `T: Serialize`) without any proc-macro type-name matching.
994///
995/// When a `response_template` is specified, the return value is instead
996/// rendered through the template and returned as `ToolOutput` with the
997/// struct attached as metadata.
998fn build_body_tokens(
999    func: &ItemFn,
1000    return_info: &ReturnInfo,
1001    crate_path: &proc_macro2::TokenStream,
1002    response_info: &ResponseTemplateInfo,
1003) -> proc_macro2::TokenStream {
1004    let is_async = func.sig.asyncness.is_some();
1005    let body_stmts = &func.block.stmts;
1006
1007    match return_info {
1008        ReturnInfo::ResultType { ok_type, err_type } => {
1009            let inner = if is_async {
1010                quote! {
1011                    let __r: ::core::result::Result<#ok_type, #err_type> = async move {
1012                        #( #body_stmts )*
1013                    }.await;
1014                }
1015            } else {
1016                quote! {
1017                    let __r: ::core::result::Result<#ok_type, #err_type> = (|| { #( #body_stmts )* })();
1018                }
1019            };
1020            let ok_branch = build_ok_branch(crate_path, response_info);
1021            quote! {
1022                #inner
1023                match __r {
1024                    ::core::result::Result::Ok(__v) => { #ok_branch },
1025                    ::core::result::Result::Err(__e) => ::core::result::Result::Err(::core::convert::Into::into(__e)),
1026                }
1027            }
1028        }
1029        ReturnInfo::BareType => {
1030            let inner = if is_async {
1031                quote! {
1032                    let __v = async move { #( #body_stmts )* }.await;
1033                }
1034            } else {
1035                quote! {
1036                    let __v = (|| { #( #body_stmts )* })();
1037                }
1038            };
1039            let ok_branch = build_ok_branch(crate_path, response_info);
1040            quote! {
1041                #inner
1042                #ok_branch
1043            }
1044        }
1045    }
1046}
1047
1048/// Build the Ok-branch conversion: either the standard `Wrap(v).__convert()`
1049/// or template-based rendering when `response_template` is set.
1050fn build_ok_branch(
1051    crate_path: &proc_macro2::TokenStream,
1052    response_info: &ResponseTemplateInfo,
1053) -> proc_macro2::TokenStream {
1054    if let Some(ref render_tokens) = response_info.render_tokens {
1055        render_tokens.clone()
1056    } else {
1057        quote! { #crate_path::__private::Wrap(__v).__convert() }
1058    }
1059}
1060
1061// ── Response Template Resolution ────────────────────────────────────────────
1062
1063/// Structured output from response template resolution.
1064struct ResponseTemplateInfo {
1065    /// Cargo dependency-tracking tokens.
1066    dep_tracking: proc_macro2::TokenStream,
1067    /// Helper tokens (e.g. static `LazyLock` declarations).
1068    helper_tokens: proc_macro2::TokenStream,
1069    /// Token stream that converts `__v` into `Result<ToolOutput, ToolError>`
1070    /// via template rendering. `None` = use default `__convert()` path.
1071    render_tokens: Option<proc_macro2::TokenStream>,
1072}
1073
1074impl Default for ResponseTemplateInfo {
1075    fn default() -> Self {
1076        Self {
1077            dep_tracking: quote! {},
1078            helper_tokens: quote! {},
1079            render_tokens: None,
1080        }
1081    }
1082}
1083
1084#[allow(unused_variables)]
1085fn resolve_response_template(
1086    attr: Option<&ToolAttr>,
1087    struct_name: &syn::Ident,
1088    fn_name: &syn::Ident,
1089) -> syn::Result<ResponseTemplateInfo> {
1090    let Some(attr) = attr else {
1091        return Ok(ResponseTemplateInfo::default());
1092    };
1093
1094    if let Some(response_path) = &attr.response_file_path {
1095        #[cfg(not(feature = "md-tmpl"))]
1096        {
1097            return Err(syn::Error::new(
1098                response_path.span(),
1099                "the `md-tmpl` feature must be enabled to use `response_file`",
1100            ));
1101        }
1102        #[cfg(feature = "md-tmpl")]
1103        {
1104            return resolve_response_template_file(response_path, struct_name, fn_name);
1105        }
1106    }
1107    if let Some(response_inline) = &attr.response_inline {
1108        #[cfg(not(feature = "md-tmpl"))]
1109        {
1110            return Err(syn::Error::new(
1111                response_inline.span(),
1112                "the `md-tmpl` feature must be enabled to use `response`",
1113            ));
1114        }
1115        #[cfg(feature = "md-tmpl")]
1116        {
1117            return resolve_response_template_inline(response_inline, struct_name, fn_name);
1118        }
1119    }
1120    Ok(ResponseTemplateInfo::default())
1121}
1122
1123/// Feature-gated implementation of response template resolution from file.
1124#[cfg(feature = "md-tmpl")]
1125fn resolve_response_template_file(
1126    response_path: &LitStr,
1127    struct_name: &syn::Ident,
1128    fn_name: &syn::Ident,
1129) -> syn::Result<ResponseTemplateInfo> {
1130    let rel_path = response_path.value();
1131    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
1132    let full_path = std::path::Path::new(&manifest_dir).join(&rel_path);
1133    let path_str = full_path.to_string_lossy().to_string();
1134
1135    // Validate the template file exists and parses at compile time.
1136    let source = std::fs::read_to_string(&full_path).map_err(|e| {
1137        syn::Error::new(
1138            response_path.span(),
1139            format!(
1140                "failed to read response template '{}': {e}",
1141                full_path.display()
1142            ),
1143        )
1144    })?;
1145    let source = template_compile::normalize_and_validate_syntax(&source, &rel_path)
1146        .map_err(|e| syn::Error::new(response_path.span(), e))?;
1147
1148    let dep_tracking = quote! {
1149        const _: &str = include_str!(#path_str);
1150    };
1151
1152    let cur_dir = template_compile::REL_PREFIX_CUR.trim_end_matches(template_compile::CHAR_SLASH);
1153    let base_dir = full_path.parent().unwrap_or(std::path::Path::new(cur_dir));
1154    let (fm, _) = md_tmpl::parse_frontmatter_with_base_dir(&source, base_dir).map_err(|e| {
1155        syn::Error::new(
1156            response_path.span(),
1157            format!("response template '{rel_path}' frontmatter error: {e}"),
1158        )
1159    })?;
1160
1161    let response_struct_name_str = format!("{struct_name}Response");
1162    let generated_idents = response_struct_gen::collect_generated_type_names(
1163        &response_struct_name_str,
1164        &fm.declarations,
1165    );
1166
1167    let response_struct_name = format_ident!("{}", response_struct_name_str);
1168    let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1169
1170    let helper_tokens = quote! {
1171        ::llm_tool::__md_tmpl_macros::template!(
1172            #source as #response_struct_name => #response_mod_name,
1173            crate = ::llm_tool::__md_tmpl
1174        );
1175        pub use #response_mod_name::{ #( #generated_idents ),* };
1176    };
1177
1178    let render_tokens = quote! {
1179        {
1180            let __rendered = #response_mod_name::template().render(&__v)
1181                .map_err(|e| ::llm_tool::ToolError::new(
1182                    format!("response template render error: {e}")
1183                ))?;
1184            ::llm_tool::ToolOutput::new(__rendered)
1185                .with_metadata(&__v)
1186                .map_err(|e| ::llm_tool::ToolError::new(
1187                    format!("response metadata error: {e}")
1188                ))
1189        }
1190    };
1191
1192    Ok(ResponseTemplateInfo {
1193        dep_tracking,
1194        helper_tokens,
1195        render_tokens: Some(render_tokens),
1196    })
1197}
1198
1199/// Feature-gated implementation of response template resolution from inline string.
1200#[cfg(feature = "md-tmpl")]
1201fn resolve_response_template_inline(
1202    response_inline: &LitStr,
1203    struct_name: &syn::Ident,
1204    fn_name: &syn::Ident,
1205) -> syn::Result<ResponseTemplateInfo> {
1206    let source = response_inline.value();
1207    let source = template_compile::normalize_and_validate_syntax(
1208        &source,
1209        template_compile::LABEL_INLINE_RESP,
1210    )
1211    .map_err(|e| syn::Error::new(response_inline.span(), e))?;
1212
1213    // Validate the inline template parses at compile time.
1214    let fm = match md_tmpl::parse_frontmatter(&source) {
1215        Ok((fm, _)) => fm,
1216        Err(e) => {
1217            return Err(syn::Error::new(
1218                response_inline.span(),
1219                format!("inline response template error: {e}"),
1220            ));
1221        }
1222    };
1223
1224    let response_struct_name_str = format!("{struct_name}Response");
1225    let generated_idents = response_struct_gen::collect_generated_type_names(
1226        &response_struct_name_str,
1227        &fm.declarations,
1228    );
1229
1230    let response_struct_name = format_ident!("{}", response_struct_name_str);
1231    let response_mod_name = format_ident!("__{}_response_mod", fn_name);
1232
1233    let helper_tokens = quote! {
1234        ::llm_tool::__md_tmpl_macros::template!(
1235            #source as #response_struct_name => #response_mod_name,
1236            crate = ::llm_tool::__md_tmpl
1237        );
1238        pub use #response_mod_name::{ #( #generated_idents ),* };
1239    };
1240
1241    let render_tokens = quote! {
1242        {
1243            let __rendered = #response_mod_name::template().render(&__v)
1244                .map_err(|e| ::llm_tool::ToolError::new(
1245                    format!("response template render error: {e}")
1246                ))?;
1247            ::llm_tool::ToolOutput::new(__rendered)
1248                .with_metadata(&__v)
1249                .map_err(|e| ::llm_tool::ToolError::new(
1250                    format!("response metadata error: {e}")
1251                ))
1252        }
1253    };
1254
1255    Ok(ResponseTemplateInfo {
1256        dep_tracking: quote! {},
1257        helper_tokens,
1258        render_tokens: Some(render_tokens),
1259    })
1260}
1261
1262/// Check whether `ty` is `Option<T>` (or `std::option::Option<T>`).
1263fn is_option_type(ty: &syn::Type) -> bool {
1264    let Type::Path(type_path) = ty else {
1265        return false;
1266    };
1267    let Some(last_seg) = type_path.path.segments.last() else {
1268        return false;
1269    };
1270    if last_seg.ident != TYPE_OPTION {
1271        return false;
1272    }
1273    matches!(&last_seg.arguments, PathArguments::AngleBracketed(args)
1274        if args.args.len() == 1
1275            && matches!(args.args.first(), Some(GenericArgument::Type(_))))
1276}
1277
1278/// Check whether `ty` is `ToolContext`, `&ToolContext`, or a qualified path
1279/// ending in `ToolContext`.
1280fn is_tool_context_type(ty: &syn::Type) -> bool {
1281    let inner = match ty {
1282        Type::Reference(r) => r.elem.as_ref(),
1283        other => other,
1284    };
1285    let Type::Path(type_path) = inner else {
1286        return false;
1287    };
1288    type_path
1289        .path
1290        .segments
1291        .last()
1292        .is_some_and(|seg| seg.ident == TYPE_TOOL_CONTEXT)
1293}
1294
1295/// Check whether `ty` is `&str`.
1296fn is_str_ref(ty: &syn::Type) -> bool {
1297    let Type::Reference(ref_type) = ty else {
1298        return false;
1299    };
1300    if ref_type.mutability.is_some() {
1301        return false;
1302    }
1303    let Type::Path(type_path) = ref_type.elem.as_ref() else {
1304        return false;
1305    };
1306    type_path
1307        .path
1308        .segments
1309        .last()
1310        .is_some_and(|seg| seg.ident == TYPE_STR && seg.arguments.is_none())
1311}
1312
1313fn is_explicit_context_attr(attr: &syn::Attribute) -> syn::Result<bool> {
1314    if !attr.path().is_ident(ATTR_LLM_TOOL) {
1315        return Ok(false);
1316    }
1317    let mut is_context = false;
1318    attr.parse_nested_meta(|meta| {
1319        if meta.path.is_ident(ATTR_CONTEXT) {
1320            is_context = true;
1321            Ok(())
1322        } else {
1323            Err(meta.error("unsupported llm_tool attribute"))
1324        }
1325    })?;
1326    Ok(is_context)
1327}
1328
1329fn extract_params(func: &ItemFn) -> syn::Result<Vec<ParamInfo>> {
1330    let mut params = Vec::new();
1331    for arg in &func.sig.inputs {
1332        match arg {
1333            FnArg::Receiver(r) => {
1334                return Err(syn::Error::new_spanned(
1335                    r,
1336                    "#[llm_tool] functions must be free functions (no `self`)",
1337                ));
1338            }
1339            FnArg::Typed(PatType { pat, ty, attrs, .. }) => {
1340                let name = match pat.as_ref() {
1341                    Pat::Ident(ident) => ident.ident.clone(),
1342                    other => {
1343                        return Err(syn::Error::new_spanned(
1344                            other,
1345                            "#[llm_tool] parameters must be simple identifiers",
1346                        ));
1347                    }
1348                };
1349
1350                let mut has_context_attr = false;
1351                for a in attrs {
1352                    has_context_attr |= is_explicit_context_attr(a)?;
1353                }
1354                let is_tool_context = is_tool_context_type(ty);
1355                let is_context = has_context_attr || is_tool_context;
1356
1357                if is_tool_context && !matches!(ty.as_ref(), syn::Type::Reference(_)) {
1358                    return Err(syn::Error::new_spanned(
1359                        ty,
1360                        "ToolContext parameter must be a reference type (e.g., `&ToolContext` or `&'a ToolContext`)",
1361                    ));
1362                }
1363
1364                let doc_attrs: Vec<syn::Attribute> = attrs
1365                    .iter()
1366                    .filter(|a| a.path().is_ident("doc"))
1367                    .cloned()
1368                    .collect();
1369                params.push(ParamInfo {
1370                    name,
1371                    ty: ty.clone(),
1372                    doc_attrs,
1373                    is_context,
1374                });
1375            }
1376        }
1377    }
1378    Ok(params)
1379}
1380
1381fn extract_doc_string(attrs: &[syn::Attribute]) -> String {
1382    let lines: Vec<String> = attrs
1383        .iter()
1384        .filter_map(|attr| {
1385            if !attr.path().is_ident("doc") {
1386                return None;
1387            }
1388            if let syn::Meta::NameValue(nv) = &attr.meta
1389                && let syn::Expr::Lit(lit) = &nv.value
1390                && let syn::Lit::Str(s) = &lit.lit
1391            {
1392                return Some(s.value());
1393            }
1394            None
1395        })
1396        .collect();
1397    lines
1398        .iter()
1399        .map(|l| l.trim())
1400        .collect::<Vec<_>>()
1401        .join("\n")
1402        .trim()
1403        .to_string()
1404}
1405
1406/// Parse the return type — either `Result<T, E>` or a bare type `T`.
1407fn parse_return_type(func: &ItemFn) -> syn::Result<ReturnInfo> {
1408    let syn::ReturnType::Type(_, ty) = &func.sig.output else {
1409        return Err(syn::Error::new_spanned(
1410            &func.sig,
1411            "#[llm_tool] functions must have an explicit return type",
1412        ));
1413    };
1414
1415    // Try to parse as Result<T, E>.
1416    if let Some(result_types) = try_extract_result_types(ty) {
1417        return Ok(result_types);
1418    }
1419
1420    // Not a Result — treat as infallible bare type.
1421    Ok(ReturnInfo::BareType)
1422}
1423
1424/// Try to extract `T` and `E` from a `Result<T, E>` return type.
1425/// Returns `None` if the type is not a `Result`.
1426fn try_extract_result_types(ty: &syn::Type) -> Option<ReturnInfo> {
1427    let Type::Path(type_path) = ty else {
1428        return None;
1429    };
1430
1431    let last_seg = type_path.path.segments.last()?;
1432
1433    if last_seg.ident != "Result" {
1434        return None;
1435    }
1436
1437    let PathArguments::AngleBracketed(args) = &last_seg.arguments else {
1438        return None;
1439    };
1440
1441    if args.args.len() != 2 {
1442        return None;
1443    }
1444
1445    let GenericArgument::Type(ok_type) = &args.args[0] else {
1446        return None;
1447    };
1448
1449    let GenericArgument::Type(err_type) = &args.args[1] else {
1450        return None;
1451    };
1452
1453    Some(ReturnInfo::ResultType {
1454        ok_type: Box::new(ok_type.clone()),
1455        err_type: Box::new(err_type.clone()),
1456    })
1457}