use std::collections::BTreeSet;
use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::ext::IdentExt as _;
use syn::parse::Parser as _;
use syn::punctuated::Punctuated;
use syn::visit_mut::VisitMut;
use syn::{Expr, Ident, ItemFn, LitInt, LitStr, Token};
pub struct CachedAttrs {
ttl: Option<String>,
max: Option<usize>,
result: bool,
key: Vec<syn::Ident>,
reads: Vec<syn::Path>,
acknowledge_stale: Option<String>,
pub scope: Option<String>,
}
fn parse_max_value(meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<usize> {
let expr: Expr = meta.value()?.parse()?;
match &expr {
Expr::Lit(lit) => match &lit.lit {
syn::Lit::Int(int) => int.base10_parse::<usize>(),
syn::Lit::Str(s) => s
.value()
.parse::<usize>()
.map_err(|_| syn::Error::new_spanned(s, "max must be a positive integer")),
_ => Err(syn::Error::new_spanned(&expr, "max must be an integer")),
},
_ => Err(syn::Error::new_spanned(
&expr,
"max must be a literal integer",
)),
}
}
pub fn parse_cached_args(attr: TokenStream) -> syn::Result<CachedAttrs> {
let mut result = CachedAttrs {
ttl: None,
max: None,
result: false,
key: Vec::new(),
reads: Vec::new(),
acknowledge_stale: None,
scope: None,
};
if attr.is_empty() {
return Ok(result);
}
syn::meta::parser(|meta| {
if meta.path.is_ident("ttl") {
let value: LitStr = meta.value()?.parse()?;
result.ttl = Some(value.value());
Ok(())
} else if meta.path.is_ident("max") {
result.max = Some(parse_max_value(&meta)?);
Ok(())
} else if meta.path.is_ident("result") {
result.result = true;
Ok(())
} else if meta.path.is_ident("key") {
let content;
syn::parenthesized!(content in meta.input);
let params: Punctuated<Ident, Token![,]> =
content.parse_terminated(Ident::parse_any, Token![,])?;
if params.is_empty() {
return Err(meta.error(
"`key()` must name at least one parameter, e.g. `key(tenant_id)`; omit it \
entirely to key on every parameter",
));
}
result.key.extend(params);
Ok(())
} else if meta.path.is_ident("reads") {
let content;
syn::parenthesized!(content in meta.input);
let models: Punctuated<syn::Path, Token![,]> =
content.parse_terminated(syn::Path::parse_mod_style, Token![,])?;
if models.is_empty() {
return Err(meta.error(
"`reads()` must name at least one model, e.g. `reads(Post)`; omit it \
entirely to let the macro derive the dependency set",
));
}
result.reads.extend(models);
Ok(())
} else if meta.path.is_ident("acknowledge_stale") {
let value: LitStr = meta.value()?.parse()?;
let reason = value.value();
if reason.trim().is_empty() {
return Err(syn::Error::new_spanned(
&value,
"`acknowledge_stale` requires a non-empty reason: it is the only record of \
why this cached read is allowed to serve stale data",
));
}
result.acknowledge_stale = Some(reason);
Ok(())
} else if meta.path.is_ident("scope") {
let value: LitStr = meta.value()?.parse()?;
if result.scope.is_some() {
return Err(meta.error("`scope` is set twice"));
}
let name = value.value();
if !is_type_path(name.trim()) {
return Err(syn::Error::new_spanned(
&value,
"`scope` must be a type name, such as \"Products\" or \"a::Store\"",
));
}
result.scope = Some(name.trim().to_owned());
Ok(())
} else {
Err(meta.error(
"unsupported attribute: expected ttl, max, result, key, reads, \
acknowledge_stale, or scope",
))
}
})
.parse2(attr)?;
Ok(result)
}
const MODEL_READ_VERBS: &[&str] = &["find_all", "find_by_id", "exists_by_id"];
fn model_from_repository_ident(ident: &str) -> Option<String> {
let base = ident.strip_suffix("Repository")?;
let base = base.strip_prefix("Pg").unwrap_or(base);
let mut chars = base.chars();
if !chars.next()?.is_ascii_uppercase() {
return None;
}
Some(base.to_string())
}
fn is_concrete_repository_ident(ident: &str) -> bool {
ident.starts_with("Pg") && model_from_repository_ident(ident).is_some()
}
fn is_model_ident(ident: &str) -> bool {
ident.chars().next().is_some_and(|c| c.is_ascii_uppercase()) && !ident.contains('_')
}
#[derive(Default)]
struct DerivedDependencies {
repositories: Vec<syn::Path>,
models: BTreeSet<String>,
unresolved_repository: bool,
}
#[derive(Default)]
struct DependencyVisitor {
found: DerivedDependencies,
seen_repositories: BTreeSet<String>,
}
impl VisitMut for DependencyVisitor {
fn visit_path_mut(&mut self, path: &mut syn::Path) {
for (i, segment) in path.segments.iter().enumerate() {
let ident = segment.ident.to_string();
if model_from_repository_ident(&ident).is_none() {
continue;
}
if is_concrete_repository_ident(&ident) {
let mut prefix = path.clone();
prefix.segments = path.segments.iter().take(i + 1).cloned().collect();
if let Some(last) = prefix.segments.last_mut()
&& let syn::PathArguments::AngleBracketed(args) = &mut last.arguments
{
args.colon2_token = None;
}
if self.seen_repositories.insert(quote!(#prefix).to_string()) {
self.found.repositories.push(prefix);
}
} else {
self.found.unresolved_repository = true;
}
break;
}
syn::visit_mut::visit_path_mut(self, path);
}
fn visit_expr_path_mut(&mut self, expr: &mut syn::ExprPath) {
if let Some((owner, verb)) = owner_and_verb(expr)
&& is_plain_model_owner(&owner)
&& MODEL_READ_VERBS.contains(&verb.as_str())
{
self.found.models.insert(owner);
}
syn::visit_mut::visit_expr_path_mut(self, expr);
}
}
fn owner_and_verb(expr: &syn::ExprPath) -> Option<(String, String)> {
let verb = expr.path.segments.last()?.ident.to_string();
let owner = if let Some(qself) = &expr.qself {
let syn::Type::Path(ty) = &*qself.ty else {
return None;
};
ty.path.segments.last()?.ident.to_string()
} else {
let n = expr.path.segments.len();
if n < 2 {
return None;
}
expr.path.segments[n - 2].ident.to_string()
};
Some((owner, verb))
}
fn is_plain_model_owner(owner: &str) -> bool {
owner != "Self" && is_model_ident(owner) && model_from_repository_ident(owner).is_none()
}
fn derive_dependencies(func: &ItemFn) -> DerivedDependencies {
let mut visitor = DependencyVisitor::default();
let mut scratch = func.clone();
visitor.visit_signature_mut(&mut scratch.sig);
visitor.visit_block_mut(&mut scratch.block);
visitor.found
}
fn read_id_const_ident(fn_name: &syn::Ident) -> syn::Ident {
format_ident!("__AUTUMN_CACHE_READ_ID__{}", fn_name)
}
fn read_id_expr(fn_name_str: &str, scope: Option<&str>) -> TokenStream {
scope.map_or_else(
|| quote! { concat!(module_path!(), "::", #fn_name_str) },
|scope| quote! { concat!(module_path!(), "::", #scope, "::", #fn_name_str) },
)
}
fn invalidator_ident(fn_name: &syn::Ident) -> syn::Ident {
format_ident!("__autumn_cache_invalidate__{}", fn_name)
}
struct CoherenceItems {
items: TokenStream,
registration: TokenStream,
}
fn generate_coherence_items(
attrs: &CachedAttrs,
input_fn: &ItemFn,
fn_name: &syn::Ident,
fn_name_str: &str,
) -> CoherenceItems {
let vis = &input_fn.vis;
let id_const = read_id_const_ident(fn_name);
let id_expr = read_id_expr(fn_name_str, attrs.scope.as_deref());
let invalidator = invalidator_ident(fn_name);
let (read_exprs, provenance) = if attrs.reads.is_empty() {
let derived = derive_dependencies(input_fn);
let mut exprs: Vec<TokenStream> = derived
.repositories
.iter()
.map(|path| quote! { <#path>::__AUTUMN_MODEL_NAME })
.collect();
exprs.extend(derived.models.iter().map(|m| quote! { || #m }));
let provenance = if derived.unresolved_repository || exprs.is_empty() {
quote! { Undetermined }
} else {
quote! { Derived }
};
(exprs, provenance)
} else {
let exprs: Vec<TokenStream> = attrs
.reads
.iter()
.map(|path| quote! { || ::core::any::type_name::<#path>() })
.collect();
(exprs, quote! { Declared })
};
let acknowledged = attrs.acknowledge_stale.as_ref().map_or_else(
|| quote! { ::core::option::Option::None },
|reason| quote! { ::core::option::Option::Some(#reason) },
);
let registration = quote! {
::autumn_web::reexports::inventory::submit! {
::autumn_web::cache::coherence::CachedReadDescriptor {
id: #id_expr,
kind: ::autumn_web::cache::coherence::ReadKind::Cached,
reads: &[#(#read_exprs),*],
provenance:
::autumn_web::cache::coherence::DependencyProvenance::#provenance,
acknowledged_stale: #acknowledged,
location: concat!(file!(), ":", line!()),
}
}
};
let items = quote! {
#[doc(hidden)]
#[allow(non_upper_case_globals, dead_code)]
#vis const #id_const: &'static ::core::primitive::str = #id_expr;
#[doc(hidden)]
#[allow(non_snake_case, dead_code)]
#vis fn #invalidator() -> ::core::primitive::bool {
::autumn_web::cache::coherence::invalidate_namespace(#id_expr)
}
};
CoherenceItems {
items,
registration,
}
}
fn param_name(pat: &syn::Pat) -> String {
match pat {
syn::Pat::Ident(ident) if ident.subpat.is_none() => ident.ident.to_string(),
other => quote!(#other).to_string(),
}
}
fn key_tuple(attrs: &CachedAttrs, param_names: &[&syn::Pat]) -> syn::Result<TokenStream> {
let key_params: Vec<&syn::Pat> = if attrs.key.is_empty() {
param_names.to_vec()
} else {
let declared: Vec<String> = param_names.iter().map(|pat| param_name(pat)).collect();
let wanted: Vec<String> = attrs.key.iter().map(ToString::to_string).collect();
if let Some((unknown, _)) = attrs
.key
.iter()
.zip(&wanted)
.find(|(_, name)| !declared.contains(*name))
{
return Err(syn::Error::new_spanned(
unknown,
format!(
"`key({unknown})` names no parameter of this function; \
declared parameters are: {}",
declared.join(", ")
),
));
}
param_names
.iter()
.copied()
.filter(|pat| wanted.contains(¶m_name(pat)))
.collect()
};
if key_params.is_empty() {
return Ok(quote! { &() });
}
let key_exprs: Vec<TokenStream> = key_params
.iter()
.map(|pat| match pat {
syn::Pat::Ident(ident) if ident.subpat.is_none() => {
let name = &ident.ident;
quote! { #name }
}
other => quote! { #other },
})
.collect();
Ok(quote! { &(#(#key_exprs.clone(),)*) })
}
fn generate_cache_body(
attrs: &CachedAttrs,
fn_name: &syn::Ident,
fn_block: &syn::Block,
is_async: bool,
key_args: &TokenStream,
ret_type: &TokenStream,
value_type: &TokenStream,
) -> TokenStream {
let ttl_expr = attrs.ttl.as_ref().map_or_else(
|| quote! { None },
|ttl| {
let ttl_str = ttl.clone();
quote! {
Some(
::autumn_web::task::parse_duration(#ttl_str)
.expect(concat!("invalid duration in #[cached(ttl = \"", #ttl_str, "\")]"))
)
}
},
);
let max_expr = attrs.max.map_or_else(
|| quote! { 10_000 },
|max| {
let max_lit = LitInt::new(&max.to_string(), proc_macro2::Span::call_site());
quote! { #max_lit }
},
);
let compute = if is_async {
quote! { (|| async move #fn_block)().await }
} else {
quote! { (|| #fn_block)() }
};
let id_expr = read_id_expr(&fn_name.to_string(), attrs.scope.as_deref());
let cache_init = quote! {
static __AUTUMN_CACHE: ::std::sync::OnceLock<(
::std::sync::Arc<::autumn_web::cache::MokaCache>,
::std::sync::Arc<::std::sync::atomic::AtomicU64>,
)> = ::std::sync::OnceLock::new();
let __autumn_ttl: ::std::option::Option<::std::time::Duration> = #ttl_expr;
let (__autumn_moka, __autumn_epoch_cell) = __AUTUMN_CACHE.get_or_init(|| {
let __autumn_store = ::std::sync::Arc::new(
::autumn_web::cache::MokaCache::new(#max_expr, __autumn_ttl)
);
let __autumn_epoch = ::autumn_web::cache::coherence::register_namespace_store(
#id_expr,
::std::clone::Clone::clone(&__autumn_store) as ::std::sync::Arc<dyn ::autumn_web::cache::Cache>,
);
(__autumn_store, __autumn_epoch)
});
let __autumn_epoch_at_entry =
::std::sync::atomic::AtomicU64::load(
__autumn_epoch_cell,
::std::sync::atomic::Ordering::Acquire,
);
let __autumn_global = ::autumn_web::cache::global_cache();
let __autumn_cache: &dyn ::autumn_web::cache::Cache =
__autumn_global
.as_deref()
.unwrap_or(&**__autumn_moka as &dyn ::autumn_web::cache::Cache);
let __autumn_tenant_key_component: ::core::option::Option<::std::string::String> =
::autumn_web::tenancy::CURRENT_TENANT
.try_with(::std::clone::Clone::clone)
.ok()
.flatten();
let __autumn_namespace: &::core::primitive::str = #id_expr;
let __autumn_key = ::autumn_web::cache::make_cache_key(
#id_expr,
&(__autumn_tenant_key_component, #key_args),
);
};
if attrs.result {
quote! {
#cache_init
if let Some(__autumn_cached) = ::autumn_web::cache::get_cached::<#value_type>(__autumn_cache, &__autumn_key) {
return <#ret_type as ::autumn_web::cache::CacheableResult>::from_ok(__autumn_cached);
}
let __autumn_shared_epoch = ::autumn_web::cache::sample_fill_epoch(__autumn_cache, __autumn_namespace);
let __autumn_result = #compute;
match <#ret_type as ::autumn_web::cache::CacheableResult>::into_result(__autumn_result) {
Ok(__autumn_val) => {
::autumn_web::cache::coherence::with_fill_fence(
__autumn_epoch_cell, __autumn_epoch_at_entry,
|| ::autumn_web::cache::insert_cached_fenced::<#value_type>(__autumn_cache, &__autumn_key, __autumn_val.clone(), __autumn_ttl, __autumn_namespace, __autumn_shared_epoch),
);
<#ret_type as ::autumn_web::cache::CacheableResult>::from_ok(__autumn_val)
}
Err(__autumn_err) => Err(__autumn_err),
}
}
} else {
quote! {
#cache_init
if let Some(__autumn_cached) = ::autumn_web::cache::get_cached::<#value_type>(__autumn_cache, &__autumn_key) {
return __autumn_cached;
}
let __autumn_shared_epoch = ::autumn_web::cache::sample_fill_epoch(__autumn_cache, __autumn_namespace);
let __autumn_result = #compute;
::autumn_web::cache::coherence::with_fill_fence(
__autumn_epoch_cell, __autumn_epoch_at_entry,
|| ::autumn_web::cache::insert_cached_fenced::<#value_type>(__autumn_cache, &__autumn_key, __autumn_result.clone(), __autumn_ttl, __autumn_namespace, __autumn_shared_epoch),
);
__autumn_result
}
}
}
pub fn cached_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
let attrs = match parse_cached_args(attr) {
Ok(a) => a,
Err(err) => return err.to_compile_error(),
};
let input_fn: ItemFn = match syn::parse2(item) {
Ok(f) => f,
Err(err) => return err.to_compile_error(),
};
let vis = &input_fn.vis;
let sig = &input_fn.sig;
let fn_name = &sig.ident;
let fn_name_str = fn_name.to_string();
let fn_attrs = &input_fn.attrs;
let fn_block = &input_fn.block;
let is_async = sig.asyncness.is_some();
let mut param_names = Vec::new();
for arg in &sig.inputs {
match arg {
syn::FnArg::Receiver(_) => {
return syn::Error::new_spanned(
arg,
"#[cached] does not support methods with `self`",
)
.to_compile_error();
}
syn::FnArg::Typed(pat_type) => {
param_names.push(&*pat_type.pat);
}
}
}
let key_args = match key_tuple(&attrs, ¶m_names) {
Ok(args) => args,
Err(err) => return err.to_compile_error(),
};
let ret_type = match &sig.output {
syn::ReturnType::Default => quote! { () },
syn::ReturnType::Type(_, ty) => quote! { #ty },
};
let value_type = if attrs.result {
quote! { <#ret_type as ::autumn_web::cache::CacheableResult>::Ok }
} else {
ret_type.clone()
};
let body = generate_cache_body(
&attrs,
fn_name,
fn_block,
is_async,
&key_args,
&ret_type,
&value_type,
);
let CoherenceItems {
items: coherence_items,
registration,
} = generate_coherence_items(&attrs, &input_fn, fn_name, &fn_name_str);
quote! {
#(#fn_attrs)*
#vis #sig {
#registration
#body
}
#coherence_items
}
}
fn is_type_path(text: &str) -> bool {
text.split("::").all(|segment| {
matches!(segment, "crate" | "self" | "super") || syn::parse_str::<Ident>(segment).is_ok()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_attrs() {
let attrs = parse_cached_args(TokenStream::new()).unwrap();
assert!(attrs.ttl.is_none());
assert!(attrs.max.is_none());
assert!(!attrs.result);
}
#[test]
fn parse_ttl_only() {
let tokens: TokenStream = quote! { ttl = "5m" };
let attrs = parse_cached_args(tokens).unwrap();
assert_eq!(attrs.ttl.as_deref(), Some("5m"));
assert!(attrs.max.is_none());
assert!(!attrs.result);
}
#[test]
fn parse_all_attrs() {
let tokens: TokenStream = quote! { ttl = "1h", max = 100, result };
let attrs = parse_cached_args(tokens).unwrap();
assert_eq!(attrs.ttl.as_deref(), Some("1h"));
assert_eq!(attrs.max, Some(100));
assert!(attrs.result);
}
#[test]
fn parse_max_as_integer() {
let tokens: TokenStream = quote! { max = 500 };
let attrs = parse_cached_args(tokens).unwrap();
assert_eq!(attrs.max, Some(500));
}
#[test]
fn parse_result_flag_only() {
let tokens: TokenStream = quote! { result };
let attrs = parse_cached_args(tokens).unwrap();
assert!(attrs.result);
}
#[test]
fn parse_unknown_attr_errors() {
let tokens: TokenStream = quote! { foo = "bar" };
assert!(parse_cached_args(tokens).is_err());
}
#[test]
fn generated_output_uses_moka() {
let attr: TokenStream = quote! { ttl = "5m" };
let item: TokenStream = quote! {
async fn get_user(id: i64) -> String {
format!("user-{id}")
}
};
let output = cached_macro(attr, item);
let output_str = output.to_string();
assert!(
output_str.contains("MokaCache"),
"should reference MokaCache"
);
assert!(
output_str.contains("make_cache_key"),
"should use make_cache_key"
);
assert!(
output_str.contains("OnceLock"),
"should use OnceLock for static"
);
assert!(
output_str.contains("get_cached"),
"should use get_cached for serde-aware retrieval"
);
assert!(
output_str.contains("insert_cached"),
"should use insert_cached for serde-aware storage"
);
}
#[test]
fn generated_output_result_mode() {
let attr: TokenStream = quote! { result };
let item: TokenStream = quote! {
async fn get_user(id: i64) -> Result<String, Error> {
Ok(format!("user-{id}"))
}
};
let output = cached_macro(attr, item);
let output_str = output.to_string();
assert!(
output_str.contains("CacheableResult"),
"result mode should use CacheableResult trait"
);
}
#[test]
fn no_args_function() {
let attr: TokenStream = quote! {};
let item: TokenStream = quote! {
async fn get_config() -> Vec<String> {
vec!["a".into()]
}
};
let output = cached_macro(attr, item);
let output_str = output.to_string();
assert!(
output_str.contains("MokaCache"),
"should still generate cache"
);
}
#[test]
fn self_receiver_errors() {
let attr: TokenStream = quote! {};
let item: TokenStream = quote! {
async fn get_thing(&self) -> String {
"hi".into()
}
};
let output = cached_macro(attr, item);
let output_str = output.to_string();
assert!(
output_str.contains("compile_error"),
"should produce compile error for self"
);
}
#[test]
fn default_max_capacity() {
let attr: TokenStream = quote! {};
let item: TokenStream = quote! {
fn compute(x: i32) -> i32 { x }
};
let output = cached_macro(attr, item);
let output_str = output.to_string();
assert!(
output_str.contains("10_000"),
"default max should be 10_000"
);
}
#[test]
fn scope_enters_the_read_identity() {
let out = cached_macro(
quote! { scope = "Products" },
quote! { async fn get(id: i64) -> String { String::new() } },
)
.to_string();
assert!(!out.contains("compile_error"), "{out}");
assert!(
out.contains("concat ! (module_path ! () , \"::\" , \"Products\" , \"::\" , \"get\")"),
"{out}"
);
assert!(
!out.contains("concat ! (module_path ! () , \"::\" , \"get\")"),
"the unscoped identity must not remain: {out}"
);
}
#[test]
fn without_scope_the_identity_is_unchanged() {
let out = cached_macro(
TokenStream::new(),
quote! { async fn get(id: i64) -> String { String::new() } },
)
.to_string();
assert!(
out.contains("concat ! (module_path ! () , \"::\" , \"get\")"),
"{out}"
);
}
#[test]
fn a_scope_that_is_not_a_type_name_is_a_compile_error() {
for bad in [" ", "x:", "::a", "Foo<T>", "a b", "1x", "a::"] {
let out = cached_macro(
quote! { scope = #bad },
quote! { async fn get(id: i64) -> String { String::new() } },
)
.to_string();
assert!(out.contains("compile_error"), "{bad}: {out}");
}
}
#[test]
fn a_repeated_scope_is_a_compile_error() {
let out = cached_macro(
quote! { scope = "A", scope = "B" },
quote! { async fn get(id: i64) -> String { String::new() } },
)
.to_string();
assert!(out.contains("compile_error"), "{out}");
}
#[test]
fn scope_combines_with_key_and_reads() {
let out = cached_macro(
quote! { scope = "Products", key(id), reads(Product) },
quote! { async fn get(id: i64, repo: &Repo) -> String { String::new() } },
)
.to_string();
assert!(!out.contains("compile_error"), "{out}");
assert!(out.contains("\"Products\""), "{out}");
}
#[test]
fn a_scope_may_use_path_keywords() {
for scope in ["crate::a::Store", "self::Store", "super::Store"] {
let out = cached_macro(quote! { scope = #scope }, quote! { fn get() {} }).to_string();
assert!(!out.contains("compile_error"), "{scope}: {out}");
}
}
#[test]
fn a_qualified_scope_enters_the_identity() {
let out = cached_macro(quote! { scope = "a::Store" }, quote! { fn get() {} }).to_string();
assert!(!out.contains("compile_error"), "{out}");
assert!(out.contains("\"a::Store\""), "{out}");
}
#[test]
fn parse_key_selects_the_key_parameters() {
let attrs = parse_cached_args(quote! { key(tenant_id, page) }).unwrap();
let names: Vec<String> = attrs.key.iter().map(ToString::to_string).collect();
assert_eq!(names, vec!["tenant_id", "page"]);
}
#[test]
fn parse_empty_key_is_an_error() {
assert!(parse_cached_args(quote! { key() }).is_err());
}
#[test]
fn key_narrows_the_cache_key_to_the_named_parameters() {
let out = cached_macro(
quote! { key(tenant_id), reads(Project) },
quote! {
async fn project_count(tenant_id: String, repo: &PgProjectRepository) -> i64 { 0 }
},
)
.to_string();
assert!(
out.contains("& (tenant_id . clone () ,)"),
"only the named parameter may enter the key: {out}"
);
assert!(
!out.contains("repo . clone ()"),
"a non-Hash handle must never be hashed into the key: {out}"
);
}
#[test]
fn key_naming_an_unknown_parameter_is_a_compile_error() {
let out = cached_macro(
quote! { key(nope) },
quote! { async fn project_count(tenant_id: String) -> i64 { 0 } },
)
.to_string();
assert!(out.contains("compile_error"), "{out}");
assert!(out.contains("names no parameter"), "{out}");
}
#[test]
fn without_key_every_parameter_still_enters_the_cache_key() {
let out = cached_macro(
TokenStream::new(),
quote! { async fn f(a: i64, b: i64) -> i64 { a + b } },
)
.to_string();
assert!(out.contains("& (a . clone () , b . clone () ,)"), "{out}");
}
#[test]
fn key_matches_a_mut_binding_by_its_name() {
let out = cached_macro(
quote! { key(tenant_id) },
quote! { async fn f(mut tenant_id: String, other: i64) -> i64 { 0 } },
)
.to_string();
assert!(!out.contains("compile_error"), "{out}");
assert!(out.contains("& (tenant_id . clone () ,)"), "{out}");
}
#[test]
fn a_destructuring_parameter_still_enters_the_key_as_a_pattern() {
let out = cached_macro(
TokenStream::new(),
quote! { fn f((a, b): (i64, i64)) -> i64 { a + b } },
)
.to_string();
assert!(out.contains("(a , b) . clone ()"), "{out}");
}
#[test]
fn the_expansion_fences_a_fill_against_a_concurrent_invalidation() {
let out =
cached_macro(TokenStream::new(), quote! { async fn recent() -> u8 { 0 } }).to_string();
assert!(out.contains("__autumn_epoch_at_entry"), "{out}");
assert!(
out.contains("with_fill_fence (__autumn_epoch_cell , __autumn_epoch_at_entry ,"),
"the insert must be gated on the fence: {out}"
);
let sample = out.find("__autumn_epoch_at_entry =").expect("sampled");
let lookup = out.find("get_cached").expect("lookup");
assert!(
sample < lookup,
"the epoch must be sampled before the lookup"
);
}
#[test]
fn result_mode_fences_its_insert_too() {
let out = cached_macro(
quote! { result },
quote! { async fn recent() -> Result<u8, Error> { Ok(0) } },
)
.to_string();
assert!(out.contains("with_fill_fence"), "{out}");
}
#[test]
fn the_insert_happens_inside_the_fence_not_merely_after_a_check() {
for (attr, item) in [
(TokenStream::new(), quote! { async fn recent() -> u8 { 0 } }),
(
quote! { result },
quote! { async fn recent() -> Result<u8, Error> { Ok(0) } },
),
] {
let out = cached_macro(attr, item).to_string();
assert!(
!out.contains("if :: autumn_web :: cache :: coherence :: fill_is_still_current"),
"the check must not merely precede the insert: {out}"
);
let fence = out.find("with_fill_fence").expect("fenced");
let insert = out.find("insert_cached").expect("inserts");
assert!(
fence < insert,
"insert_cached must sit inside the with_fill_fence call: {out}"
);
assert_eq!(
out.matches("insert_cached").count(),
out.matches("with_fill_fence").count(),
"every insert must be fenced: {out}"
);
}
}
#[test]
fn the_insert_is_fenced_by_the_shared_epoch_too() {
for (attr, item) in [
(TokenStream::new(), quote! { async fn recent() -> u8 { 0 } }),
(
quote! { result },
quote! { async fn recent() -> Result<u8, Error> { Ok(0) } },
),
] {
let out = cached_macro(attr, item).to_string();
assert!(out.contains("insert_cached_fenced"), "{out}");
assert!(!out.contains("insert_cached ::"), "unfenced insert: {out}");
let miss = out.find("get_cached").expect("looks up");
let sample = out
.find("sample_fill_epoch")
.expect("samples the shared epoch");
let compute = out.find("__autumn_result =").expect("computes");
assert!(miss < sample && sample < compute, "wrong order: {out}");
}
}
#[test]
fn parse_reads_declares_the_dependency_set() {
let tokens: TokenStream = quote! { reads(Post, Comment) };
let attrs = parse_cached_args(tokens).unwrap();
let names: Vec<String> = attrs
.reads
.iter()
.map(|p| p.segments.last().unwrap().ident.to_string())
.collect();
assert_eq!(names, vec!["Post", "Comment"]);
}
#[test]
fn parse_reads_accepts_qualified_paths() {
let tokens: TokenStream = quote! { ttl = "5m", reads(crate::models::Post) };
let attrs = parse_cached_args(tokens).unwrap();
assert_eq!(attrs.reads.len(), 1);
assert_eq!(attrs.ttl.as_deref(), Some("5m"));
}
#[test]
fn parse_empty_reads_is_an_error() {
let tokens: TokenStream = quote! { reads() };
assert!(parse_cached_args(tokens).is_err());
}
#[test]
fn parse_acknowledge_stale_requires_a_nonempty_reason() {
let ok: TokenStream = quote! { acknowledge_stale = "ttl is 2s" };
assert_eq!(
parse_cached_args(ok).unwrap().acknowledge_stale.as_deref(),
Some("ttl is 2s")
);
let empty: TokenStream = quote! { acknowledge_stale = "" };
assert!(parse_cached_args(empty).is_err());
let blank: TokenStream = quote! { acknowledge_stale = " " };
assert!(parse_cached_args(blank).is_err());
}
fn derived_repositories(f: &ItemFn) -> Vec<String> {
derive_dependencies(f)
.repositories
.iter()
.map(|p| quote!(#p).to_string())
.collect()
}
fn derived_models(f: &ItemFn) -> Vec<String> {
derive_dependencies(f).models.into_iter().collect()
}
#[test]
fn derives_dependencies_from_repository_types_in_the_signature() {
let f: ItemFn = syn::parse_quote! {
async fn recent_posts(repo: &PgPostRepository) -> Vec<String> {
repo.find_all().await.unwrap_or_default()
}
};
assert_eq!(derived_repositories(&f), vec!["PgPostRepository"]);
assert_eq!(derived_models(&f), [] as [String; 0]);
assert!(!derive_dependencies(&f).unresolved_repository);
}
#[test]
fn a_repository_whose_name_differs_from_its_model_is_not_guessed_from_the_name() {
let f: ItemFn = syn::parse_quote! {
async fn recent_comments(repo: &PgModerationRepository) -> Vec<String> {
repo.find_all().await.unwrap_or_default()
}
};
let derived = derive_dependencies(&f);
assert_eq!(derived_repositories(&f), vec!["PgModerationRepository"]);
assert!(
!derived.models.contains("Moderation"),
"the name must not become a dependency — the repository states its own model"
);
let out = cached_macro(
TokenStream::new(),
quote! {
async fn recent_comments(repo: &PgModerationRepository) -> Vec<String> {
repo.find_all().await.unwrap_or_default()
}
},
)
.to_string();
assert!(
out.contains("< PgModerationRepository > :: __AUTUMN_MODEL_NAME"),
"{out}"
);
assert!(!out.contains("|| \"Moderation\""), "{out}");
}
#[test]
fn a_repository_reached_only_through_its_trait_is_undetermined() {
let f: ItemFn = syn::parse_quote! {
async fn feed(repo: &impl CommentRepository) -> Vec<String> { Vec::new() }
};
let derived = derive_dependencies(&f);
assert!(derived.repositories.is_empty());
assert!(derived.models.is_empty());
assert!(derived.unresolved_repository);
let out = cached_macro(
TokenStream::new(),
quote! {
async fn feed(repo: &impl CommentRepository) -> Vec<String> { Vec::new() }
},
)
.to_string();
assert!(
out.contains("DependencyProvenance :: Undetermined"),
"{out}"
);
assert!(!out.contains("|| \"Comment\""), "{out}");
}
#[test]
fn derives_dependencies_from_model_finder_calls_in_the_body() {
let f: ItemFn = syn::parse_quote! {
async fn recent(db: &mut Db) -> Vec<String> {
let rows = Post::find_all(db).await;
Vec::new()
}
};
assert_eq!(derived_models(&f), vec!["Post".to_string()]);
assert_eq!(derived_repositories(&f), [] as [String; 0]);
}
#[test]
fn a_model_specific_verb_still_names_the_model() {
for verb in ["find_all", "find_by_id", "exists_by_id"] {
let verb = format_ident!("{verb}");
let f: ItemFn = syn::parse_quote! {
async fn recent(db: &mut Db) -> u8 {
let _ = Post::#verb(db);
0
}
};
assert_eq!(derived_models(&f), vec!["Post".to_string()], "{verb}");
}
}
#[test]
fn a_common_verb_names_no_model() {
for verb in ["load", "count", "all", "list", "page", "first"] {
let verb = format_ident!("{verb}");
let f: ItemFn = syn::parse_quote! {
async fn dashboard() -> Data { Config::#verb() }
};
assert!(derived_models(&f).is_empty(), "{verb}");
}
}
#[test]
fn a_model_read_is_found_in_every_call_shape() {
let shapes: [ItemFn; 3] = [
syn::parse_quote! { fn f() { let _ = Some(1).map(Post::find_all); } },
syn::parse_quote! { fn f(db: u8) { let _ = <Post>::find_all(db); } },
syn::parse_quote! { fn f(db: u8) { let _ = crate::Post::<T>::find_all(db); } },
];
for f in &shapes {
assert_eq!(
derived_models(f),
vec!["Post".to_string()],
"{}",
quote!(#f)
);
}
}
#[test]
fn a_model_read_beside_an_unrelated_call_stays_derived() {
let f: ItemFn = syn::parse_quote! {
async fn dashboard(db: u8) -> Data {
let _ = Post::find_all(db);
helper(&Config::load()).await
}
};
let out = cached_macro(TokenStream::new(), quote! { #f }).to_string();
assert!(out.contains("DependencyProvenance :: Derived"), "{out}");
assert!(out.contains("\"Post\""), "{out}");
assert!(!out.contains("\"Config\""), "{out}");
}
#[test]
fn generated_output_marks_an_unrelated_call_undetermined() {
let f: ItemFn = syn::parse_quote! {
async fn dashboard() -> Data {
let cfg = Config::load();
posts_via_helper(&cfg).await
}
};
let out = cached_macro(TokenStream::new(), quote! { #f }).to_string();
assert!(
out.contains("DependencyProvenance :: Undetermined"),
"{out}"
);
assert!(!out.contains("\"Config\""), "{out}");
}
#[test]
fn self_is_never_a_model() {
let f: ItemFn = syn::parse_quote! {
fn f(db: &mut Db) { let _ = Self::find_all(db); }
};
assert!(derived_models(&f).is_empty());
}
#[test]
fn a_declared_read_ignores_an_unrelated_call() {
let f: ItemFn = syn::parse_quote! {
async fn f() -> u8 { let _ = Config::load(); 0 }
};
let out = cached_macro(quote! { reads(Post) }, quote! { #f }).to_string();
assert!(out.contains("Declared"), "{out}");
}
#[test]
fn derivation_is_sorted_and_deduplicated() {
let f: ItemFn = syn::parse_quote! {
async fn feed(posts: &PgPostRepository, comments: &PgCommentRepository) -> u8 {
let _ = Post::find_all(posts);
0
}
};
assert_eq!(
derived_repositories(&f),
vec!["PgPostRepository", "PgCommentRepository"]
);
assert_eq!(derived_models(&f), vec!["Post".to_string()]);
}
#[test]
fn a_repository_mentioned_twice_is_recorded_once() {
let f: ItemFn = syn::parse_quote! {
async fn feed(a: &PgPostRepository, b: &PgPostRepository) -> u8 { 0 }
};
assert_eq!(derived_repositories(&f), vec!["PgPostRepository"]);
}
#[test]
fn a_qualified_repository_path_keeps_only_the_type_segments() {
let f: ItemFn = syn::parse_quote! {
async fn feed(db: &mut Db) -> u8 {
let _ = crate::repositories::PgPostRepository::find_all(db);
0
}
};
assert_eq!(
derived_repositories(&f),
vec!["crate :: repositories :: PgPostRepository"]
);
}
#[test]
fn derivation_finds_nothing_in_a_pure_function() {
let f: ItemFn = syn::parse_quote! {
fn double(x: i32) -> i32 { x * 2 }
};
let derived = derive_dependencies(&f);
assert!(derived.repositories.is_empty());
assert!(derived.models.is_empty());
assert!(!derived.unresolved_repository);
}
#[test]
fn derivation_ignores_non_repository_camel_case_types() {
let f: ItemFn = syn::parse_quote! {
fn build(cfg: &HashMap<String, String>) -> String { String::new() }
};
let derived = derive_dependencies(&f);
assert!(derived.repositories.is_empty());
assert!(derived.models.is_empty());
assert!(!derived.unresolved_repository);
}
#[test]
fn generated_output_registers_a_declared_cached_read() {
let attr: TokenStream = quote! { reads(Post) };
let item: TokenStream = quote! {
async fn recent_posts() -> Vec<String> { Vec::new() }
};
let out = cached_macro(attr, item).to_string();
assert!(out.contains("CachedReadDescriptor"), "{out}");
assert!(out.contains("Declared"), "{out}");
assert!(out.contains("type_name"), "{out}");
assert!(
out.contains("__AUTUMN_CACHE_READ_ID__recent_posts"),
"must emit the compiler-checkable id constant: {out}"
);
assert!(
out.contains("__autumn_cache_invalidate__recent_posts"),
"must emit the callable invalidator: {out}"
);
}
#[test]
fn generated_output_marks_underivable_reads_undetermined() {
let out = cached_macro(
TokenStream::new(),
quote! { fn double(x: i32) -> i32 { x * 2 } },
)
.to_string();
assert!(out.contains("Undetermined"), "{out}");
}
#[test]
fn generated_output_marks_body_derived_reads_derived() {
let out = cached_macro(
TokenStream::new(),
quote! {
async fn recent(repo: &PgPostRepository) -> u8 { 0 }
},
)
.to_string();
assert!(out.contains("Derived"), "{out}");
assert!(
out.contains("< PgPostRepository > :: __AUTUMN_MODEL_NAME"),
"{out}"
);
}
#[test]
fn generated_output_carries_the_acknowledged_stale_reason() {
let out = cached_macro(
quote! { reads(Post), acknowledge_stale = "5s ttl is tight enough" },
quote! { async fn recent() -> u8 { 0 } },
)
.to_string();
assert!(out.contains("5s ttl is tight enough"), "{out}");
}
#[test]
fn every_use_of_the_identity_is_the_same_expression() {
let out =
cached_macro(TokenStream::new(), quote! { async fn recent() -> u8 { 0 } }).to_string();
let id = "concat ! (module_path ! () , \"::\" , \"recent\")";
assert_eq!(
out.matches(id).count(),
6,
"key, namespace registration, fence namespace, descriptor, id constant, invalidator: {out}"
);
assert!(
out.contains(&format!("make_cache_key ({id}")),
"the cache key must be prefixed with the identity: {out}"
);
assert!(out.contains(&format!("id : {id}")), "{out}");
assert!(
out.contains(&format!("invalidate_namespace ({id})")),
"{out}"
);
assert!(
out.contains(&format!(
"const __AUTUMN_CACHE_READ_ID__recent : & 'static :: core :: primitive :: str = {id}"
)),
"{out}"
);
}
#[test]
fn the_expansion_survives_a_shadowed_str_or_bool() {
let out =
cached_macro(TokenStream::new(), quote! { async fn recent() -> u8 { 0 } }).to_string();
assert!(out.contains(":: core :: primitive :: str"), "{out}");
assert!(out.contains(":: core :: primitive :: bool"), "{out}");
}
}