1use 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#[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
256fn 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 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}