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