Skip to main content

keel_macros/
lib.rs

1//! `#[keel::wrap]` — the Rust front end's one-attribute wrapping promise
2//! (architecture-spec.md §5.3, dx-spec.md invariant 1).
3//!
4//! v1 scope (deliberately narrow, see `crates/keel/src/lib.rs`'s crate docs
5//! and the session gap brief for the full descoping rationale):
6//!
7//! * The target is **explicit only**: `#[keel::wrap(target = "...")]`.
8//!   Signature/host inference was judged too fiddly for a free function (no
9//!   receiver to read a base URL off, unlike a Python/Node HTTP client
10//!   object) to be worth building for v1; an explicit string is one token
11//!   more than the ideal but still a single attribute, and it is exactly
12//!   the string the same policy key in `keel.toml` uses, so it stays
13//!   copy-pasteable and greppable.
14//! * The wrapped function must be a free (no receiver) `async fn` returning
15//!   `Result<T, E>` where `T: Serialize + DeserializeOwned` (round-tripped
16//!   through the engine's `Value` payload — required for the *cache* path,
17//!   where no "live" Rust value exists because the effect never ran) and
18//!   `E: std::error::Error + Send + Sync + 'static`.
19//! * Every parameter type must implement `Clone`: a retried call re-invokes
20//!   the wrapped body, and the macro cannot move the caller's arguments more
21//!   than once, so it clones them fresh for each attempt.
22//! * At compile time, the macro walks up from `CARGO_MANIFEST_DIR` looking
23//!   for a `keel.toml` (mirroring how `Cargo.toml`/`tsconfig.json` discovery
24//!   works). If found, it must be valid TOML (a hard compile error
25//!   otherwise — this is the main compile-time value the brief asks for:
26//!   catching a broken policy file before you ever run the binary). If the
27//!   file has a non-empty `[target]` table and the given `target` string
28//!   does not appear in it (and no glob-style key containing `*` is
29//!   present, which might match it at runtime), the macro emits a
30//!   `compile_error!` naming the closest thing it *did* find — this catches
31//!   the common typo case (`#[keel::wrap(target = "stripe")]` vs a
32//!   `[target."stripe-api"]` table) statically. The runtime policy is still
33//!   (re)loaded and applied by `keel::init`/the lazy engine init — this is a
34//!   best-effort static lint layered on top, not a cache of the policy.
35use std::path::{Path, PathBuf};
36
37use proc_macro::TokenStream;
38use proc_macro2::TokenStream as TokenStream2;
39use quote::{format_ident, quote};
40use syn::{
41    Expr, ExprLit, FnArg, GenericArgument, ItemFn, Lit, MetaNameValue, Pat, PathArguments,
42    ReturnType, Token, Type,
43    parse::{Parse, ParseStream},
44    parse_macro_input,
45    punctuated::Punctuated,
46    spanned::Spanned,
47};
48
49/// `#[keel::wrap(target = "...", idempotent = true)]`.
50#[proc_macro_attribute]
51pub fn wrap(attr: TokenStream, item: TokenStream) -> TokenStream {
52    let args = parse_macro_input!(attr as WrapArgs);
53    let input = parse_macro_input!(item as ItemFn);
54    match expand(&args, &input) {
55        Ok(tokens) => tokens.into(),
56        Err(err) => err.to_compile_error().into(),
57    }
58}
59
60struct WrapArgs {
61    target: syn::LitStr,
62    idempotent: bool,
63}
64
65impl Parse for WrapArgs {
66    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
67        if input.is_empty() {
68            return Err(syn::Error::new(
69                proc_macro2::Span::call_site(),
70                "#[keel::wrap] requires `target = \"...\"`, e.g. \
71                 #[keel::wrap(target = \"orders-api\")] (explicit-target-only in v1 — \
72                 see crates/keel-macros/src/lib.rs for why signature inference is deferred)",
73            ));
74        }
75        let pairs = Punctuated::<MetaNameValue, Token![,]>::parse_terminated(input)?;
76        let mut target = None;
77        let mut idempotent = false;
78        for pair in pairs {
79            let Some(key) = pair.path.get_ident().map(ToString::to_string) else {
80                return Err(syn::Error::new_spanned(
81                    &pair.path,
82                    "expected a simple identifier (`target` or `idempotent`)",
83                ));
84            };
85            match key.as_str() {
86                "target" => target = Some(expect_litstr(&pair.value)?),
87                "idempotent" => idempotent = expect_litbool(&pair.value)?,
88                other => {
89                    return Err(syn::Error::new_spanned(
90                        &pair.path,
91                        format!(
92                            "unknown #[keel::wrap] argument `{other}`; expected `target` or `idempotent`"
93                        ),
94                    ));
95                }
96            }
97        }
98        let target = target.ok_or_else(|| {
99            syn::Error::new(
100                proc_macro2::Span::call_site(),
101                "#[keel::wrap] requires `target = \"...\"`",
102            )
103        })?;
104        Ok(WrapArgs { target, idempotent })
105    }
106}
107
108fn expect_litstr(expr: &Expr) -> syn::Result<syn::LitStr> {
109    match expr {
110        Expr::Lit(ExprLit {
111            lit: Lit::Str(s), ..
112        }) => Ok(s.clone()),
113        other => Err(syn::Error::new_spanned(other, "expected a string literal")),
114    }
115}
116
117fn expect_litbool(expr: &Expr) -> syn::Result<bool> {
118    match expr {
119        Expr::Lit(ExprLit {
120            lit: Lit::Bool(b), ..
121        }) => Ok(b.value),
122        other => Err(syn::Error::new_spanned(other, "expected `true` or `false`")),
123    }
124}
125
126fn expand(args: &WrapArgs, input: &ItemFn) -> syn::Result<TokenStream2> {
127    if input.sig.asyncness.is_none() {
128        return Err(syn::Error::new(
129            input.sig.fn_token.span(),
130            "#[keel::wrap] only supports `async fn` (v1 scope: the engine chain is driven on \
131             the caller's async runtime; wrap a sync body in `tokio::task::spawn_blocking` \
132             yourself first if needed)",
133        ));
134    }
135    if input.sig.receiver().is_some() {
136        return Err(syn::Error::new(
137            input.sig.fn_token.span(),
138            "#[keel::wrap] only supports free functions in v1, not methods with a `self` \
139             receiver — pull the body out into a free function and call it from the method",
140        ));
141    }
142    if !input.sig.generics.params.is_empty() || input.sig.generics.where_clause.is_some() {
143        return Err(syn::Error::new(
144            input.sig.generics.span(),
145            "#[keel::wrap] does not support generic functions in v1",
146        ));
147    }
148
149    let (ok_ty, err_ty) = parse_result_return(&input.sig.output)?;
150    validate_manifest_policy(args)?;
151
152    let vis = &input.vis;
153    let attrs = &input.attrs;
154    let name = &input.sig.ident;
155    let inner_name = format_ident!("__keel_inner_{}", name);
156    let inputs = &input.sig.inputs;
157    let output = &input.sig.output;
158    let body = &input.block;
159    let target_lit = &args.target;
160    let idempotent_lit = args.idempotent;
161
162    let mut param_idents = Vec::with_capacity(inputs.len());
163    for arg in inputs {
164        let FnArg::Typed(pat_ty) = arg else {
165            unreachable!("receiver already rejected above")
166        };
167        let Pat::Ident(pat_ident) = pat_ty.pat.as_ref() else {
168            return Err(syn::Error::new_spanned(
169                &pat_ty.pat,
170                "#[keel::wrap] requires simple identifier parameters in v1 (no destructuring \
171                 patterns) — each parameter is cloned once per retry attempt",
172            ));
173        };
174        param_idents.push(pat_ident.ident.clone());
175    }
176    let ref_idents: Vec<_> = param_idents
177        .iter()
178        .map(|p| format_ident!("__keel_ref_{}", p))
179        .collect();
180
181    let op_expr = quote! { concat!(module_path!(), "::", stringify!(#name)) };
182
183    Ok(quote! {
184        #[doc(hidden)]
185        #(#attrs)*
186        async fn #inner_name(#inputs) #output {
187            #body
188        }
189
190        #(#attrs)*
191        #vis async fn #name(#inputs) -> ::std::result::Result<#ok_ty, ::keel::Error<#err_ty>>
192        where
193            #ok_ty: ::keel::__private::serde::Serialize
194                + ::keel::__private::serde::de::DeserializeOwned
195                + ::std::marker::Send
196                + 'static,
197            #err_ty: ::std::error::Error + ::std::marker::Send + ::std::marker::Sync + 'static,
198        {
199            #( let #ref_idents = &#param_idents; )*
200            ::keel::__private::wrap_call(
201                #target_lit,
202                #op_expr,
203                #idempotent_lit,
204                move || {
205                    #( let #param_idents = ::std::clone::Clone::clone(#ref_idents); )*
206                    async move { #inner_name(#(#param_idents),*).await }
207                },
208            )
209            .await
210        }
211    })
212}
213
214fn parse_result_return(output: &ReturnType) -> syn::Result<(Type, Type)> {
215    let ReturnType::Type(_, ty) = output else {
216        return Err(syn::Error::new(
217            proc_macro2::Span::call_site(),
218            "#[keel::wrap] requires a `-> Result<T, E>` return type",
219        ));
220    };
221    let Type::Path(type_path) = ty.as_ref() else {
222        return Err(syn::Error::new_spanned(
223            ty,
224            "#[keel::wrap] requires a `-> Result<T, E>` return type",
225        ));
226    };
227    let Some(last) = type_path.path.segments.last() else {
228        return Err(syn::Error::new_spanned(ty, "malformed return type"));
229    };
230    if last.ident != "Result" {
231        return Err(syn::Error::new_spanned(
232            &last.ident,
233            "#[keel::wrap] requires a `-> Result<T, E>` return type",
234        ));
235    }
236    let PathArguments::AngleBracketed(generics) = &last.arguments else {
237        return Err(syn::Error::new_spanned(
238            last,
239            "#[keel::wrap]'s `Result` must be written with explicit `<T, E>` type arguments \
240             (a bare `Result` alias cannot be inspected at macro-expansion time)",
241        ));
242    };
243    let mut types = generics.args.iter().filter_map(|a| match a {
244        GenericArgument::Type(t) => Some(t.clone()),
245        _ => None,
246    });
247    let (Some(ok_ty), Some(err_ty)) = (types.next(), types.next()) else {
248        return Err(syn::Error::new_spanned(
249            generics,
250            "#[keel::wrap]'s `Result<T, E>` must specify both type arguments",
251        ));
252    };
253    Ok((ok_ty, err_ty))
254}
255
256/// Best-effort compile-time policy check (module docs). Never fails the
257/// build just because there is no `keel.toml` — Level 0 defaults apply at
258/// runtime the same way they do for the Python/Node front ends.
259fn validate_manifest_policy(args: &WrapArgs) -> syn::Result<()> {
260    let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else {
261        return Ok(());
262    };
263    let Some(path) = find_keel_toml(Path::new(&manifest_dir)) else {
264        return Ok(());
265    };
266    let Ok(contents) = std::fs::read_to_string(&path) else {
267        return Ok(());
268    };
269    let parsed: toml::Value = toml::from_str(&contents).map_err(|err| {
270        syn::Error::new(
271            proc_macro2::Span::call_site(),
272            format!(
273                "keel.toml at {} is not valid TOML: {err} (fix it, or delete it to fall back \
274                 to Level 0 defaults)",
275                path.display()
276            ),
277        )
278    })?;
279    let Some(table) = parsed.get("target").and_then(toml::Value::as_table) else {
280        return Ok(());
281    };
282    if table.is_empty() {
283        return Ok(());
284    }
285    let target = args.target.value();
286    if table.contains_key(&target) {
287        return Ok(());
288    }
289    if table.keys().any(|k| k.contains('*')) {
290        // A glob-style pattern key is present; it might match this target at
291        // runtime (targeting.md), and the macro does not implement glob
292        // matching — do not false-positive.
293        return Ok(());
294    }
295    let known: Vec<_> = table.keys().map(String::as_str).collect();
296    Err(syn::Error::new(
297        args.target.span(),
298        format!(
299            "target \"{target}\" is not a key in {}'s [target] table, and no glob-style key \
300             (containing `*`) is present that could match it at runtime. Known target keys: \
301             [{}]. If this is intentional (e.g. the target falls back to [defaults.outbound]), \
302             add an explicit `[target.\"{target}\"]` table (even an empty one) to keel.toml to \
303             silence this check.",
304            path.display(),
305            known.join(", "),
306        ),
307    ))
308}
309
310fn find_keel_toml(start: &Path) -> Option<PathBuf> {
311    let mut dir = Some(start);
312    while let Some(d) = dir {
313        let candidate = d.join("keel.toml");
314        if candidate.is_file() {
315            return Some(candidate);
316        }
317        dir = d.parent();
318    }
319    None
320}