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