gemini_adk_macros_rs/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![forbid(unsafe_code)]
3//! Procedural macros for `gemini-adk-rs`.
4//!
5//! This crate provides the [`macro@tool`] attribute macro, which turns a plain
6//! `async fn` into a registrable Gemini tool — eliminating the
7//! `TypedTool::new::<Args>` + separate-args-struct ceremony.
8//!
9//! You normally don't depend on this crate directly. The [`macro@tool`] macro is
10//! re-exported from `gemini-adk-rs` and the `gemini-adk-fluent-rs` prelude:
11//!
12//! ```ignore
13//! use gemini_adk_fluent_rs::prelude::*;   // brings `tool` into scope
14//! use serde_json::{json, Value};
15//!
16//! /// Get the current weather for a city.
17//! ///
18//! /// # Arguments
19//! ///
20//! /// * `city` - The city name.
21//! /// * `units` - "metric" or "imperial"; metric when omitted.
22//! #[tool]
23//! async fn get_weather(city: String, units: Option<String>) -> Result<Value, ToolError> {
24//!     Ok(json!({ "city": city, "units": units.unwrap_or("metric".into()) }))
25//! }
26//!
27//! // `get_weather()` returns a value implementing `ToolFunction`.
28//! let mut d = ToolDispatcher::new();
29//! d.register_function(std::sync::Arc::new(get_weather()));
30//! ```
31
32use proc_macro::TokenStream;
33use proc_macro2::Span;
34use quote::{format_ident, quote};
35use syn::{
36    Data, DeriveInput, Expr, ExprLit, Fields, FnArg, ItemFn, Lit, LitInt, LitStr, Meta, Pat,
37    PatType, ReturnType, Type, TypePath, parse_macro_input,
38};
39
40/// Where the runtime crate (`gemini-adk-rs`) is reachable from the expansion
41/// site, as a path and as the string form the derive `crate = ".."` attributes
42/// want.
43///
44/// A direct dependency wins (under whatever name it was renamed to); a crate
45/// that depends only on `gemini-adk-fluent-rs`, or only on the `gemini-adk`
46/// facade, reaches it through that crate's `gemini_adk_rs` re-export. Inside
47/// `gemini-adk-rs` itself the crate declares `extern crate self as
48/// gemini_adk_rs`, so the plain name works there and in its own tests.
49fn runtime() -> (proc_macro2::TokenStream, String) {
50    use proc_macro_crate::{FoundCrate, crate_name};
51    match crate_name("gemini-adk-rs") {
52        Ok(FoundCrate::Name(name)) => {
53            let ident = format_ident!("{name}");
54            (quote! { ::#ident }, name)
55        }
56        Ok(FoundCrate::Itself) => (quote! { ::gemini_adk_rs }, "gemini_adk_rs".to_string()),
57        // Otherwise through a crate that re-exports it: the `gemini-adk`
58        // facade (first, so its own tests exercise this path), or the fluent
59        // layer it wraps.
60        Err(_) => ["gemini-adk", "gemini-adk-fluent-rs"]
61            .into_iter()
62            .find_map(|facade| match crate_name(facade) {
63                Ok(FoundCrate::Name(name)) => Some(name),
64                Ok(FoundCrate::Itself) => Some(facade.replace('-', "_")),
65                Err(_) => None,
66            })
67            .map_or_else(
68                || (quote! { ::gemini_adk_rs }, "gemini_adk_rs".to_string()),
69                |name| {
70                    let ident = format_ident!("{name}");
71                    (
72                        quote! { ::#ident::gemini_adk_rs },
73                        format!("{name}::gemini_adk_rs"),
74                    )
75                },
76            ),
77    }
78}
79
80/// Turn a documented `async fn` into a registrable Gemini tool.
81///
82/// The function's doc comment is what the model reads: its opening prose is
83/// the tool's description, and a `# Arguments` section describes each
84/// parameter. The parameter types are the schema.
85///
86/// ```ignore
87/// /// Get the current weather for a city.
88/// ///
89/// /// # Arguments
90/// ///
91/// /// * `city` - The city name, e.g. "Paris".
92/// /// * `units` - "metric" or "imperial"; metric when omitted.
93/// #[tool]
94/// async fn get_weather(city: String, units: Option<String>) -> Result<Weather, reqwest::Error> {
95///     fetch_weather(&city, units.as_deref()).await
96/// }
97///
98/// agent.tool(get_weather());
99/// ```
100///
101/// # Description
102///
103/// The doc comment's prose up to its first `#` heading, with wrapped lines
104/// joined. `#[tool("...")]` replaces it when the text for the model should
105/// differ from the text for readers. A tool with neither is a compile error:
106/// the model chooses tools by their descriptions.
107///
108/// # Arguments
109///
110/// Each item of a `# Arguments` (or `# Args`, `# Parameters`) section — in the
111/// rustdoc form ``* `name` - text`` or ``- `name`: text`` — becomes that
112/// parameter's schema `description`. Naming a parameter the function does not
113/// have is a compile error, so the documentation cannot drift from the
114/// signature.
115///
116/// Every parameter type must be `serde::Deserialize + schemars::JsonSchema`
117/// and owned (`String`, not `&str`): arguments are deserialized from the
118/// model's JSON. `Option<T>` parameters are optional. The schema is produced
119/// by `gemini_adk_rs::tool::wire_schema`, so nested types are inlined and
120/// optional fields declare a single type, as the API requires.
121///
122/// # Return type
123///
124/// - A type spelled `Result<T, E>` (under any path: `anyhow::Result<T>`,
125///   `io::Result<T>`) is fallible. `T` is any `serde::Serialize` type; `E` is
126///   any error — a `ToolError` keeps its variant, anything else becomes
127///   `ToolError::ExecutionFailed` with its message.
128/// - Any other type is the tool's output, and the tool cannot fail.
129/// - No return type sends `null`.
130///
131/// A result that is not a JSON object reaches the model as `{"output": ..}`.
132/// A `Result` behind an alias with another name is not recognized; spell the
133/// return type as `Result<..>`.
134///
135/// # What it generates
136///
137/// A constructor `fn get_weather() -> impl ToolFunction` (with the original
138/// visibility and doc comment) whose value you register:
139/// `agent.tool(get_weather())`, or
140/// `dispatcher.register_function(Arc::new(get_weather()))`. The original body
141/// runs in a hidden `async fn`, which keeps the function's other attributes
142/// (`#[allow]`, `#[tracing::instrument]`, ...); `#[cfg]` applies to every
143/// generated item.
144///
145/// # Path hygiene
146///
147/// Generated code reaches `serde`, `schemars`, `serde_json`, and `async_trait`
148/// through the runtime crate's `__macros` module, so none of them need to be
149/// in your `Cargo.toml`. The runtime crate is located at expansion time:
150/// `gemini-adk-rs` if it is a direct dependency (under whatever name), else
151/// through the re-export in `gemini-adk` or `gemini-adk-fluent-rs`.
152#[proc_macro_attribute]
153pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
154    let description = if attr.is_empty() {
155        None
156    } else {
157        Some(parse_macro_input!(attr as LitStr))
158    };
159    let func = parse_macro_input!(item as ItemFn);
160
161    match expand(description, func) {
162        Ok(ts) => ts.into(),
163        Err(e) => e.to_compile_error().into(),
164    }
165}
166
167/// What a `#[tool]` fn's doc comment says, split into the parts the model sees.
168#[derive(Debug, Default, PartialEq)]
169struct ToolDocs {
170    /// Prose before the first heading, paragraphs separated by a blank line.
171    description: String,
172    /// `(parameter, description)` from the `# Arguments` section, in order.
173    arguments: Vec<(String, String)>,
174}
175
176/// The text of each `#[doc = ".."]` attribute, one entry per doc line.
177fn doc_lines(attrs: &[syn::Attribute]) -> Vec<String> {
178    attrs
179        .iter()
180        .filter(|a| a.path().is_ident("doc"))
181        .filter_map(|a| match &a.meta {
182            Meta::NameValue(nv) => match &nv.value {
183                Expr::Lit(ExprLit {
184                    lit: Lit::Str(s), ..
185                }) => Some(s.value()),
186                _ => None,
187            },
188            _ => None,
189        })
190        .flat_map(|text| text.lines().map(str::to_owned).collect::<Vec<_>>())
191        .collect()
192}
193
194/// Split doc lines into the description and the `# Arguments` items.
195fn parse_docs(lines: &[String]) -> ToolDocs {
196    let mut docs = ToolDocs::default();
197    let mut paragraphs: Vec<String> = Vec::new();
198    let mut paragraph = String::new();
199    // `None` before the first heading; then whether we are in an arguments section.
200    let mut section: Option<bool> = None;
201
202    let flush = |paragraph: &mut String, paragraphs: &mut Vec<String>| {
203        if !paragraph.is_empty() {
204            paragraphs.push(std::mem::take(paragraph));
205        }
206    };
207
208    let mut in_code = false;
209    for raw in lines {
210        let line = raw.trim();
211        // Code blocks are for readers, and their `# hidden` lines are not headings.
212        if line.starts_with("```") || line.starts_with("~~~") {
213            in_code = !in_code;
214            continue;
215        }
216        if in_code {
217            continue;
218        }
219        if let Some(heading) = line.strip_prefix('#') {
220            let heading = heading.trim_start_matches('#').trim().to_ascii_lowercase();
221            section = Some(matches!(
222                heading.as_str(),
223                "arguments" | "args" | "parameters" | "params"
224            ));
225            continue;
226        }
227        match section {
228            None if line.is_empty() => flush(&mut paragraph, &mut paragraphs),
229            None => {
230                if !paragraph.is_empty() {
231                    paragraph.push(' ');
232                }
233                paragraph.push_str(line);
234            }
235            Some(true) => {
236                if let Some(item) = line.strip_prefix(['*', '-']) {
237                    if let Some(parsed) = parse_argument_item(item) {
238                        docs.arguments.push(parsed);
239                    }
240                } else if !line.is_empty()
241                    && let Some((_, text)) = docs.arguments.last_mut()
242                {
243                    if !text.is_empty() {
244                        text.push(' ');
245                    }
246                    text.push_str(line);
247                }
248            }
249            Some(false) => {}
250        }
251    }
252    flush(&mut paragraph, &mut paragraphs);
253    docs.description = paragraphs.join("\n\n");
254    docs
255}
256
257/// Parse ``` `name` - text``` / ``name: text`` into `(name, text)`.
258fn parse_argument_item(item: &str) -> Option<(String, String)> {
259    let item = item.trim();
260    let (name, rest) = if let Some(quoted) = item.strip_prefix('`') {
261        let end = quoted.find('`')?;
262        (&quoted[..end], &quoted[end + 1..])
263    } else {
264        let end = item
265            .find(|c: char| !(c.is_alphanumeric() || c == '_'))
266            .unwrap_or(item.len());
267        (&item[..end], &item[end..])
268    };
269    if name.is_empty() {
270        return None;
271    }
272    let text = rest
273        .trim_start()
274        .trim_start_matches(['-', ':', '\u{2013}', '\u{2014}'])
275        .trim();
276    Some((name.to_owned(), text.to_owned()))
277}
278
279/// Whether a return type is spelled `Result<..>` under any path.
280/// Whether `ty` is the runtime's `ToolContext` (by its last path segment),
281/// which `#[tool]` injects rather than asks the model for.
282fn is_tool_context(ty: &Type) -> bool {
283    match ty {
284        Type::Path(path) => path
285            .path
286            .segments
287            .last()
288            .is_some_and(|seg| seg.ident == "ToolContext" && seg.arguments.is_empty()),
289        _ => false,
290    }
291}
292
293fn is_result(ty: &Type) -> bool {
294    let Type::Path(TypePath { qself: None, path }) = ty else {
295        return false;
296    };
297    path.segments
298        .last()
299        .is_some_and(|seg| seg.ident == "Result" && !seg.arguments.is_none())
300}
301
302fn expand(description: Option<LitStr>, func: ItemFn) -> syn::Result<proc_macro2::TokenStream> {
303    let sig = &func.sig;
304
305    if sig.asyncness.is_none() {
306        return Err(syn::Error::new_spanned(
307            sig.fn_token,
308            "#[tool] requires an `async fn`",
309        ));
310    }
311    if let Some(variadic) = &sig.variadic {
312        return Err(syn::Error::new_spanned(
313            variadic,
314            "#[tool] does not support variadic functions",
315        ));
316    }
317    if !sig.generics.params.is_empty() {
318        return Err(syn::Error::new_spanned(
319            sig.generics.clone(),
320            "#[tool] does not support generic functions",
321        ));
322    }
323
324    let fn_name = &sig.ident;
325    let vis = &func.vis;
326    let body = &func.block;
327
328    // Collect (ident, type) for each parameter; reject `self` receivers,
329    // patterns and borrowed types.
330    let mut field_idents = Vec::new();
331    let mut field_types = Vec::new();
332    // Every parameter in declaration order (for the preserved body), and the
333    // one typed `ToolContext`, which the runtime fills instead of the model.
334    let mut param_idents = Vec::new();
335    let mut param_types = Vec::new();
336    let mut context_ident: Option<syn::Ident> = None;
337    for input in &sig.inputs {
338        match input {
339            FnArg::Receiver(r) => {
340                return Err(syn::Error::new_spanned(
341                    r,
342                    "#[tool] cannot be applied to methods taking `self`",
343                ));
344            }
345            FnArg::Typed(PatType { pat, ty, .. }) => {
346                let ident = match pat.as_ref() {
347                    Pat::Ident(pat_ident) => pat_ident.ident.clone(),
348                    other => {
349                        return Err(syn::Error::new_spanned(
350                            other,
351                            "#[tool] parameters must be simple identifiers (no patterns)",
352                        ));
353                    }
354                };
355                param_idents.push(ident.clone());
356                param_types.push((*ty).clone());
357                if is_tool_context(ty) {
358                    if context_ident.is_some() {
359                        return Err(syn::Error::new_spanned(
360                            ty,
361                            "#[tool] takes at most one `ToolContext` parameter",
362                        ));
363                    }
364                    context_ident = Some(ident);
365                    continue;
366                }
367                if let Type::Reference(reference) = ty.as_ref() {
368                    return Err(syn::Error::new_spanned(
369                        reference,
370                        "#[tool] parameters are deserialized from the model's JSON, so they \
371                         must be owned: use `String` for `&str`, `Vec<T>` for `&[T]`",
372                    ));
373                }
374                field_idents.push(ident);
375                field_types.push((*ty).clone());
376            }
377        }
378    }
379
380    // What the model is told: the attribute wins, else the doc comment.
381    let docs = parse_docs(&doc_lines(&func.attrs));
382    for (name, _) in &docs.arguments {
383        if !field_idents.iter().any(|ident| ident == name) {
384            return Err(syn::Error::new_spanned(
385                fn_name,
386                format!(
387                    "the `# Arguments` section documents `{name}`, which is not a parameter \
388                     of `{fn_name}`"
389                ),
390            ));
391        }
392    }
393    let description = match description {
394        Some(text) => text.value(),
395        None if !docs.description.is_empty() => docs.description.clone(),
396        None => {
397            return Err(syn::Error::new_spanned(
398                fn_name,
399                "#[tool] needs a description for the model: add a `///` doc comment to the \
400                 function, or pass one as `#[tool(\"...\")]`",
401            ));
402        }
403    };
404
405    // The body keeps its declared return type; the tool adapts it.
406    let (return_type, adapt) = match &sig.output {
407        ReturnType::Default => (quote! { () }, quote! { tool_output }),
408        ReturnType::Type(_, ty) if is_result(ty) => (quote! { #ty }, quote! { tool_result }),
409        ReturnType::Type(_, ty) => (quote! { #ty }, quote! { tool_output }),
410    };
411
412    // `#[cfg]` gates every generated item; docs and `#[deprecated]` describe
413    // the constructor users call; everything else belongs with the body.
414    let mut cfg_attrs = Vec::new();
415    let mut constructor_attrs = Vec::new();
416    let mut body_attrs = Vec::new();
417    for attr in &func.attrs {
418        let path = attr.path();
419        if path.is_ident("cfg") {
420            cfg_attrs.push(attr);
421        } else if path.is_ident("doc") || path.is_ident("deprecated") {
422            constructor_attrs.push(attr);
423        } else {
424            body_attrs.push(attr);
425        }
426    }
427
428    // Naming for generated items, derived from the (Pascal-cased) fn name.
429    let pascal = to_pascal_case(&fn_name.to_string());
430    let args_struct = format_ident!("__{}Args", pascal);
431    let tool_struct = format_ident!("__{}Tool", pascal);
432    // The inner async fn that holds the original body, invoked from `call`.
433    let inner_fn = format_ident!("__{}_impl", fn_name);
434
435    let fn_name_str = fn_name.to_string();
436
437    // Build the hidden args struct fields; a documented parameter carries its
438    // text as a doc comment, which the schema derive turns into `description`.
439    let struct_fields = field_idents
440        .iter()
441        .zip(field_types.iter())
442        .map(|(ident, ty)| {
443            let doc = docs
444                .arguments
445                .iter()
446                .find(|(name, _)| ident == name)
447                .map(|(_, text)| text.as_str())
448                .filter(|text| !text.is_empty())
449                .map(|text| quote! { #[doc = #text] });
450            // `Option<T>` fields default to `None` when absent from the JSON.
451            // No trailing comma here — `#(#struct_fields),*` adds the separators.
452            let default = is_option(ty).then(|| quote! { #[serde(default)] });
453            quote! {
454                #doc
455                #default
456                #ident: #ty
457            }
458        });
459
460    // Upstream crates are reached through `gemini_adk_rs::__macros` so the consumer
461    // doesn't need them in scope under those exact names.
462    let (rt, rt_str) = runtime();
463    let serde = quote! { #rt::__macros::serde };
464    let schemars = quote! { #rt::__macros::schemars };
465    let async_trait = quote! { #rt::__macros::async_trait };
466    let serde_json = quote! { #rt::__macros::serde_json };
467    let serde_crate = LitStr::new(&format!("{rt_str}::__macros::serde"), Span::call_site());
468    let schemars_crate = LitStr::new(&format!("{rt_str}::__macros::schemars"), Span::call_site());
469
470    // Bind the runtime's context to the parameter that asked for it.
471    let bind_context = match &context_ident {
472        Some(ident) => quote! { let #ident = ctx; },
473        None => quote! { let _ = ctx; },
474    };
475
476    let expanded = quote! {
477        // Hidden args struct: drives both deserialization and schema generation.
478        #(#cfg_attrs)*
479        #[derive(#serde::Deserialize, #schemars::JsonSchema)]
480        #[serde(crate = #serde_crate)]
481        #[schemars(crate = #schemars_crate)]
482        #[allow(non_camel_case_types, non_snake_case)]
483        struct #args_struct {
484            #(#struct_fields),*
485        }
486
487        // The original function body, preserved verbatim as a free async fn.
488        #(#cfg_attrs)*
489        #(#body_attrs)*
490        #[allow(non_snake_case)]
491        async fn #inner_fn ( #(#param_idents : #param_types),* ) -> #return_type #body
492
493        // Hidden tool type implementing `ToolFunction`.
494        #(#cfg_attrs)*
495        #[allow(non_camel_case_types)]
496        #[derive(Clone, Copy, Debug, Default)]
497        #vis struct #tool_struct;
498
499        #(#cfg_attrs)*
500        #[#async_trait::async_trait]
501        impl #rt::tool::ToolFunction for #tool_struct {
502            fn name(&self) -> &str {
503                #fn_name_str
504            }
505
506            fn description(&self) -> &str {
507                #description
508            }
509
510            fn parameters(&self) -> ::core::option::Option<#serde_json::Value> {
511                // The args struct's name is an implementation detail; the
512                // tool's name and description are what the model reads.
513                let mut schema = #rt::tool::wire_schema::<#args_struct>();
514                if let ::core::option::Option::Some(object) = schema.as_object_mut() {
515                    object.remove("title");
516                }
517                ::core::option::Option::Some(schema)
518            }
519
520            async fn call(
521                &self,
522                args: #serde_json::Value,
523            ) -> ::core::result::Result<#serde_json::Value, #rt::error::ToolError> {
524                self.call_with_context(args, #rt::tool::ToolContext::detached()).await
525            }
526
527            async fn call_with_context(
528                &self,
529                args: #serde_json::Value,
530                ctx: #rt::tool::ToolContext,
531            ) -> ::core::result::Result<#serde_json::Value, #rt::error::ToolError> {
532                let #args_struct { #(#field_idents),* } =
533                    #serde_json::from_value(args).map_err(|e| {
534                        #rt::error::ToolError::InvalidArgs(
535                            ::std::format!("Failed to deserialize arguments: {e}"),
536                        )
537                    })?;
538                #bind_context
539                #rt::__macros::#adapt(#inner_fn ( #(#param_idents),* ).await)
540            }
541        }
542
543        // Public constructor: `fn foo() -> __FooTool`.
544        #(#cfg_attrs)*
545        #(#constructor_attrs)*
546        #[allow(non_snake_case)]
547        #vis fn #fn_name () -> #tool_struct {
548            #tool_struct
549        }
550    };
551
552    Ok(expanded)
553}
554
555/// Derive an `Extract` record builder from a struct's fields.
556///
557/// Each field carries a `#[recognize(..)]` attribute naming a deterministic
558/// recognizer; the macro generates an inherent `fn extract() -> Extract` that
559/// builds the record. The field name becomes the record field name and (by
560/// default) its `State` key.
561///
562/// ```ignore
563/// use gemini_adk_rs::extract::Extract;   // the type — same name, type namespace
564/// use gemini_adk_rs::Extract;            // the derive — macro namespace
565///
566/// #[derive(Extract)]
567/// #[extract(name = "order", window = 3)]
568/// struct Order {
569///     #[recognize(integer_near = ["want", "get"])]
570///     quantity: Option<i64>,
571///     #[recognize(one_of = ["pizza", "salad", "soda"])]
572///     item: Option<String>,
573///     #[recognize(datetime)]
574///     #[extract(state = "when")]
575///     pickup: Option<serde_json::Value>,
576///     #[recognize(yes_no)]
577///     confirmed: Option<bool>,
578/// }
579///
580/// let record: Extract = Order::extract();
581/// ```
582///
583/// # Recognizer forms
584///
585/// | Attribute | Recognizer |
586/// |---|---|
587/// | `#[recognize(integer)]` | `Recognizer::integer()` |
588/// | `#[recognize(integer_near = ["a", "b"])]` | `Recognizer::integer_near([..])` |
589/// | `#[recognize(money)]` | `Recognizer::money()` |
590/// | `#[recognize(regex = "pat")]` | `Recognizer::regex("pat")` |
591/// | `#[recognize(one_of = ["a", "b"])]` | `Recognizer::one_of([..])` |
592/// | `#[recognize(fuzzy = ["a", "b"])]` | `Recognizer::fuzzy([..])` |
593/// | `#[recognize(yes_no)]` | `Recognizer::yes_no()` |
594/// | `#[recognize(datetime)]` | `Recognizer::datetime()` |
595///
596/// # Options
597///
598/// - Container `#[extract(name = "...")]` — record name (default: the struct
599///   name in `snake_case`).
600/// - Container `#[extract(window = N)]` — transcript window (default `3`).
601/// - Field `#[extract(state = "key")]` — promote to a custom `State` key.
602///
603/// Fields without a `#[recognize(..)]` attribute are ignored.
604#[proc_macro_derive(Extract, attributes(recognize, extract))]
605pub fn derive_extract(item: TokenStream) -> TokenStream {
606    let input = parse_macro_input!(item as DeriveInput);
607    match expand_extract(input) {
608        Ok(ts) => ts.into(),
609        Err(e) => e.to_compile_error().into(),
610    }
611}
612
613fn expand_extract(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
614    let (rt, _) = runtime();
615    let ident = &input.ident;
616
617    let fields = match &input.data {
618        Data::Struct(s) => match &s.fields {
619            Fields::Named(named) => &named.named,
620            _ => {
621                return Err(syn::Error::new_spanned(
622                    ident,
623                    "#[derive(Extract)] requires a struct with named fields",
624                ));
625            }
626        },
627        _ => {
628            return Err(syn::Error::new_spanned(
629                ident,
630                "#[derive(Extract)] can only be applied to structs",
631            ));
632        }
633    };
634
635    // Container options: name + window.
636    let mut name = to_snake_case(&ident.to_string());
637    let mut window: usize = 3;
638    for attr in &input.attrs {
639        if attr.path().is_ident("extract") {
640            attr.parse_nested_meta(|meta| {
641                if meta.path.is_ident("name") {
642                    let v: LitStr = meta.value()?.parse()?;
643                    name = v.value();
644                } else if meta.path.is_ident("window") {
645                    let v: LitInt = meta.value()?.parse()?;
646                    window = v.base10_parse()?;
647                } else {
648                    return Err(
649                        meta.error("unknown `extract` option (expected `name` or `window`)")
650                    );
651                }
652                Ok(())
653            })?;
654        }
655    }
656
657    // Every named field, referenced by a hidden marker method so that deriving
658    // `Extract` on an otherwise-unread struct does not trip `dead_code`.
659    let all_field_idents: Vec<_> = fields.iter().filter_map(|f| f.ident.clone()).collect();
660
661    // One `.field(..)` / `.field_to(..)` call per recognized field.
662    let mut field_calls = Vec::new();
663    for field in fields {
664        let Some(recognize) = field.attrs.iter().find(|a| a.path().is_ident("recognize")) else {
665            continue;
666        };
667        let fname = field.ident.as_ref().expect("named field").to_string();
668        let recognizer = recognizer_expr(recognize)?;
669
670        // Optional per-field state-key override.
671        let mut state_key: Option<String> = None;
672        for attr in &field.attrs {
673            if attr.path().is_ident("extract") {
674                attr.parse_nested_meta(|meta| {
675                    if meta.path.is_ident("state") {
676                        let v: LitStr = meta.value()?.parse()?;
677                        state_key = Some(v.value());
678                    } else {
679                        return Err(meta.error("unknown field `extract` option (expected `state`)"));
680                    }
681                    Ok(())
682                })?;
683            }
684        }
685
686        field_calls.push(match state_key {
687            Some(sk) => quote! { .field_to(#fname, #sk, #recognizer) },
688            None => quote! { .field(#fname, #recognizer) },
689        });
690    }
691
692    let doc = format!("The `Extract` record derived from `{ident}`'s `#[recognize(..)]` fields.");
693    Ok(quote! {
694        impl #ident {
695            #[doc = #doc]
696            pub fn extract() -> #rt::extract::Extract {
697                #rt::extract::Extract::record(#name)
698                    #(#field_calls)*
699                    .window(#window)
700                    .build()
701            }
702
703            #[allow(dead_code)]
704            #[doc(hidden)]
705            fn __extract_mark_fields_used(&self) {
706                #( let _ = &self.#all_field_idents; )*
707            }
708        }
709    })
710}
711
712/// Derive a [`Frame`] impl from a struct's `#[slot(..)]` fields.
713///
714/// Every named field becomes a slot (state key = field name unless overridden).
715/// The generated `fn frame() -> FrameSpec` carries each slot's prompt, reprompt,
716/// confirmation policy, and PII flag — the metadata the conversation compiler and
717/// repair use.
718///
719/// ```ignore
720/// #[derive(Frame)]
721/// #[frame(name = "booking")]
722/// struct Booking {
723///     #[slot(prompt = "For how many people?", confirm = "low_confidence")]
724///     party_size: u8,
725///     #[slot(prompt = "Name?", pii)]
726///     name: String,
727/// }
728/// ```
729///
730/// Field `#[slot(..)]` options: `prompt`, `reprompt`, `confirm`
731/// (`never`/`low_confidence`/`always`), `state` (key override), `pii` (flag).
732/// Container `#[frame(name = "...")]` sets the frame name.
733#[proc_macro_derive(Frame, attributes(slot, frame, recognize))]
734pub fn derive_frame(item: TokenStream) -> TokenStream {
735    let input = parse_macro_input!(item as DeriveInput);
736    match expand_frame(input) {
737        Ok(ts) => ts.into(),
738        Err(e) => e.to_compile_error().into(),
739    }
740}
741
742fn expand_frame(input: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
743    let (rt, _) = runtime();
744    let ident = &input.ident;
745
746    let fields = match &input.data {
747        Data::Struct(s) => match &s.fields {
748            Fields::Named(named) => &named.named,
749            _ => {
750                return Err(syn::Error::new_spanned(
751                    ident,
752                    "#[derive(Frame)] requires a struct with named fields",
753                ));
754            }
755        },
756        _ => {
757            return Err(syn::Error::new_spanned(
758                ident,
759                "#[derive(Frame)] can only be applied to structs",
760            ));
761        }
762    };
763
764    // Container `#[frame(name = "...")]`.
765    let mut name = to_snake_case(&ident.to_string());
766    for attr in &input.attrs {
767        if attr.path().is_ident("frame") {
768            attr.parse_nested_meta(|meta| {
769                if meta.path.is_ident("name") {
770                    let v: LitStr = meta.value()?.parse()?;
771                    name = v.value();
772                    Ok(())
773                } else {
774                    Err(meta.error("unknown `frame` option (expected `name`)"))
775                }
776            })?;
777        }
778    }
779
780    let all_field_idents: Vec<_> = fields.iter().filter_map(|f| f.ident.clone()).collect();
781
782    let mut slot_exprs = Vec::new();
783    for field in fields {
784        let fname = field.ident.as_ref().expect("named field").to_string();
785        let mut state_key = fname.clone();
786        let mut prompt: Option<String> = None;
787        let mut reprompt: Option<String> = None;
788        let mut confirm = quote! { #rt::frame::ConfirmPolicy::Never };
789        let mut pii = false;
790        let mut min: Option<f64> = None;
791        let mut max: Option<f64> = None;
792        let mut non_empty = false;
793
794        // Optional `#[recognize(..)]` (same vocabulary as `#[derive(Extract)]`).
795        let recognizer = match field.attrs.iter().find(|a| a.path().is_ident("recognize")) {
796            Some(attr) => {
797                let r = slot_recognizer_expr(attr)?;
798                quote! { Some(#r) }
799            }
800            None => quote! { None },
801        };
802
803        for attr in &field.attrs {
804            if !attr.path().is_ident("slot") {
805                continue;
806            }
807            attr.parse_nested_meta(|meta| {
808                if meta.path.is_ident("prompt") {
809                    let v: LitStr = meta.value()?.parse()?;
810                    prompt = Some(v.value());
811                } else if meta.path.is_ident("reprompt") {
812                    let v: LitStr = meta.value()?.parse()?;
813                    reprompt = Some(v.value());
814                } else if meta.path.is_ident("state") {
815                    let v: LitStr = meta.value()?.parse()?;
816                    state_key = v.value();
817                } else if meta.path.is_ident("confirm") {
818                    let v: LitStr = meta.value()?.parse()?;
819                    confirm = match v.value().as_str() {
820                        "never" => quote! { #rt::frame::ConfirmPolicy::Never },
821                        "low_confidence" => {
822                            quote! { #rt::frame::ConfirmPolicy::LowConfidence }
823                        }
824                        "always" => quote! { #rt::frame::ConfirmPolicy::Always },
825                        other => {
826                            return Err(meta.error(format!(
827                                "unknown confirm policy '{other}' (expected never/low_confidence/always)"
828                            )))
829                        }
830                    };
831                } else if meta.path.is_ident("pii") {
832                    pii = true;
833                } else if meta.path.is_ident("min") {
834                    min = Some(lit_to_f64(&meta.value()?.parse()?)?);
835                } else if meta.path.is_ident("max") {
836                    max = Some(lit_to_f64(&meta.value()?.parse()?)?);
837                } else if meta.path.is_ident("non_empty") {
838                    non_empty = true;
839                } else {
840                    return Err(meta.error(
841                        "unknown `slot` option (expected prompt/reprompt/state/confirm/pii/min/max/non_empty)",
842                    ));
843                }
844                Ok(())
845            })?;
846        }
847
848        // Lower min/max/non_empty into a serializable SlotValidator.
849        let validate = if min.is_some() || max.is_some() {
850            let min_tok = match min {
851                Some(v) => quote! { Some(#v) },
852                None => quote! { None },
853            };
854            let max_tok = match max {
855                Some(v) => quote! { Some(#v) },
856                None => quote! { None },
857            };
858            quote! { Some(#rt::frame::SlotValidator::Range { min: #min_tok, max: #max_tok }) }
859        } else if non_empty {
860            quote! { Some(#rt::frame::SlotValidator::NonEmpty) }
861        } else {
862            quote! { None }
863        };
864
865        let prompt_tok = match prompt {
866            Some(p) => quote! { Some(#p.to_string()) },
867            None => quote! { None },
868        };
869        let reprompt_tok = match reprompt {
870            Some(p) => quote! { Some(#p.to_string()) },
871            None => quote! { None },
872        };
873        slot_exprs.push(quote! {
874            #rt::frame::SlotSpec {
875                name: #fname.to_string(),
876                state_key: #state_key.to_string(),
877                prompt: #prompt_tok,
878                reprompt: #reprompt_tok,
879                confirm: #confirm,
880                pii: #pii,
881                recognizer: #recognizer,
882                validate: #validate,
883            }
884        });
885    }
886
887    let doc = format!("The `FrameSpec` derived from `{ident}`'s `#[slot(..)]` fields.");
888    Ok(quote! {
889        impl #rt::frame::Frame for #ident {
890            #[doc = #doc]
891            fn frame() -> #rt::frame::FrameSpec {
892                #rt::frame::FrameSpec {
893                    name: #name.to_string(),
894                    slots: ::std::vec![ #(#slot_exprs),* ],
895                }
896            }
897        }
898
899        impl #ident {
900            #[allow(dead_code)]
901            #[doc(hidden)]
902            fn __frame_mark_fields_used(&self) {
903                #( let _ = &self.#all_field_idents; )*
904            }
905        }
906    })
907}
908
909/// Build the `Recognizer::..` expression for a single `#[recognize(..)]` attr.
910fn recognizer_expr(attr: &syn::Attribute) -> syn::Result<proc_macro2::TokenStream> {
911    let (rt, _) = runtime();
912    let r = quote! { #rt::extract::Recognizer };
913    let meta: Meta = attr.parse_args()?;
914    match meta {
915        Meta::Path(p) => {
916            let id = p
917                .get_ident()
918                .ok_or_else(|| syn::Error::new_spanned(&p, "expected a recognizer name"))?;
919            match id.to_string().as_str() {
920                "integer" => Ok(quote! { #r::integer() }),
921                "money" => Ok(quote! { #r::money() }),
922                "yes_no" => Ok(quote! { #r::yes_no() }),
923                "datetime" => Ok(quote! { #r::datetime() }),
924                other => Err(syn::Error::new_spanned(
925                    &p,
926                    format!("unknown recognizer `{other}`"),
927                )),
928            }
929        }
930        Meta::NameValue(nv) => {
931            let id = nv
932                .path
933                .get_ident()
934                .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected a recognizer name"))?;
935            match id.to_string().as_str() {
936                "integer_near" => {
937                    let a = str_array(&nv.value)?;
938                    Ok(quote! { #r::integer_near([ #(#a),* ]) })
939                }
940                "one_of" => {
941                    let a = str_array(&nv.value)?;
942                    Ok(quote! { #r::one_of([ #(#a),* ]) })
943                }
944                "fuzzy" => {
945                    let a = str_array(&nv.value)?;
946                    Ok(quote! { #r::fuzzy([ #(#a),* ]) })
947                }
948                "regex" => {
949                    let s = str_lit(&nv.value)?;
950                    Ok(quote! { #r::regex(#s) })
951                }
952                other => Err(syn::Error::new_spanned(
953                    &nv.path,
954                    format!("`{other}` does not take a value"),
955                )),
956            }
957        }
958        Meta::List(l) => Err(syn::Error::new_spanned(
959            l,
960            "unexpected nested list in `#[recognize(..)]`",
961        )),
962    }
963}
964
965/// Build a serializable `SlotRecognizer` expression for a `#[recognize(..)]` attr
966/// on a `#[derive(Frame)]` field (same vocabulary as the Extract derive).
967fn slot_recognizer_expr(attr: &syn::Attribute) -> syn::Result<proc_macro2::TokenStream> {
968    let (rt, _) = runtime();
969    let r = quote! { #rt::frame::SlotRecognizer };
970    let meta: Meta = attr.parse_args()?;
971    match meta {
972        Meta::Path(p) => {
973            let id = p
974                .get_ident()
975                .ok_or_else(|| syn::Error::new_spanned(&p, "expected a recognizer name"))?;
976            match id.to_string().as_str() {
977                "integer" => Ok(quote! { #r::Integer }),
978                "money" => Ok(quote! { #r::Money }),
979                "yes_no" => Ok(quote! { #r::YesNo }),
980                "datetime" => Ok(quote! { #r::DateTime }),
981                other => Err(syn::Error::new_spanned(
982                    &p,
983                    format!("unknown recognizer `{other}`"),
984                )),
985            }
986        }
987        Meta::NameValue(nv) => {
988            let id = nv
989                .path
990                .get_ident()
991                .ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected a recognizer name"))?;
992            match id.to_string().as_str() {
993                "integer_near" => {
994                    let a = str_array(&nv.value)?;
995                    Ok(quote! { #r::IntegerNear(::std::vec![ #(#a.to_string()),* ]) })
996                }
997                "one_of" => {
998                    let a = str_array(&nv.value)?;
999                    Ok(quote! { #r::OneOf(::std::vec![ #(#a.to_string()),* ]) })
1000                }
1001                "fuzzy" => {
1002                    let a = str_array(&nv.value)?;
1003                    Ok(quote! { #r::Fuzzy(::std::vec![ #(#a.to_string()),* ]) })
1004                }
1005                "regex" => {
1006                    let s = str_lit(&nv.value)?;
1007                    Ok(quote! { #r::Regex(#s.to_string()) })
1008                }
1009                other => Err(syn::Error::new_spanned(
1010                    &nv.path,
1011                    format!("`{other}` does not take a value"),
1012                )),
1013            }
1014        }
1015        Meta::List(l) => Err(syn::Error::new_spanned(
1016            l,
1017            "unexpected nested list in `#[recognize(..)]`",
1018        )),
1019    }
1020}
1021
1022/// Parse an integer or float literal into an `f64` (for slot `min`/`max`).
1023fn lit_to_f64(lit: &Lit) -> syn::Result<f64> {
1024    match lit {
1025        Lit::Int(i) => i.base10_parse::<f64>(),
1026        Lit::Float(f) => f.base10_parse::<f64>(),
1027        other => Err(syn::Error::new_spanned(
1028            other,
1029            "expected a numeric literal for `min`/`max`",
1030        )),
1031    }
1032}
1033
1034/// Parse an expression that must be an array of string literals.
1035fn str_array(expr: &Expr) -> syn::Result<Vec<LitStr>> {
1036    match expr {
1037        Expr::Array(arr) => arr
1038            .elems
1039            .iter()
1040            .map(|e| match e {
1041                Expr::Lit(ExprLit {
1042                    lit: Lit::Str(s), ..
1043                }) => Ok(s.clone()),
1044                other => Err(syn::Error::new_spanned(
1045                    other,
1046                    "expected a string literal in the array",
1047                )),
1048            })
1049            .collect(),
1050        other => Err(syn::Error::new_spanned(
1051            other,
1052            "expected an array of string literals, e.g. [\"a\", \"b\"]",
1053        )),
1054    }
1055}
1056
1057/// Parse an expression that must be a single string literal.
1058fn str_lit(expr: &Expr) -> syn::Result<LitStr> {
1059    match expr {
1060        Expr::Lit(ExprLit {
1061            lit: Lit::Str(s), ..
1062        }) => Ok(s.clone()),
1063        other => Err(syn::Error::new_spanned(other, "expected a string literal")),
1064    }
1065}
1066
1067/// Convert a `PascalCase`/`camelCase` identifier to `snake_case`.
1068fn to_snake_case(s: &str) -> String {
1069    let mut out = String::with_capacity(s.len() + 4);
1070    for (i, ch) in s.chars().enumerate() {
1071        if ch.is_uppercase() {
1072            if i != 0 {
1073                out.push('_');
1074            }
1075            out.extend(ch.to_lowercase());
1076        } else {
1077            out.push(ch);
1078        }
1079    }
1080    out
1081}
1082
1083/// Returns `true` if `ty` is syntactically an `Option<...>`.
1084///
1085/// Accepts the prelude name (`Option`) and the spelled-out std/core paths
1086/// (`option::Option`, `std::option::Option`, `core::option::Option`, with or
1087/// without a leading `::`). The full path is checked — a user type like
1088/// `my::Option` does NOT match. Purely syntactic: a type alias or renamed
1089/// import of `Option` is invisible to the macro, as with any derive.
1090fn is_option(ty: &Type) -> bool {
1091    let Type::Path(TypePath { qself: None, path }) = ty else {
1092        return false;
1093    };
1094    // Only the final `Option` segment may carry generic arguments.
1095    if path
1096        .segments
1097        .iter()
1098        .rev()
1099        .skip(1)
1100        .any(|seg| !seg.arguments.is_none())
1101    {
1102        return false;
1103    }
1104    let idents: Vec<&syn::Ident> = path.segments.iter().map(|seg| &seg.ident).collect();
1105    match idents.as_slice() {
1106        // `Option<T>` / `option::Option<T>` resolve via the prelude only when
1107        // the path is relative.
1108        [opt] => path.leading_colon.is_none() && *opt == "Option",
1109        [module, opt] => path.leading_colon.is_none() && *module == "option" && *opt == "Option",
1110        // `std::option::Option<T>` / `core::option::Option<T>`, `::`-rooted or not.
1111        [root, module, opt] => {
1112            (*root == "std" || *root == "core") && *module == "option" && *opt == "Option"
1113        }
1114        _ => false,
1115    }
1116}
1117
1118/// Convert a `snake_case` identifier to `PascalCase`.
1119fn to_pascal_case(s: &str) -> String {
1120    let mut out = String::with_capacity(s.len());
1121    let mut upper_next = true;
1122    for ch in s.chars() {
1123        if ch == '_' {
1124            upper_next = true;
1125        } else if upper_next {
1126            out.extend(ch.to_uppercase());
1127            upper_next = false;
1128        } else {
1129            out.push(ch);
1130        }
1131    }
1132    out
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::{ToolDocs, is_option, is_result, parse_docs};
1138    use syn::parse_quote;
1139
1140    fn docs(text: &str) -> ToolDocs {
1141        let lines: Vec<String> = text.lines().map(|l| format!(" {l}")).collect();
1142        parse_docs(&lines)
1143    }
1144
1145    #[test]
1146    fn description_is_the_prose_before_the_first_heading() {
1147        let parsed =
1148            docs("Get the weather\nfor a city.\n\nUses the cached forecast.\n\n# Errors\n\nNever.");
1149        assert_eq!(
1150            parsed.description,
1151            "Get the weather for a city.\n\nUses the cached forecast."
1152        );
1153        assert!(parsed.arguments.is_empty());
1154    }
1155
1156    #[test]
1157    fn arguments_accept_the_common_rustdoc_forms() {
1158        let parsed = docs(
1159            "Look up.\n\n# Arguments\n\n\
1160             * `city` - The city,\n  e.g. Paris.\n\
1161             - `units`: metric or imperial.\n\
1162             * days \u{2014} how far ahead.\n\n# Examples\n\n* `ignored` - not an argument",
1163        );
1164        assert_eq!(parsed.description, "Look up.");
1165        assert_eq!(
1166            parsed.arguments,
1167            vec![
1168                ("city".to_string(), "The city, e.g. Paris.".to_string()),
1169                ("units".to_string(), "metric or imperial.".to_string()),
1170                ("days".to_string(), "how far ahead.".to_string()),
1171            ]
1172        );
1173    }
1174
1175    #[test]
1176    fn code_blocks_are_neither_description_nor_headings() {
1177        let parsed = docs("Add two numbers.\n```\n# let x = 1;\nadd(1, 2);\n```\nThen return.");
1178        assert_eq!(parsed.description, "Add two numbers. Then return.");
1179    }
1180
1181    #[test]
1182    fn is_result_matches_any_path_ending_in_result() {
1183        assert!(is_result(&parse_quote!(Result<Value, ToolError>)));
1184        assert!(is_result(&parse_quote!(anyhow::Result<u32>)));
1185        assert!(is_result(&parse_quote!(std::io::Result<()>)));
1186        assert!(!is_result(&parse_quote!(SearchResult)));
1187        assert!(!is_result(&parse_quote!(Vec<Result<u8, String>>)));
1188    }
1189
1190    #[test]
1191    fn is_option_accepts_std_core_paths() {
1192        assert!(is_option(&parse_quote!(Option<String>)));
1193        assert!(is_option(&parse_quote!(option::Option<String>)));
1194        assert!(is_option(&parse_quote!(std::option::Option<String>)));
1195        assert!(is_option(&parse_quote!(core::option::Option<String>)));
1196        assert!(is_option(&parse_quote!(::std::option::Option<String>)));
1197        assert!(is_option(&parse_quote!(::core::option::Option<String>)));
1198    }
1199
1200    #[test]
1201    fn is_option_rejects_lookalikes() {
1202        assert!(!is_option(&parse_quote!(String)));
1203        assert!(!is_option(&parse_quote!(Vec<Option<String>>)));
1204        assert!(!is_option(&parse_quote!(my::Option<String>)));
1205        assert!(!is_option(&parse_quote!(my::option::Option<String>)));
1206        assert!(!is_option(&parse_quote!(::option::Option<String>)));
1207        assert!(!is_option(&parse_quote!(<T as Trait>::Option)));
1208    }
1209}