Skip to main content

llm_tool_macros/
lib.rs

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