Skip to main content

cachekit_macros/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::TokenStream as TokenStream2;
3use quote::{quote, quote_spanned};
4use syn::{
5    parse::{Parse, ParseStream},
6    parse_macro_input,
7    spanned::Spanned,
8    Ident, ItemFn, LitInt, LitStr, ReturnType, Token, Type,
9};
10
11// ── Attribute argument parsing ───────────────────────────────────────────────
12
13struct MacroArgs {
14    client: Ident,
15    ttl: u64,
16    interop: String,
17    namespace: String,
18    secure: bool,
19}
20
21/// Compile-time mirror of the interop/v1 segment grammar
22/// (`^[a-z0-9][a-z0-9._-]{0,63}$` — see `cachekit::interop`'s
23/// `validate_segment`, the canonical implementation). Duplicated because a
24/// proc-macro crate cannot depend on the runtime crate; `interop_key`
25/// re-validates at runtime, so drift fails loudly, never silently. The
26/// reserved-namespace check is mirrored in `parse_segment`.
27fn segment_is_valid(segment: &str) -> bool {
28    let bytes = segment.as_bytes();
29    matches!(bytes.first(), Some(b) if b.is_ascii_lowercase() || b.is_ascii_digit())
30        && bytes.len() <= 64
31        && bytes[1..].iter().all(|b| {
32            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
33        })
34}
35
36/// Extract and validate an interop segment from a string literal, spanning
37/// the error to the literal.
38///
39/// Mirrors `validate_segment`'s namespace reservation: a `namespace` of exactly
40/// `ns` or `nsapi` is rejected (the CachekitIO server parses a key starting
41/// `ns:` / `nsapi:` as namespace-prefixed); both stay valid as `interop`.
42fn parse_segment(kind: &str, lit: &LitStr) -> syn::Result<String> {
43    let value = lit.value();
44    if !segment_is_valid(&value) {
45        return Err(syn::Error::new(
46            lit.span(),
47            format!(
48                "`{kind}` {value:?} is not a valid interop/v1 key segment: must match \
49                 ^[a-z0-9][a-z0-9._-]{{0,63}}$ (lowercase ASCII letters, digits, '.', '_', \
50                 '-'; 1-64 chars)"
51            ),
52        ));
53    }
54    if kind == "namespace" && matches!(value.as_str(), "ns" | "nsapi") {
55        return Err(syn::Error::new(
56            lit.span(),
57            format!(
58                "`namespace` {value:?} is reserved: the CachekitIO server parses a key \
59                 starting \"{value}:\" as namespace-prefixed"
60            ),
61        ));
62    }
63    Ok(value)
64}
65
66impl Parse for MacroArgs {
67    fn parse(input: ParseStream) -> syn::Result<Self> {
68        let mut client: Option<Ident> = None;
69        let mut ttl: Option<u64> = None;
70        let mut interop: Option<String> = None;
71        let mut namespace: Option<String> = None;
72        let mut secure = false;
73
74        while !input.is_empty() {
75            let key: Ident = input.parse()?;
76            match key.to_string().as_str() {
77                "client" => {
78                    input.parse::<Token![=]>()?;
79                    client = Some(input.parse()?);
80                }
81                "ttl" => {
82                    input.parse::<Token![=]>()?;
83                    let lit: LitInt = input.parse()?;
84                    ttl = Some(lit.base10_parse()?);
85                }
86                "interop" => {
87                    input.parse::<Token![=]>()?;
88                    let lit: LitStr = input.parse()?;
89                    interop = Some(parse_segment("interop", &lit)?);
90                }
91                "namespace" => {
92                    input.parse::<Token![=]>()?;
93                    let lit: LitStr = input.parse()?;
94                    namespace = Some(parse_segment("namespace", &lit)?);
95                }
96                "secure" => {
97                    secure = true;
98                }
99                other => {
100                    return Err(syn::Error::new(
101                        key.span(),
102                        format!("unknown attribute `{other}`"),
103                    ));
104                }
105            }
106
107            // Consume trailing comma if present
108            if !input.is_empty() {
109                input.parse::<Token![,]>()?;
110            }
111        }
112
113        let client = client
114            .ok_or_else(|| input.error("`client` is required: #[cachekit(client = name, ...)]"))?;
115        let ttl = ttl.ok_or_else(|| {
116            input.error("`ttl` is required: #[cachekit(client = name, ttl = 60)]")
117        })?;
118        let interop = interop.ok_or_else(|| {
119            input.error(
120                "`interop` is required: #[cachekit(client = name, ttl = 60, interop = \
121                 \"get_user\", namespace = \"users\")] — it names the operation segment of the \
122                 interop/v1 cache key `{namespace}:{operation}:{args_hash}`, and the spec \
123                 requires it to be explicit (protocol spec/interop-mode.md, SDK requirement 1)",
124            )
125        })?;
126        let namespace = namespace.ok_or_else(|| {
127            input.error(
128                "`namespace` is required: #[cachekit(client = name, ttl = 60, interop = \
129                 \"get_user\", namespace = \"users\")] — cache keys are interop/v1 \
130                 `{namespace}:{operation}:{args_hash}` and the namespace segment cannot be empty",
131            )
132        })?;
133
134        Ok(MacroArgs {
135            client,
136            ttl,
137            interop,
138            namespace,
139            secure,
140        })
141    }
142}
143
144// ── Return-type extraction ───────────────────────────────────────────────────
145
146/// Extract `T` from `Result<T, E>` in a function's return type.
147fn extract_ok_type(ret: &ReturnType) -> syn::Result<Type> {
148    let ty = match ret {
149        ReturnType::Type(_, ty) => ty.as_ref(),
150        ReturnType::Default => {
151            return Err(syn::Error::new_spanned(
152                ret,
153                "#[cachekit] function must return Result<T, CachekitError>",
154            ));
155        }
156    };
157
158    // Walk through the type to find Result<T, E> and extract T.
159    if let Type::Path(type_path) = ty {
160        if let Some(seg) = type_path.path.segments.last() {
161            if seg.ident != "Result" {
162                return Err(syn::Error::new_spanned(
163                    ty,
164                    "#[cachekit] function must return Result<T, CachekitError>",
165                ));
166            }
167            if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
168                if let Some(syn::GenericArgument::Type(first)) = args.args.first() {
169                    return Ok(first.clone());
170                }
171            }
172        }
173    }
174
175    Err(syn::Error::new_spanned(
176        ty,
177        "#[cachekit] function must return Result<T, CachekitError>",
178    ))
179}
180
181// ── Proc-macro entry point ──────────────────────────────────────────────────
182
183/// Cache the result of an async function under an interop/v1 cache key.
184///
185/// Keys are `{namespace}:{operation}:{args_hash}` per the protocol's
186/// interop-mode spec, so the same entry is addressable from the Python and
187/// TypeScript SDKs — Python's `@cache(interop="get_user",
188/// namespace="users")` computes the identical key for the same arguments.
189///
190/// # Attributes
191///
192/// - `client = <ident>` (required): Name of the `&CacheKit` parameter.
193/// - `ttl = <integer>` (required): TTL in seconds.
194/// - `interop = "<string>"` (required): explicit, language-neutral operation
195///   segment (`^[a-z0-9][a-z0-9._-]{0,63}$`) — same meaning as Python's
196///   `interop=` / TypeScript's `interop:`.
197/// - `namespace = "<string>"` (required): interop/v1 namespace segment,
198///   same grammar. `ns` and `nsapi` are reserved as namespaces (a compile
199///   error — the CachekitIO server parses those key prefixes); operations are
200///   unaffected.
201/// - `secure` (optional flag): Use encrypted cache via `cache.secure_cache()`.
202///
203/// # Requirements
204///
205/// - Every non-client argument must be a plain identifier (no destructuring
206///   patterns, no `self`) and must convert into
207///   `cachekit::interop::InteropValue` via `From` (`bool`, `i32`/`i64`/
208///   `u32`/`u64`/`i128`, `f64`, `&str`/`String`, `Uuid`). Unsupported
209///   argument types fail at compile time.
210/// - In-model *values* outside the interop ranges fail at CALL time with
211///   `CachekitError::Serialization` before the body runs: non-finite floats
212///   (`NAN`, `±INFINITY`) and `i128` outside `[-2^63, 2^64-1]`. This
213///   fail-loud contract matches the Python and TypeScript SDKs — silently
214///   running uncached would mask cross-SDK key divergence.
215/// - The client must be built **without** `.namespace()` — interop keys
216///   carry their own namespace segment, and reads fail closed on a
217///   namespaced client.
218/// - A stored entry that cannot be decoded as the return type
219///   (`CachekitError::Serialization`) is treated as a miss and overwritten
220///   (self-healing). On `secure` functions this covers only post-decrypt
221///   decode failures: an entry that fails AES-GCM authentication raises
222///   `CachekitError::Encryption`, which propagates (fail-closed). The failed
223///   read drops the key's L1 copy and leaves the backend entry in place, so
224///   the next call reads the backend again: it fails while that entry
225///   remains, succeeds once any process replaces it with ciphertext this
226///   client can decrypt, and is a miss once it expires or is deleted.
227///
228/// # Requirements (continued)
229///
230/// - The function must be `async` — the generated body awaits the cache
231///   read, the cold-miss single-flight, and SWR refresh scheduling. A sync
232///   function is a clear compile-time error, never a silent no-op.
233///
234/// # Reliability behaviour
235///
236/// - **Stale-while-revalidate** (client SWR enabled — the default with the
237///   `l1` feature on native targets): an L1 hit past the client's freshness
238///   threshold (`swr_threshold_ratio` × entry TTL, ±10% jitter) but before
239///   hard expiry is returned **immediately** — the caller never blocks on a
240///   merely-stale entry — while one background task re-executes the function.
241///   Its version-checked commit rewrites both layers only if no newer set or
242///   delete replaced the stale entry; a successful commit renews the L1 hard
243///   expiry with the full write-path TTL. Refresh dedup rides
244///   [`CacheKit::single_flight`]: N concurrent stale readers trigger exactly
245///   one re-execution per process (and, on lock-capable backends, per
246///   fleet). Hard-expired entries always take the normal blocking miss path.
247///   The refresh task needs a tokio runtime (skipped otherwise — the stale
248///   value was already served) and captures arguments by owned copy
249///   (`Clone`/`ToOwned` — already required for key derivation); the future
250///   must be `Send`. Refresh failures are absorbed: the stale value keeps
251///   serving until hard expiry, where the blocking path surfaces errors.
252/// - **Graceful degradation**: on an outage-class backend failure —
253///   transient, timeout, an open circuit breaker, or a backpressure shed —
254///   the plain path fails *open*: the function executes uncached and its
255///   result is returned.
256///   Permanent and authentication errors propagate even on the plain path
257///   (a wrong API key must fail loudly, not silently disable caching
258///   forever). With `secure`, *every* backend and decryption error fails
259///   *closed* and propagates: an encrypted workload never silently
260///   degrades.
261/// - **Cold-miss single-flight**: concurrent calls that miss on the same
262///   key are collapsed to one execution per process (and per fleet, when
263///   the backend supports distributed fill locks — CachekitIO and Redis do,
264///   with the `reliability` feature). Waiters re-read the filled entry
265///   instead of recomputing. See `cachekit::flight`.
266///
267/// # Example
268///
269/// ```ignore
270/// #[cachekit(client = cache, ttl = 60, interop = "get_user", namespace = "users")]
271/// async fn get_user(cache: &CacheKit, id: u64) -> Result<User, CachekitError> {
272///     Ok(User { name: format!("User {id}") })
273/// }
274/// ```
275#[proc_macro_attribute]
276pub fn cachekit(attr: TokenStream, item: TokenStream) -> TokenStream {
277    let args = parse_macro_input!(attr as MacroArgs);
278    let input_fn = parse_macro_input!(item as ItemFn);
279
280    match expand(&args, input_fn) {
281        Ok(tokens) => tokens.into(),
282        Err(e) => e.to_compile_error().into(),
283    }
284}
285
286fn expand(args: &MacroArgs, mut func: ItemFn) -> syn::Result<TokenStream2> {
287    let client_ident = &args.client;
288    let ttl_secs = args.ttl;
289    let namespace = args.namespace.as_str();
290    let operation = args.interop.as_str();
291
292    // The generated body awaits (cache reads, single-flight, SWR refresh
293    // scheduling); a sync function cannot drive any of it. Fail with a clear
294    // decoration-time error instead of a cascade of "await is only allowed
295    // inside async functions" — no silent no-op (the sibling SDK posture).
296    if func.sig.asyncness.is_none() {
297        return Err(syn::Error::new_spanned(
298            func.sig.fn_token,
299            "#[cachekit] requires an `async fn`: the cache read, cold-miss single-flight, \
300             and stale-while-revalidate background refresh all await — annotate the \
301             function `async` (a sync function cannot be cached by this macro)",
302        ));
303    }
304
305    // Extract the Ok type from Result<T, E>
306    let ok_type = extract_ok_type(&func.sig.output)?;
307
308    // Collect non-client parameters (ident + type) for cache key derivation
309    // and SWR refresh capture. Every parameter MUST contribute to the key — a
310    // silently skipped parameter means two different calls share one cache
311    // entry (wrong-data collision), so anything we can't name is a compile
312    // error, never a skip.
313    let mut non_client_params: Vec<(&Ident, &Type)> = Vec::new();
314    for arg in &func.sig.inputs {
315        match arg {
316            syn::FnArg::Receiver(recv) => {
317                return Err(syn::Error::new_spanned(
318                    recv,
319                    "#[cachekit] does not support methods: a `self` receiver cannot \
320                     contribute to the interop/v1 cache key — wrap a free function instead",
321                ));
322            }
323            syn::FnArg::Typed(pat) => match pat.pat.as_ref() {
324                syn::Pat::Ident(pi) => {
325                    if pi.ident != *client_ident {
326                        non_client_params.push((&pi.ident, pat.ty.as_ref()));
327                    }
328                }
329                other => {
330                    return Err(syn::Error::new_spanned(
331                        other,
332                        "#[cachekit] parameters must be plain identifiers: destructuring \
333                         patterns cannot contribute to the cache key, and skipping them \
334                         would make different calls share one cache entry",
335                    ));
336                }
337            },
338        }
339    }
340    let non_client_idents: Vec<&Ident> = non_client_params.iter().map(|(id, _)| *id).collect();
341
342    // Convert each non-client argument to an InteropValue. `.clone()` keeps
343    // the original binding usable by the function body; for Copy types it
344    // compiles to a copy. Types outside the interop data model fail here at
345    // COMPILE time with a missing `From<T> for InteropValue` — that is the
346    // contract, not a bug: interop/v1 keys only hash modelable values.
347    // quote_spanned pins that error to the offending parameter, not the
348    // attribute.
349    let interop_args = non_client_idents.iter().map(|id| {
350        quote_spanned! {id.span()=> cachekit::interop::InteropValue::from(#id.clone()) }
351    });
352
353    // Generate cache get/set calls depending on `secure` flag.
354    let (get_expr, get_swr_expr, set_expr, complete_refresh_expr) = if args.secure {
355        (
356            quote! {
357                {
358                    let __ck_sec = #client_ident.secure_cache()?;
359                    __ck_sec.interop_get::<#ok_type>(&__ck_key).await
360                }
361            },
362            quote! {
363                {
364                    let __ck_sec = #client_ident.secure_cache()?;
365                    __ck_sec.interop_get_swr::<#ok_type>(&__ck_key).await
366                }
367            },
368            quote! {
369                let __ck_sec = #client_ident.secure_cache()?;
370                let _ = __ck_sec.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await;
371            },
372            quote! {
373                let __ck_sec = #client_ident.secure_cache()?;
374                let _ = __ck_sec.__complete_swr_refresh(
375                    &__ck_key,
376                    __ck_val,
377                    std::time::Duration::from_secs(#ttl_secs),
378                    __ck_swr_token,
379                ).await;
380            },
381        )
382    } else {
383        (
384            quote! { #client_ident.interop_get::<#ok_type>(&__ck_key).await },
385            quote! { #client_ident.interop_get_swr::<#ok_type>(&__ck_key).await },
386            quote! {
387                let _ = #client_ident.set_with_ttl(&__ck_key, __ck_val, std::time::Duration::from_secs(#ttl_secs)).await;
388            },
389            quote! {
390                let _ = #client_ident.__complete_swr_refresh(
391                    &__ck_key,
392                    __ck_val,
393                    std::time::Duration::from_secs(#ttl_secs),
394                    __ck_swr_token,
395                ).await;
396            },
397        )
398    };
399
400    // SWR refresh capture: the background task is `'static`, so every
401    // argument is re-materialised as an owned value before the spawn and
402    // rebound under its original name (and, for references, its original
403    // shape) inside the task — the function body runs unchanged:
404    //   - `&str`     → captured as `String`, rebound via `.as_str()` (exact)
405    //   - other `&T` → captured as `T` via `ToOwned`, rebound as `&owned`
406    //   - owned `T`  → captured via `Clone`, rebound by value
407    let mut swr_captures = TokenStream2::new();
408    let mut swr_rebinds = TokenStream2::new();
409    for (i, (id, ty)) in non_client_params.iter().enumerate() {
410        let holder = quote::format_ident!("__ck_swr_arg_{i}");
411        match ty {
412            Type::Reference(r) => {
413                // The refresh task re-executes the body from an owned
414                // snapshot rebound as an immutable borrow — mutation through
415                // a `&mut` argument cannot be preserved, so reject it up
416                // front instead of surfacing a confusing downstream error.
417                if r.mutability.is_some() {
418                    return Err(syn::Error::new_spanned(
419                        ty,
420                        "#[cachekit] does not support `&mut` parameters: the SWR background \
421                         refresh re-executes the function from an owned snapshot, so mutation \
422                         through the reference cannot be preserved — take the argument by \
423                         owned value instead",
424                    ));
425                }
426                swr_captures.extend(quote_spanned! {id.span()=>
427                    let #holder = ::std::borrow::ToOwned::to_owned(#id);
428                });
429                let is_str = matches!(r.elem.as_ref(),
430                    Type::Path(p) if p.path.is_ident("str"));
431                if is_str {
432                    swr_rebinds.extend(quote! { let #id = #holder.as_str(); });
433                } else {
434                    swr_rebinds.extend(quote! { let #id = &#holder; });
435                }
436            }
437            _ => {
438                swr_captures.extend(quote_spanned! {id.span()=>
439                    #[allow(clippy::clone_on_copy)]
440                    let #holder = #id.clone();
441                });
442                swr_rebinds.extend(quote! { let #id = #holder; });
443            }
444        }
445    }
446
447    // Graceful degradation: on an OUTAGE-class backend failure —
448    // retryable (transient/timeout), a fast-failing open circuit breaker,
449    // or a backpressure shed — the plain path fails OPEN: the
450    // wrapped function runs uncached, so a cache outage costs performance,
451    // not availability. A shed is the same class as CircuitOpen (the call
452    // never reached the backend), and failing open adds no work a cold-miss
453    // storm doesn't already create: the limiter bounds backend cache ops,
454    // not origin executions, and single-flight still dedupes those.
455    // Permanent and authentication errors PROPAGATE even on the plain path:
456    // a wrong API key that silently fell open would run uncached forever
457    // with zero signal while looking healthy. The
458    // `secure` path stays fail-CLOSED on everything: backend and decryption
459    // errors reach the caller, so an encrypted workload never silently
460    // degrades.
461    let fail_open_arm = if args.secure {
462        quote! {}
463    } else {
464        quote! {
465            Err(cachekit::error::CachekitError::Backend(__ck_be))
466                if __ck_be.kind.is_retryable()
467                    || matches!(
468                        __ck_be.kind,
469                        cachekit::error::BackendErrorKind::CircuitOpen
470                            | cachekit::error::BackendErrorKind::Backpressure
471                    ) => {}
472        }
473    };
474
475    // Capture the original function body.
476    let original_body = &func.block;
477
478    // Extract the raw return type (without `->` arrow) for type annotation.
479    let ret_ty = match &func.sig.output {
480        ReturnType::Type(_, ty) => ty.as_ref(),
481        ReturnType::Default => {
482            return Err(syn::Error::new_spanned(
483                &func.sig,
484                "#[cachekit] function must return Result<T, CachekitError>",
485            ));
486        }
487    };
488
489    // Build the new function body.
490    let new_body: syn::Block = syn::parse_quote! {
491        {
492            // interop/v1 key: {namespace}:{operation}:{args_hash} — the same
493            // key is computable from any SDK (protocol spec/interop-mode.md).
494            // The generated `.clone()` per argument keeps the binding usable
495            // by the body; for Copy types it is a copy and for `&str` it is
496            // a deliberate reference copy — hence the allows.
497            #[allow(clippy::clone_on_copy, noop_method_call)]
498            let __ck_key = cachekit::interop::interop_key(
499                #namespace,
500                #operation,
501                &[#(#interop_args),*],
502            )?;
503
504            // Try cache hit, classified against the stale-while-revalidate
505            // freshness window. A Serialization error means the stored entry
506            // cannot be decoded as #ok_type (poisoned, foreign-shaped, or a
507            // Python-internal CK frame) — treat it as a miss so the fresh
508            // result OVERWRITES it, instead of hard-failing every call until
509            // TTL expiry. Backend errors fail open on the plain path (run
510            // the function uncached) and fail closed on the secure path.
511            // Other errors (namespaced-client Config, decryption) propagate.
512            match #get_swr_expr {
513                Ok(cachekit::SwrRead::Fresh(__ck_cached)) => return Ok(__ck_cached),
514                // Stale (past the freshness threshold, before hard expiry):
515                // serve the cached value immediately and schedule ONE
516                // background refresh. Dedup rides the same single-flight as
517                // the cold-miss path: the first refresh task leads and
518                // re-executes the function; concurrent tasks queue, see the
519                // (still-present) entry, and exit without computing. Refresh
520                // failures are deliberately absorbed — the stale value keeps
521                // being served, a later stale read retries, and hard expiry
522                // falls through to the blocking path where errors surface.
523                // Completion is version-checked, so a newer explicit set or
524                // delete always wins over this older origin computation.
525                Ok(cachekit::SwrRead::Stale(__ck_cached, __ck_swr_token)) => {
526                    let __ck_swr_client = ::std::clone::Clone::clone(#client_ident);
527                    let __ck_swr_key = __ck_key.clone();
528                    #swr_captures
529                    cachekit::__swr_spawn(async move {
530                        let #client_ident = &__ck_swr_client;
531                        let __ck_key = __ck_swr_key;
532                        #swr_rebinds
533                        let _: ::std::result::Result<(), cachekit::error::CachekitError> = async {
534                            let mut __ck_flight = #client_ident.single_flight(&__ck_key).await;
535                            while __ck_flight.wait_for_fill().await {
536                                if matches!(#get_expr, Ok(Some(_))) {
537                                    // Another worker is already on it (the
538                                    // stale entry is still present, or the
539                                    // leader has refreshed) — stand down.
540                                    __ck_flight.release().await;
541                                    return Ok(());
542                                }
543                            }
544                            let __ck_result: #ret_ty = (async #original_body).await;
545                            if let Ok(ref __ck_val) = __ck_result {
546                                // Commit only if no newer set/delete replaced
547                                // the stale entry while the origin ran.
548                                #complete_refresh_expr
549                            }
550                            __ck_flight.release().await;
551                            __ck_result.map(|_| ())
552                        }
553                        .await;
554                    });
555                    return Ok(__ck_cached);
556                }
557                Ok(cachekit::SwrRead::Miss) => {}
558                Err(cachekit::error::CachekitError::Serialization(_)) => {}
559                #fail_open_arm
560                Err(__ck_err) => return Err(__ck_err.into()),
561            }
562
563            // Cold-miss single-flight: collapse concurrent fills of this key
564            // to one execution (misses are billable). While another worker is
565            // filling, re-check the cache instead of recomputing.
566            let mut __ck_flight = #client_ident.single_flight(&__ck_key).await;
567            while __ck_flight.wait_for_fill().await {
568                match #get_expr {
569                    Ok(Some(__ck_cached)) => {
570                        __ck_flight.release().await;
571                        return Ok(__ck_cached);
572                    }
573                    Ok(None) => {}
574                    Err(cachekit::error::CachekitError::Serialization(_)) => {}
575                    #fail_open_arm
576                    Err(__ck_err) => {
577                        __ck_flight.release().await;
578                        return Err(__ck_err.into());
579                    }
580                }
581            }
582
583            // Execute original function body
584            let __ck_result: #ret_ty = (async #original_body).await;
585
586            // Cache on success
587            if let Ok(ref __ck_val) = __ck_result {
588                #set_expr
589            }
590
591            __ck_flight.release().await;
592            __ck_result
593        }
594    };
595
596    *func.block = new_body;
597    Ok(quote! { #func })
598}
599
600#[cfg(test)]
601mod tests {
602    use super::{expand, segment_is_valid, MacroArgs};
603
604    /// A sync fn must fail at decoration time with a clear, actionable error
605    /// — never a cascade of "await is only allowed inside async" diagnostics,
606    /// and never a silent no-op.
607    #[test]
608    fn sync_fn_is_a_clear_decoration_time_error() {
609        let args: MacroArgs = syn::parse_quote!(
610            client = cache,
611            ttl = 60,
612            interop = "get_user",
613            namespace = "users"
614        );
615        let func: syn::ItemFn = syn::parse_quote! {
616            fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
617                Ok(format!("User {id}"))
618            }
619        };
620        let err = expand(&args, func).expect_err("sync fn must be rejected");
621        let msg = err.to_string();
622        assert!(
623            msg.contains("requires an `async fn`"),
624            "error must name the fix, got: {msg}"
625        );
626    }
627
628    /// `&mut` parameters are rejected at decoration time: the SWR refresh
629    /// task rebinds references as immutable borrows of an owned snapshot,
630    /// so mutation through the argument cannot be modelled.
631    #[test]
632    fn mut_ref_param_is_a_clear_decoration_time_error() {
633        let args: MacroArgs = syn::parse_quote!(
634            client = cache,
635            ttl = 60,
636            interop = "get_user",
637            namespace = "users"
638        );
639        let func: syn::ItemFn = syn::parse_quote! {
640            async fn get_user(cache: &CacheKit, id: &mut u64) -> Result<String, CachekitError> {
641                Ok(format!("User {id}"))
642            }
643        };
644        let err = expand(&args, func).expect_err("&mut param must be rejected");
645        assert!(
646            err.to_string().contains("`&mut` parameters"),
647            "error must name the constraint, got: {err}"
648        );
649    }
650
651    /// The same function with `async` added expands cleanly — proving the
652    /// rejection above is precisely about asyncness.
653    #[test]
654    fn async_fn_expands() {
655        let args: MacroArgs = syn::parse_quote!(
656            client = cache,
657            ttl = 60,
658            interop = "get_user",
659            namespace = "users"
660        );
661        let func: syn::ItemFn = syn::parse_quote! {
662            async fn get_user(cache: &CacheKit, id: u64) -> Result<String, CachekitError> {
663                Ok(format!("User {id}"))
664            }
665        };
666        expand(&args, func).expect("async fn must expand");
667    }
668
669    /// Mirror of interop.rs's validate_segment acceptance table — the two
670    /// implementations must not drift (see the NOTE on validate_segment).
671    #[test]
672    fn segment_grammar_table() {
673        for ok in [
674            "a",
675            "0",
676            "users",
677            "users.fetch_by_id",
678            "a-b_c.d",
679            &"a".repeat(64),
680        ] {
681            assert!(segment_is_valid(ok), "{ok:?} should be accepted");
682        }
683        for bad in [
684            "",
685            ".users",
686            "-users",
687            "_users",
688            "Users",
689            "users\n",
690            "users:x",
691            "users/x",
692            "usérs",
693            &"a".repeat(65),
694        ] {
695            assert!(!segment_is_valid(bad), "{bad:?} should be rejected");
696        }
697    }
698
699    /// Mirror of interop.rs's namespace reservation: `ns` / `nsapi` are a
700    /// compile error as `namespace`, but stay valid as the operation.
701    #[test]
702    fn reserved_namespace_is_a_compile_error() {
703        let attr = |interop: &str, namespace: &str| {
704            syn::parse_str::<MacroArgs>(&format!(
705                "client = cache, ttl = 60, interop = {interop:?}, namespace = {namespace:?}"
706            ))
707        };
708        for reserved in ["ns", "nsapi"] {
709            let Err(err) = attr("get_user", reserved) else {
710                panic!("namespace {reserved:?} must be rejected");
711            };
712            assert!(
713                err.to_string().contains("reserved"),
714                "error must name the reservation, got: {err}"
715            );
716            assert!(
717                attr(reserved, "users").is_ok(),
718                "operation {reserved:?} must be accepted"
719            );
720        }
721        assert!(attr("get_user", "nsx").is_ok());
722        assert!(attr("get_user", "nsapi2").is_ok());
723    }
724}