mod args;
mod collector;
mod file_utils;
mod metadata;
mod method;
mod openapi_generator;
mod parser;
mod route;
mod schema_macro;
use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::quote;
use std::path::Path;
use std::sync::{LazyLock, Mutex};
use syn::LitStr;
use syn::bracketed;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use crate::collector::collect_metadata;
use crate::metadata::{CollectedMetadata, StructMetadata};
use crate::method::http_method_to_token_stream;
use crate::openapi_generator::generate_openapi_doc_with_metadata;
use vespera_core::openapi::Server;
use vespera_core::route::HttpMethod;
/// Validate route function - must be pub and async
fn validate_route_fn(item_fn: &syn::ItemFn) -> Result<(), syn::Error> {
if !matches!(item_fn.vis, syn::Visibility::Public(_)) {
return Err(syn::Error::new_spanned(
item_fn.sig.fn_token,
"route function must be public",
));
}
if item_fn.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
item_fn.sig.fn_token,
"route function must be async",
));
}
Ok(())
}
/// Process route attribute - extracted for testability
fn process_route_attribute(
attr: proc_macro2::TokenStream,
item: proc_macro2::TokenStream,
) -> syn::Result<proc_macro2::TokenStream> {
syn::parse2::<args::RouteArgs>(attr)?;
let item_fn: syn::ItemFn = syn::parse2(item.clone()).map_err(|e| {
syn::Error::new(e.span(), "route attribute can only be applied to functions")
})?;
validate_route_fn(&item_fn)?;
Ok(item)
}
/// route attribute macro
#[cfg(not(tarpaulin_include))]
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
match process_route_attribute(attr.into(), item.into()) {
Ok(tokens) => tokens.into(),
Err(e) => e.to_compile_error().into(),
}
}
#[cfg(not(tarpaulin_include))]
fn init_schema_storage() -> Mutex<Vec<StructMetadata>> {
Mutex::new(Vec::new())
}
static SCHEMA_STORAGE: LazyLock<Mutex<Vec<StructMetadata>>> = LazyLock::new(init_schema_storage);
/// Extract custom schema name from #[schema(name = "...")] attribute
fn extract_schema_name_attr(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("schema") {
let mut custom_name = None;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("name") {
let value = meta.value()?;
let lit: syn::LitStr = value.parse()?;
custom_name = Some(lit.value());
}
Ok(())
});
if custom_name.is_some() {
return custom_name;
}
}
}
None
}
/// Process derive input and return metadata + expanded code
fn process_derive_schema(input: &syn::DeriveInput) -> (StructMetadata, proc_macro2::TokenStream) {
let name = &input.ident;
let generics = &input.generics;
// Check for custom schema name from #[schema(name = "...")] attribute
let schema_name = extract_schema_name_attr(&input.attrs).unwrap_or_else(|| name.to_string());
// Schema-derived types appear in OpenAPI spec (include_in_openapi: true)
let metadata = StructMetadata::new(schema_name, quote::quote!(#input).to_string());
let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
let expanded = quote! {
impl #impl_generics vespera::schema::SchemaBuilder for #name #ty_generics #where_clause {}
};
(metadata, expanded)
}
/// Derive macro for Schema
///
/// Supports `#[schema(name = "CustomName")]` attribute to set custom OpenAPI schema name.
#[cfg(not(tarpaulin_include))]
#[proc_macro_derive(Schema, attributes(schema))]
pub fn derive_schema(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as syn::DeriveInput);
let (metadata, expanded) = process_derive_schema(&input);
SCHEMA_STORAGE.lock().unwrap().push(metadata);
TokenStream::from(expanded)
}
/// Generate an OpenAPI Schema from a type with optional field filtering.
///
/// This macro creates a `vespera::schema::Schema` struct at compile time
/// from a type that has `#[derive(Schema)]`.
///
/// # Syntax
///
/// ```ignore
/// // Full schema (all fields)
/// let user_schema = schema!(User);
///
/// // Schema with fields omitted
/// let response_schema = schema!(User, omit = ["password", "internal_id"]);
///
/// // Schema with only specified fields (pick)
/// let summary_schema = schema!(User, pick = ["id", "name"]);
/// ```
///
/// # Parameters
///
/// - `Type`: The type to generate schema for (must have `#[derive(Schema)]`)
/// - `omit = [...]`: Optional list of field names to exclude from the schema
/// - `pick = [...]`: Optional list of field names to include (excludes all others)
///
/// Note: `omit` and `pick` cannot be used together.
///
/// # Example
///
/// ```ignore
/// use vespera::{Schema, schema};
///
/// #[derive(Schema)]
/// struct User {
/// pub id: i32,
/// pub name: String,
/// pub email: String,
/// pub password: String, // sensitive!
/// }
///
/// // For API responses, omit password
/// let response_schema = schema!(User, omit = ["password"]);
///
/// // For list endpoints, only return summary fields
/// let list_schema = schema!(User, pick = ["id", "name"]);
/// ```
#[cfg(not(tarpaulin_include))]
#[proc_macro]
pub fn schema(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as schema_macro::SchemaInput);
// Get stored schemas
let storage = SCHEMA_STORAGE.lock().unwrap();
match schema_macro::generate_schema_code(&input, &storage) {
Ok(tokens) => TokenStream::from(tokens),
Err(e) => e.to_compile_error().into(),
}
}
/// Generate a new struct type derived from an existing type with field filtering.
///
/// This macro creates a new struct at compile time by picking or omitting fields
/// from an existing type that has `#[derive(Schema)]`.
///
/// # Syntax
///
/// ```ignore
/// // Pick specific fields
/// schema_type!(CreateUserRequest from User, pick = ["name", "email"]);
///
/// // Omit specific fields
/// schema_type!(UserResponse from User, omit = ["password", "internal_id"]);
///
/// // Without Clone derive
/// schema_type!(UserUpdate from User, pick = ["name"], clone = false);
/// ```
///
/// # Parameters
///
/// - `NewTypeName`: The name of the new struct to generate
/// - `from SourceType`: The source type to derive from (must have `#[derive(Schema)]`)
/// - `pick = [...]`: List of field names to include (excludes all others)
/// - `omit = [...]`: List of field names to exclude
/// - `clone = bool`: Whether to derive Clone (default: true)
/// - `partial`: Make all fields `Option<T>` (fields already `Option<T>` are unchanged)
/// - `partial = [...]`: Make only listed fields `Option<T>`
///
/// Note: `omit` and `pick` cannot be used together.
///
/// # Example
///
/// ```ignore
/// use vespera::{Schema, schema_type};
///
/// #[derive(Schema)]
/// pub struct User {
/// pub id: i32,
/// pub name: String,
/// pub email: String,
/// pub password: String,
/// }
///
/// // Generate CreateUserRequest with only name and email
/// schema_type!(CreateUserRequest from User, pick = ["name", "email"]);
///
/// // Generate UserPublic without password
/// schema_type!(UserPublic from User, omit = ["password"]);
///
/// // Now use in handlers:
/// pub async fn create_user(Json(req): Json<CreateUserRequest>) -> Json<UserPublic> {
/// // ...
/// }
/// ```
#[cfg(not(tarpaulin_include))]
#[proc_macro]
pub fn schema_type(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as schema_macro::SchemaTypeInput);
// Get stored schemas
let mut storage = SCHEMA_STORAGE.lock().unwrap();
match schema_macro::generate_schema_type_code(&input, &storage) {
Ok((tokens, generated_metadata)) => {
// If custom name is provided, register the schema directly
// This ensures it appears in OpenAPI even when `ignore` is set
if let Some(metadata) = generated_metadata {
storage.push(metadata);
}
TokenStream::from(tokens)
}
Err(e) => e.to_compile_error().into(),
}
}
/// Server configuration for OpenAPI
#[derive(Clone)]
struct ServerConfig {
url: String,
description: Option<String>,
}
struct AutoRouterInput {
dir: Option<LitStr>,
openapi: Option<Vec<LitStr>>,
title: Option<LitStr>,
version: Option<LitStr>,
docs_url: Option<LitStr>,
redoc_url: Option<LitStr>,
servers: Option<Vec<ServerConfig>>,
/// Apps to merge (e.g., [third::ThirdApp, another::AnotherApp])
merge: Option<Vec<syn::Path>>,
}
impl Parse for AutoRouterInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let mut dir = None;
let mut openapi = None;
let mut title = None;
let mut version = None;
let mut docs_url = None;
let mut redoc_url = None;
let mut servers = None;
let mut merge = None;
while !input.is_empty() {
let lookahead = input.lookahead1();
if lookahead.peek(syn::Ident) {
let ident: syn::Ident = input.parse()?;
let ident_str = ident.to_string();
match ident_str.as_str() {
"dir" => {
input.parse::<syn::Token![=]>()?;
dir = Some(input.parse()?);
}
"openapi" => {
openapi = Some(parse_openapi_values(input)?);
}
"docs_url" => {
input.parse::<syn::Token![=]>()?;
docs_url = Some(input.parse()?);
}
"redoc_url" => {
input.parse::<syn::Token![=]>()?;
redoc_url = Some(input.parse()?);
}
"title" => {
input.parse::<syn::Token![=]>()?;
title = Some(input.parse()?);
}
"version" => {
input.parse::<syn::Token![=]>()?;
version = Some(input.parse()?);
}
"servers" => {
servers = Some(parse_servers_values(input)?);
}
"merge" => {
merge = Some(parse_merge_values(input)?);
}
_ => {
return Err(syn::Error::new(
ident.span(),
format!(
"unknown field: `{}`. Expected `dir`, `openapi`, `title`, `version`, `docs_url`, `redoc_url`, `servers`, or `merge`",
ident_str
),
));
}
}
} else if lookahead.peek(syn::LitStr) {
// If just a string, treat it as dir (for backward compatibility)
dir = Some(input.parse()?);
} else {
return Err(lookahead.error());
}
if input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
} else {
break;
}
}
Ok(AutoRouterInput {
dir: dir.or_else(|| {
std::env::var("VESPERA_DIR")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
openapi: openapi.or_else(|| {
std::env::var("VESPERA_OPENAPI")
.map(|f| vec![LitStr::new(&f, Span::call_site())])
.ok()
}),
title: title.or_else(|| {
std::env::var("VESPERA_TITLE")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
version: version
.or_else(|| {
std::env::var("VESPERA_VERSION")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
})
.or_else(|| {
std::env::var("CARGO_PKG_VERSION")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
docs_url: docs_url.or_else(|| {
std::env::var("VESPERA_DOCS_URL")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
redoc_url: redoc_url.or_else(|| {
std::env::var("VESPERA_REDOC_URL")
.map(|f| LitStr::new(&f, Span::call_site()))
.ok()
}),
servers: servers.or_else(|| {
std::env::var("VESPERA_SERVER_URL")
.ok()
.filter(|url| url.starts_with("http://") || url.starts_with("https://"))
.map(|url| {
vec![ServerConfig {
url,
description: std::env::var("VESPERA_SERVER_DESCRIPTION").ok(),
}]
})
}),
merge,
})
}
}
/// Parse merge values: merge = [path::to::App, another::App]
fn parse_merge_values(input: ParseStream) -> syn::Result<Vec<syn::Path>> {
input.parse::<syn::Token![=]>()?;
let content;
let _ = bracketed!(content in input);
let paths: Punctuated<syn::Path, syn::Token![,]> =
content.parse_terminated(syn::Path::parse, syn::Token![,])?;
Ok(paths.into_iter().collect())
}
fn parse_openapi_values(input: ParseStream) -> syn::Result<Vec<LitStr>> {
input.parse::<syn::Token![=]>()?;
if input.peek(syn::token::Bracket) {
let content;
let _ = bracketed!(content in input);
let entries: Punctuated<LitStr, syn::Token![,]> =
content.parse_terminated(|input| input.parse::<LitStr>(), syn::Token![,])?;
Ok(entries.into_iter().collect())
} else {
let single: LitStr = input.parse()?;
Ok(vec![single])
}
}
/// Validate that a URL starts with http:// or https://
fn validate_server_url(url: &LitStr) -> syn::Result<String> {
let url_value = url.value();
if !url_value.starts_with("http://") && !url_value.starts_with("https://") {
return Err(syn::Error::new(
url.span(),
format!(
"invalid server URL: `{}`. URL must start with `http://` or `https://`",
url_value
),
));
}
Ok(url_value)
}
/// Parse server values in various formats:
/// - `servers = "url"` - single URL
/// - `servers = ["url1", "url2"]` - multiple URLs (strings only)
/// - `servers = [("url", "description")]` - tuple format with descriptions
/// - `servers = [{url = "...", description = "..."}]` - struct-like format
/// - `servers = {url = "...", description = "..."}` - single server struct-like format
fn parse_servers_values(input: ParseStream) -> syn::Result<Vec<ServerConfig>> {
use syn::token::{Brace, Paren};
input.parse::<syn::Token![=]>()?;
if input.peek(syn::token::Bracket) {
// Array format: [...]
let content;
let _ = bracketed!(content in input);
let mut servers = Vec::new();
while !content.is_empty() {
if content.peek(Paren) {
// Parse tuple: ("url", "description")
let tuple_content;
syn::parenthesized!(tuple_content in content);
let url: LitStr = tuple_content.parse()?;
let url_value = validate_server_url(&url)?;
let description = if tuple_content.peek(syn::Token![,]) {
tuple_content.parse::<syn::Token![,]>()?;
Some(tuple_content.parse::<LitStr>()?.value())
} else {
None
};
servers.push(ServerConfig {
url: url_value,
description,
});
} else if content.peek(Brace) {
// Parse struct-like: {url = "...", description = "..."}
let server = parse_server_struct(&content)?;
servers.push(server);
} else {
// Parse simple string: "url"
let url: LitStr = content.parse()?;
let url_value = validate_server_url(&url)?;
servers.push(ServerConfig {
url: url_value,
description: None,
});
}
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
} else {
break;
}
}
Ok(servers)
} else if input.peek(syn::token::Brace) {
// Single struct-like format: servers = {url = "...", description = "..."}
let server = parse_server_struct(input)?;
Ok(vec![server])
} else {
// Single string: servers = "url"
let single: LitStr = input.parse()?;
let url_value = validate_server_url(&single)?;
Ok(vec![ServerConfig {
url: url_value,
description: None,
}])
}
}
/// Parse a single server in struct-like format: {url = "...", description = "..."}
fn parse_server_struct(input: ParseStream) -> syn::Result<ServerConfig> {
let content;
syn::braced!(content in input);
let mut url: Option<String> = None;
let mut description: Option<String> = None;
while !content.is_empty() {
let ident: syn::Ident = content.parse()?;
let ident_str = ident.to_string();
match ident_str.as_str() {
"url" => {
content.parse::<syn::Token![=]>()?;
let url_lit: LitStr = content.parse()?;
url = Some(validate_server_url(&url_lit)?);
}
"description" => {
content.parse::<syn::Token![=]>()?;
description = Some(content.parse::<LitStr>()?.value());
}
_ => {
return Err(syn::Error::new(
ident.span(),
format!(
"unknown field: `{}`. Expected `url` or `description`",
ident_str
),
));
}
}
if content.peek(syn::Token![,]) {
content.parse::<syn::Token![,]>()?;
} else {
break;
}
}
let url = url.ok_or_else(|| {
syn::Error::new(
proc_macro2::Span::call_site(),
"server config requires `url` field",
)
})?;
Ok(ServerConfig { url, description })
}
/// Docs info tuple type alias for cleaner signatures
type DocsInfo = (Option<(String, String)>, Option<(String, String)>);
/// Processed vespera input with extracted values
struct ProcessedVesperaInput {
folder_name: String,
openapi_file_names: Vec<String>,
title: Option<String>,
version: Option<String>,
docs_url: Option<String>,
redoc_url: Option<String>,
servers: Option<Vec<Server>>,
/// Apps to merge (syn::Path for code generation)
merge: Vec<syn::Path>,
}
/// Process AutoRouterInput into extracted values
fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput {
ProcessedVesperaInput {
folder_name: input
.dir
.map(|f| f.value())
.unwrap_or_else(|| "routes".to_string()),
openapi_file_names: input
.openapi
.unwrap_or_default()
.into_iter()
.map(|f| f.value())
.collect(),
title: input.title.map(|t| t.value()),
version: input.version.map(|v| v.value()),
docs_url: input.docs_url.map(|u| u.value()),
redoc_url: input.redoc_url.map(|u| u.value()),
servers: input.servers.map(|svrs| {
svrs.into_iter()
.map(|s| Server {
url: s.url,
description: s.description,
variables: None,
})
.collect()
}),
merge: input.merge.unwrap_or_default(),
}
}
/// Generate OpenAPI JSON and write to files, returning docs info
fn generate_and_write_openapi(
input: &ProcessedVesperaInput,
metadata: &CollectedMetadata,
) -> Result<DocsInfo, String> {
if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none()
{
return Ok((None, None));
}
let mut openapi_doc = generate_openapi_doc_with_metadata(
input.title.clone(),
input.version.clone(),
input.servers.clone(),
metadata,
);
// Merge specs from child apps at compile time
if !input.merge.is_empty()
&& let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR")
{
let manifest_path = Path::new(&manifest_dir);
let target_dir = find_target_dir(manifest_path);
let vespera_dir = target_dir.join("vespera");
for merge_path in &input.merge {
// Extract the struct name (last segment, e.g., "ThirdApp" from "third::ThirdApp")
if let Some(last_segment) = merge_path.segments.last() {
let struct_name = last_segment.ident.to_string();
let spec_file = vespera_dir.join(format!("{}.openapi.json", struct_name));
if let Ok(spec_content) = std::fs::read_to_string(&spec_file)
&& let Ok(child_spec) =
serde_json::from_str::<vespera_core::openapi::OpenApi>(&spec_content)
{
openapi_doc.merge(child_spec);
}
}
}
}
let json_str = serde_json::to_string_pretty(&openapi_doc)
.map_err(|e| format!("Failed to serialize OpenAPI document: {}", e))?;
for openapi_file_name in &input.openapi_file_names {
let file_path = Path::new(openapi_file_name);
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
std::fs::write(file_path, &json_str).map_err(|e| {
format!(
"Failed to write OpenAPI document to {}: {}",
openapi_file_name, e
)
})?;
}
let docs_info = input
.docs_url
.as_ref()
.map(|url| (url.clone(), json_str.clone()));
let redoc_info = input.redoc_url.as_ref().map(|url| (url.clone(), json_str));
Ok((docs_info, redoc_info))
}
/// Process vespera macro - extracted for testability
fn process_vespera_macro(
processed: &ProcessedVesperaInput,
schema_storage: &[StructMetadata],
) -> syn::Result<proc_macro2::TokenStream> {
let folder_path = find_folder_path(&processed.folder_name);
if !folder_path.exists() {
return Err(syn::Error::new(
Span::call_site(),
format!("Folder not found: {}", processed.folder_name),
));
}
let mut metadata = collect_metadata(&folder_path, &processed.folder_name).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!("Failed to collect metadata: {}", e),
)
})?;
metadata.structs.extend(schema_storage.iter().cloned());
let (docs_info, redoc_info) = generate_and_write_openapi(processed, &metadata)
.map_err(|e| syn::Error::new(Span::call_site(), e))?;
Ok(generate_router_code(
&metadata,
docs_info,
redoc_info,
&processed.merge,
))
}
#[cfg(not(tarpaulin_include))]
#[proc_macro]
pub fn vespera(input: TokenStream) -> TokenStream {
let input = syn::parse_macro_input!(input as AutoRouterInput);
let processed = process_vespera_input(input);
let schema_storage = SCHEMA_STORAGE.lock().unwrap();
match process_vespera_macro(&processed, &schema_storage) {
Ok(tokens) => tokens.into(),
Err(e) => e.to_compile_error().into(),
}
}
fn find_folder_path(folder_name: &str) -> std::path::PathBuf {
let root = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let path = format!("{}/src/{}", root, folder_name);
let path = Path::new(&path);
if path.exists() && path.is_dir() {
return path.to_path_buf();
}
Path::new(folder_name).to_path_buf()
}
/// Find the workspace root's target directory
fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf {
// Look for workspace root by finding a Cargo.toml with [workspace] section
let mut current = Some(manifest_path);
let mut last_with_lock = None;
while let Some(dir) = current {
// Check if this directory has Cargo.lock
if dir.join("Cargo.lock").exists() {
last_with_lock = Some(dir.to_path_buf());
}
// Check if this is a workspace root (has Cargo.toml with [workspace])
let cargo_toml = dir.join("Cargo.toml");
if cargo_toml.exists()
&& let Ok(contents) = std::fs::read_to_string(&cargo_toml)
&& contents.contains("[workspace]")
{
return dir.join("target");
}
current = dir.parent();
}
// If we found a Cargo.lock but no [workspace], use the topmost one
if let Some(lock_dir) = last_with_lock {
return lock_dir.join("target");
}
// Fallback: use manifest dir's target
manifest_path.join("target")
}
fn generate_router_code(
metadata: &CollectedMetadata,
docs_info: Option<(String, String)>,
redoc_info: Option<(String, String)>,
merge_apps: &[syn::Path],
) -> proc_macro2::TokenStream {
let mut router_nests = Vec::new();
for route in &metadata.routes {
let http_method = HttpMethod::from(route.method.as_str());
let method_path = http_method_to_token_stream(http_method);
let path = &route.path;
let module_path = &route.module_path;
let function_name = &route.function_name;
let mut p: syn::punctuated::Punctuated<syn::PathSegment, syn::Token![::]> =
syn::punctuated::Punctuated::new();
p.push(syn::PathSegment {
ident: syn::Ident::new("crate", Span::call_site()),
arguments: syn::PathArguments::None,
});
p.extend(
module_path
.split("::")
.filter_map(|s| {
if s.is_empty() {
None
} else {
Some(syn::PathSegment {
ident: syn::Ident::new(s, Span::call_site()),
arguments: syn::PathArguments::None,
})
}
})
.collect::<Vec<syn::PathSegment>>(),
);
let func_name = syn::Ident::new(function_name, Span::call_site());
router_nests.push(quote!(
.route(#path, #method_path(#p::#func_name))
));
}
// Check if we need to merge specs at runtime
let has_merge = !merge_apps.is_empty();
if let Some((docs_url, spec)) = docs_info {
let method_path = http_method_to_token_stream(HttpMethod::Get);
if has_merge {
// Generate code that merges specs at runtime using OnceLock
let merge_spec_code: Vec<_> = merge_apps
.iter()
.map(|app_path| {
quote! {
if let Ok(other) = vespera::serde_json::from_str::<vespera::OpenApi>(#app_path::OPENAPI_SPEC) {
merged.merge(other);
}
}
})
.collect();
router_nests.push(quote!(
.route(#docs_url, #method_path(|| async {
static MERGED_SPEC: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let spec = MERGED_SPEC.get_or_init(|| {
let base_spec = #spec;
let mut merged: vespera::OpenApi = vespera::serde_json::from_str(base_spec).unwrap();
#(#merge_spec_code)*
vespera::serde_json::to_string(&merged).unwrap()
});
let html = format!(
r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Swagger UI</title><link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" /></head><body style="margin: 0; padding: 0;"><div id="swagger-ui"></div><script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script><script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script><script>const openapiSpec = {};window.onload = () => {{ SwaggerUIBundle({{ spec: openapiSpec, dom_id: "\#swagger-ui", presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], layout: "StandaloneLayout" }}); }};</script></body></html>"#,
spec
);
vespera::axum::response::Html(html)
}))
));
} else {
let html = format!(
r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Swagger UI</title><link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" /></head><body style="margin: 0; padding: 0;"><div id="swagger-ui"></div><script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script><script src="https://unpkg.com/swagger-ui-dist/swagger-ui-standalone-preset.js"></script><script>const openapiSpec = {spec_json};window.onload = () => {{ SwaggerUIBundle({{ spec: openapiSpec, dom_id: "\#swagger-ui", presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], layout: "StandaloneLayout" }}); }};</script></body></html>"#,
spec_json = spec
);
router_nests.push(quote!(
.route(#docs_url, #method_path(|| async { vespera::axum::response::Html(#html) }))
));
}
}
if let Some((redoc_url, spec)) = redoc_info {
let method_path = http_method_to_token_stream(HttpMethod::Get);
if has_merge {
// Generate code that merges specs at runtime using OnceLock
let merge_spec_code: Vec<_> = merge_apps
.iter()
.map(|app_path| {
quote! {
if let Ok(other) = vespera::serde_json::from_str::<vespera::OpenApi>(#app_path::OPENAPI_SPEC) {
merged.merge(other);
}
}
})
.collect();
router_nests.push(quote!(
.route(#redoc_url, #method_path(|| async {
static MERGED_SPEC: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let spec = MERGED_SPEC.get_or_init(|| {
let base_spec = #spec;
let mut merged: vespera::OpenApi = vespera::serde_json::from_str(base_spec).unwrap();
#(#merge_spec_code)*
vespera::serde_json::to_string(&merged).unwrap()
});
let html = format!(
r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>ReDoc</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body {{ margin: 0; padding: 0; }}</style><link rel="stylesheet" href="https://unpkg.com/redoc/bundles/redoc.standalone.css" /></head><body><div id="redoc-container"></div><script src="https://unpkg.com/redoc/bundles/redoc.standalone.js"></script><script>const openapiSpec = {};Redoc.init(openapiSpec, {{}}, document.getElementById("redoc-container"));</script></body></html>"#,
spec
);
vespera::axum::response::Html(html)
}))
));
} else {
let html = format!(
r#"<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>ReDoc</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body {{ margin: 0; padding: 0; }}</style><link rel="stylesheet" href="https://unpkg.com/redoc/bundles/redoc.standalone.css" /></head><body><div id="redoc-container"></div><script src="https://unpkg.com/redoc/bundles/redoc.standalone.js"></script><script>const openapiSpec = {spec_json};Redoc.init(openapiSpec, {{}}, document.getElementById("redoc-container"));</script></body></html>"#,
spec_json = spec
);
router_nests.push(quote!(
.route(#redoc_url, #method_path(|| async { vespera::axum::response::Html(#html) }))
));
}
}
if merge_apps.is_empty() {
quote! {
vespera::axum::Router::new()
#( #router_nests )*
}
} else {
// When merging apps, return VesperaRouter which defers the merge
// until with_state() is called. This is necessary because Axum requires
// merged routers to have the same state type.
quote! {
vespera::VesperaRouter::new(
vespera::axum::Router::new()
#( #router_nests )*,
vec![#( #merge_apps::router ),*]
)
}
}
}
/// Input for export_app! macro
struct ExportAppInput {
/// App name (struct name to generate)
name: syn::Ident,
/// Route directory
dir: Option<LitStr>,
}
impl Parse for ExportAppInput {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: syn::Ident = input.parse()?;
let mut dir = None;
// Parse optional comma and arguments
while input.peek(syn::Token![,]) {
input.parse::<syn::Token![,]>()?;
if input.is_empty() {
break;
}
let ident: syn::Ident = input.parse()?;
let ident_str = ident.to_string();
match ident_str.as_str() {
"dir" => {
input.parse::<syn::Token![=]>()?;
dir = Some(input.parse()?);
}
_ => {
return Err(syn::Error::new(
ident.span(),
format!("unknown field: `{}`. Expected `dir`", ident_str),
));
}
}
}
Ok(ExportAppInput { name, dir })
}
}
/// Export a vespera app as a reusable component.
///
/// Generates a struct with:
/// - `OPENAPI_SPEC: &'static str` - The OpenAPI JSON spec
/// - `router() -> Router` - Function returning the Axum router
///
/// # Example
/// ```ignore
/// // Simple - uses "routes" folder by default
/// vespera::export_app!(MyApp);
///
/// // Custom directory
/// vespera::export_app!(MyApp, dir = "api");
///
/// // Generates:
/// // pub struct MyApp;
/// // impl MyApp {
/// // pub const OPENAPI_SPEC: &'static str = "...";
/// // pub fn router() -> axum::Router { ... }
/// // }
/// ```
///
/// Process export_app macro - extracted for testability
fn process_export_app(
name: &syn::Ident,
folder_name: &str,
schema_storage: &[StructMetadata],
manifest_dir: &str,
) -> syn::Result<proc_macro2::TokenStream> {
let folder_path = find_folder_path(folder_name);
if !folder_path.exists() {
return Err(syn::Error::new(
Span::call_site(),
format!("Folder not found: {}", folder_name),
));
}
let mut metadata = collect_metadata(&folder_path, folder_name).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!("Failed to collect metadata: {}", e),
)
})?;
metadata.structs.extend(schema_storage.iter().cloned());
// Generate OpenAPI spec JSON string
let openapi_doc = generate_openapi_doc_with_metadata(None, None, None, &metadata);
let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!("Failed to serialize OpenAPI spec: {}", e),
)
})?;
// Write spec to temp file for compile-time merging by parent apps
let name_str = name.to_string();
let manifest_path = Path::new(manifest_dir);
let target_dir = find_target_dir(manifest_path);
let vespera_dir = target_dir.join("vespera");
std::fs::create_dir_all(&vespera_dir).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!("Failed to create vespera dir {:?}: {}", vespera_dir, e),
)
})?;
let spec_file = vespera_dir.join(format!("{}.openapi.json", name_str));
std::fs::write(&spec_file, &spec_json).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!("Failed to write spec file {:?}: {}", spec_file, e),
)
})?;
// Generate router code (without docs routes, no merge)
let router_code = generate_router_code(&metadata, None, None, &[]);
Ok(quote! {
/// Auto-generated vespera app struct
pub struct #name;
impl #name {
/// OpenAPI specification as JSON string
pub const OPENAPI_SPEC: &'static str = #spec_json;
/// Create the router for this app.
/// Returns `Router<()>` which can be merged into any other router.
pub fn router() -> vespera::axum::Router<()> {
#router_code
}
}
})
}
#[cfg(not(tarpaulin_include))]
#[proc_macro]
pub fn export_app(input: TokenStream) -> TokenStream {
let ExportAppInput { name, dir } = syn::parse_macro_input!(input as ExportAppInput);
let folder_name = dir
.map(|d| d.value())
.or_else(|| std::env::var("VESPERA_DIR").ok())
.unwrap_or_else(|| "routes".to_string());
let schema_storage = SCHEMA_STORAGE.lock().unwrap();
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set");
match process_export_app(&name, &folder_name, &schema_storage, &manifest_dir) {
Ok(tokens) => tokens.into(),
Err(e) => e.to_compile_error().into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::fs;
use tempfile::TempDir;
fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf {
let file_path = dir.path().join(filename);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).expect("Failed to create parent directory");
}
fs::write(&file_path, content).expect("Failed to write temp file");
file_path
}
#[test]
fn test_generate_router_code_empty() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name).unwrap(),
None,
None,
&[],
);
let code = result.to_string();
// Should generate empty router
// quote! generates "vespera :: axum :: Router :: new ()" format
assert!(
code.contains("Router") && code.contains("new"),
"Code should contain Router::new(), got: {}",
code
);
assert!(
!code.contains("route"),
"Code should not contain route, got: {}",
code
);
drop(temp_dir);
}
#[rstest]
#[case::single_get_route(
"routes",
vec![(
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/users",
"routes::users::get_users",
)]
#[case::single_post_route(
"routes",
vec![(
"create_user.rs",
r#"
#[route(post)]
pub fn create_user() -> String {
"created".to_string()
}
"#,
)],
"post",
"/create-user",
"routes::create_user::create_user",
)]
#[case::single_put_route(
"routes",
vec![(
"update_user.rs",
r#"
#[route(put)]
pub fn update_user() -> String {
"updated".to_string()
}
"#,
)],
"put",
"/update-user",
"routes::update_user::update_user",
)]
#[case::single_delete_route(
"routes",
vec![(
"delete_user.rs",
r#"
#[route(delete)]
pub fn delete_user() -> String {
"deleted".to_string()
}
"#,
)],
"delete",
"/delete-user",
"routes::delete_user::delete_user",
)]
#[case::single_patch_route(
"routes",
vec![(
"patch_user.rs",
r#"
#[route(patch)]
pub fn patch_user() -> String {
"patched".to_string()
}
"#,
)],
"patch",
"/patch-user",
"routes::patch_user::patch_user",
)]
#[case::route_with_custom_path(
"routes",
vec![(
"users.rs",
r#"
#[route(get, path = "/api/users")]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/users/api/users",
"routes::users::get_users",
)]
#[case::nested_module(
"routes",
vec![(
"api/users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/api/users",
"routes::api::users::get_users",
)]
#[case::deeply_nested_module(
"routes",
vec![(
"api/v1/users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
)],
"get",
"/api/v1/users",
"routes::api::v1::users::get_users",
)]
fn test_generate_router_code_single_route(
#[case] folder_name: &str,
#[case] files: Vec<(&str, &str)>,
#[case] expected_method: &str,
#[case] expected_path: &str,
#[case] expected_function_path: &str,
) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
for (filename, content) in files {
create_temp_file(&temp_dir, filename, content);
}
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name).unwrap(),
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(
code.contains("Router") && code.contains("new"),
"Code should contain Router::new(), got: {}",
code
);
// Check route method
assert!(
code.contains(expected_method),
"Code should contain method: {}, got: {}",
expected_method,
code
);
// Check route path
assert!(
code.contains(expected_path),
"Code should contain path: {}, got: {}",
expected_path,
code
);
// Check function path (quote! adds spaces, so we check for parts)
let function_parts: Vec<&str> = expected_function_path.split("::").collect();
for part in &function_parts {
if !part.is_empty() {
assert!(
code.contains(part),
"Code should contain function part: {}, got: {}",
part,
code
);
}
}
drop(temp_dir);
}
#[test]
fn test_generate_router_code_multiple_routes() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
// Create multiple route files
create_temp_file(
&temp_dir,
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
);
create_temp_file(
&temp_dir,
"create_user.rs",
r#"
#[route(post)]
pub fn create_user() -> String {
"created".to_string()
}
"#,
);
create_temp_file(
&temp_dir,
"update_user.rs",
r#"
#[route(put)]
pub fn update_user() -> String {
"updated".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name).unwrap(),
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check all routes are present
assert!(code.contains("get_users"));
assert!(code.contains("create_user"));
assert!(code.contains("update_user"));
// Check methods
assert!(code.contains("get"));
assert!(code.contains("post"));
assert!(code.contains("put"));
// Count route calls (quote! generates ". route (" with spaces)
// Count occurrences of ". route (" pattern
let route_count = code.matches(". route (").count();
assert_eq!(
route_count, 3,
"Should have 3 route calls, got: {}, code: {}",
route_count, code
);
drop(temp_dir);
}
#[test]
fn test_generate_router_code_same_path_different_methods() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
// Create routes with same path but different methods
create_temp_file(
&temp_dir,
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
#[route(post)]
pub fn create_users() -> String {
"created".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name).unwrap(),
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check both routes are present
assert!(code.contains("get_users"));
assert!(code.contains("create_users"));
// Check methods
assert!(code.contains("get"));
assert!(code.contains("post"));
// Should have 2 routes (quote! generates ". route (" with spaces)
let route_count = code.matches(". route (").count();
assert_eq!(
route_count, 2,
"Should have 2 routes, got: {}, code: {}",
route_count, code
);
drop(temp_dir);
}
#[test]
fn test_generate_router_code_with_mod_rs() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "routes";
// Create mod.rs file
create_temp_file(
&temp_dir,
"mod.rs",
r#"
#[route(get)]
pub fn index() -> String {
"index".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name).unwrap(),
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check route is present
assert!(code.contains("index"));
// Path should be / (mod.rs maps to root, segments is empty)
// quote! generates "\"/\""
assert!(code.contains("\"/\""));
drop(temp_dir);
}
#[test]
fn test_generate_router_code_empty_folder_name() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let folder_name = "";
create_temp_file(
&temp_dir,
"users.rs",
r#"
#[route(get)]
pub fn get_users() -> String {
"users".to_string()
}
"#,
);
let result = generate_router_code(
&collect_metadata(temp_dir.path(), folder_name).unwrap(),
None,
None,
&[],
);
let code = result.to_string();
// Check router initialization (quote! generates "vespera :: axum :: Router :: new ()")
assert!(code.contains("Router") && code.contains("new"));
// Check route is present
assert!(code.contains("get_users"));
// Module path should not have double colons
assert!(!code.contains("::users::users"));
drop(temp_dir);
}
// ========== Tests for parsing functions ==========
#[test]
fn test_parse_openapi_values_single() {
// Test that single string openapi value parses correctly via AutoRouterInput
let tokens = quote::quote!(openapi = "openapi.json");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 1);
assert_eq!(openapi[0].value(), "openapi.json");
}
#[test]
fn test_parse_openapi_values_array() {
// Test that array openapi value parses correctly via AutoRouterInput
let tokens = quote::quote!(openapi = ["openapi.json", "api.json"]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 2);
assert_eq!(openapi[0].value(), "openapi.json");
assert_eq!(openapi[1].value(), "api.json");
}
#[test]
fn test_validate_server_url_valid_http() {
let lit = LitStr::new("http://localhost:3000", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "http://localhost:3000");
}
#[test]
fn test_validate_server_url_valid_https() {
let lit = LitStr::new("https://api.example.com", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "https://api.example.com");
}
#[test]
fn test_validate_server_url_invalid() {
let lit = LitStr::new("ftp://example.com", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_err());
}
#[test]
fn test_validate_server_url_no_scheme() {
let lit = LitStr::new("example.com", Span::call_site());
let result = validate_server_url(&lit);
assert!(result.is_err());
}
#[test]
fn test_auto_router_input_parse_dir_only() {
let tokens = quote::quote!(dir = "api");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.dir.unwrap().value(), "api");
assert!(input.openapi.is_none());
}
#[test]
fn test_auto_router_input_parse_string_as_dir() {
let tokens = quote::quote!("routes");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.dir.unwrap().value(), "routes");
}
#[test]
fn test_auto_router_input_parse_openapi_single() {
let tokens = quote::quote!(openapi = "openapi.json");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 1);
assert_eq!(openapi[0].value(), "openapi.json");
}
#[test]
fn test_auto_router_input_parse_openapi_array() {
let tokens = quote::quote!(openapi = ["a.json", "b.json"]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let openapi = input.openapi.unwrap();
assert_eq!(openapi.len(), 2);
}
#[test]
fn test_auto_router_input_parse_title_version() {
let tokens = quote::quote!(title = "My API", version = "2.0.0");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.title.unwrap().value(), "My API");
assert_eq!(input.version.unwrap().value(), "2.0.0");
}
#[test]
fn test_auto_router_input_parse_docs_redoc() {
let tokens = quote::quote!(docs_url = "/docs", redoc_url = "/redoc");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert_eq!(input.docs_url.unwrap().value(), "/docs");
assert_eq!(input.redoc_url.unwrap().value(), "/redoc");
}
#[test]
fn test_auto_router_input_parse_servers_single() {
let tokens = quote::quote!(servers = "http://localhost:3000");
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert!(servers[0].description.is_none());
}
#[test]
fn test_auto_router_input_parse_servers_array_strings() {
let tokens = quote::quote!(servers = ["http://localhost:3000", "https://api.example.com"]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 2);
}
#[test]
fn test_auto_router_input_parse_servers_tuple() {
let tokens = quote::quote!(servers = [("http://localhost:3000", "Development")]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert_eq!(servers[0].description, Some("Development".to_string()));
}
#[test]
fn test_auto_router_input_parse_servers_struct() {
let tokens =
quote::quote!(servers = [{ url = "http://localhost:3000", description = "Dev" }]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert_eq!(servers[0].description, Some("Dev".to_string()));
}
#[test]
fn test_auto_router_input_parse_servers_single_struct() {
let tokens = quote::quote!(servers = { url = "https://api.example.com" });
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "https://api.example.com");
}
#[test]
fn test_auto_router_input_parse_unknown_field() {
let tokens = quote::quote!(unknown_field = "value");
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_auto_router_input_parse_all_fields() {
let tokens = quote::quote!(
dir = "api",
openapi = "openapi.json",
title = "Test API",
version = "1.0.0",
docs_url = "/docs",
redoc_url = "/redoc",
servers = "http://localhost:3000"
);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
assert!(input.dir.is_some());
assert!(input.openapi.is_some());
assert!(input.title.is_some());
assert!(input.version.is_some());
assert!(input.docs_url.is_some());
assert!(input.redoc_url.is_some());
assert!(input.servers.is_some());
}
#[test]
fn test_generate_router_code_with_docs() {
let metadata = CollectedMetadata::new();
let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let result = generate_router_code(&metadata, docs_info, None, &[]);
let code = result.to_string();
assert!(code.contains("/docs"));
assert!(code.contains("swagger-ui"));
}
#[test]
fn test_generate_router_code_with_redoc() {
let metadata = CollectedMetadata::new();
let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let result = generate_router_code(&metadata, None, redoc_info, &[]);
let code = result.to_string();
assert!(code.contains("/redoc"));
assert!(code.contains("redoc"));
}
#[test]
fn test_generate_router_code_with_both_docs() {
let metadata = CollectedMetadata::new();
let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let result = generate_router_code(&metadata, docs_info, redoc_info, &[]);
let code = result.to_string();
assert!(code.contains("/docs"));
assert!(code.contains("/redoc"));
}
#[test]
fn test_parse_server_struct_url_only() {
// Test server struct parsing via AutoRouterInput
let tokens = quote::quote!(servers = { url = "http://localhost:3000" });
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
assert!(servers[0].description.is_none());
}
#[test]
fn test_parse_server_struct_with_description() {
let tokens =
quote::quote!(servers = { url = "http://localhost:3000", description = "Local" });
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers[0].description, Some("Local".to_string()));
}
#[test]
fn test_parse_server_struct_unknown_field() {
let tokens = quote::quote!(servers = { url = "http://localhost:3000", unknown = "test" });
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_parse_server_struct_missing_url() {
let tokens = quote::quote!(servers = { description = "test" });
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_parse_servers_tuple_url_only() {
let tokens = quote::quote!(servers = [("http://localhost:3000")]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let servers = input.servers.unwrap();
assert_eq!(servers.len(), 1);
assert!(servers[0].description.is_none());
}
#[test]
fn test_parse_servers_invalid_url() {
let tokens = quote::quote!(servers = "invalid-url");
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_auto_router_input_parse_invalid_token() {
// Test line 149: neither ident nor string literal triggers lookahead error
let tokens = quote::quote!(123);
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_err());
}
#[test]
fn test_auto_router_input_empty() {
// Test empty input - should use defaults/env vars
let tokens = quote::quote!();
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_ok());
}
#[test]
fn test_auto_router_input_multiple_commas() {
// Test input with trailing comma
let tokens = quote::quote!(dir = "api",);
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
assert!(result.is_ok());
}
#[test]
fn test_auto_router_input_no_comma() {
// Test input without comma between fields (should stop at second field)
let tokens = quote::quote!(dir = "api" title = "Test");
let result: syn::Result<AutoRouterInput> = syn::parse2(tokens);
// This should fail or only parse first field
assert!(result.is_err());
}
// ========== Tests for validate_route_fn ==========
#[test]
fn test_validate_route_fn_not_public() {
let item: syn::ItemFn = syn::parse_quote! {
async fn private_handler() -> String {
"test".to_string()
}
};
let result = validate_route_fn(&item);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("must be public"));
}
#[test]
fn test_validate_route_fn_not_async() {
let item: syn::ItemFn = syn::parse_quote! {
pub fn sync_handler() -> String {
"test".to_string()
}
};
let result = validate_route_fn(&item);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("must be async"));
}
#[test]
fn test_validate_route_fn_valid() {
let item: syn::ItemFn = syn::parse_quote! {
pub async fn valid_handler() -> String {
"test".to_string()
}
};
let result = validate_route_fn(&item);
assert!(result.is_ok());
}
// ========== Tests for process_derive_schema ==========
#[test]
fn test_process_derive_schema_struct() {
let input: syn::DeriveInput = syn::parse_quote! {
struct User {
name: String,
age: u32,
}
};
let (metadata, expanded) = process_derive_schema(&input);
assert_eq!(metadata.name, "User");
assert!(metadata.definition.contains("struct User"));
let code = expanded.to_string();
assert!(code.contains("SchemaBuilder"));
assert!(code.contains("User"));
}
#[test]
fn test_process_derive_schema_enum() {
let input: syn::DeriveInput = syn::parse_quote! {
enum Status {
Active,
Inactive,
}
};
let (metadata, expanded) = process_derive_schema(&input);
assert_eq!(metadata.name, "Status");
assert!(metadata.definition.contains("enum Status"));
let code = expanded.to_string();
assert!(code.contains("SchemaBuilder"));
}
#[test]
fn test_process_derive_schema_generic() {
let input: syn::DeriveInput = syn::parse_quote! {
struct Container<T> {
value: T,
}
};
let (metadata, expanded) = process_derive_schema(&input);
assert_eq!(metadata.name, "Container");
let code = expanded.to_string();
assert!(code.contains("SchemaBuilder"));
// Should have generic impl
assert!(code.contains("impl"));
}
// ========== Tests for process_vespera_input ==========
#[test]
fn test_process_vespera_input_defaults() {
let tokens = quote::quote!();
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let processed = process_vespera_input(input);
assert_eq!(processed.folder_name, "routes");
assert!(processed.openapi_file_names.is_empty());
assert!(processed.title.is_none());
assert!(processed.docs_url.is_none());
}
#[test]
fn test_process_vespera_input_all_fields() {
let tokens = quote::quote!(
dir = "api",
openapi = ["openapi.json", "api.json"],
title = "My API",
version = "1.0.0",
docs_url = "/docs",
redoc_url = "/redoc",
servers = "http://localhost:3000"
);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let processed = process_vespera_input(input);
assert_eq!(processed.folder_name, "api");
assert_eq!(
processed.openapi_file_names,
vec!["openapi.json", "api.json"]
);
assert_eq!(processed.title, Some("My API".to_string()));
assert_eq!(processed.version, Some("1.0.0".to_string()));
assert_eq!(processed.docs_url, Some("/docs".to_string()));
assert_eq!(processed.redoc_url, Some("/redoc".to_string()));
let servers = processed.servers.unwrap();
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].url, "http://localhost:3000");
}
#[test]
fn test_process_vespera_input_servers_with_description() {
let tokens = quote::quote!(
servers = [{ url = "https://api.example.com", description = "Production" }]
);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let processed = process_vespera_input(input);
let servers = processed.servers.unwrap();
assert_eq!(servers[0].url, "https://api.example.com");
assert_eq!(servers[0].description, Some("Production".to_string()));
}
// ========== Tests for generate_and_write_openapi ==========
#[test]
fn test_generate_and_write_openapi_no_output() {
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: None,
version: None,
docs_url: None,
redoc_url: None,
servers: None,
merge: vec![],
};
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata);
assert!(result.is_ok());
let (docs_info, redoc_info) = result.unwrap();
assert!(docs_info.is_none());
assert!(redoc_info.is_none());
}
#[test]
fn test_generate_and_write_openapi_docs_only() {
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: Some("Test API".to_string()),
version: Some("1.0.0".to_string()),
docs_url: Some("/docs".to_string()),
redoc_url: None,
servers: None,
merge: vec![],
};
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata);
assert!(result.is_ok());
let (docs_info, redoc_info) = result.unwrap();
assert!(docs_info.is_some());
let (url, json) = docs_info.unwrap();
assert_eq!(url, "/docs");
assert!(json.contains("\"openapi\""));
assert!(json.contains("Test API"));
assert!(redoc_info.is_none());
}
#[test]
fn test_generate_and_write_openapi_redoc_only() {
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: None,
version: None,
docs_url: None,
redoc_url: Some("/redoc".to_string()),
servers: None,
merge: vec![],
};
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata);
assert!(result.is_ok());
let (docs_info, redoc_info) = result.unwrap();
assert!(docs_info.is_none());
assert!(redoc_info.is_some());
let (url, _) = redoc_info.unwrap();
assert_eq!(url, "/redoc");
}
#[test]
fn test_generate_and_write_openapi_both_docs() {
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: None,
version: None,
docs_url: Some("/docs".to_string()),
redoc_url: Some("/redoc".to_string()),
servers: None,
merge: vec![],
};
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata);
assert!(result.is_ok());
let (docs_info, redoc_info) = result.unwrap();
assert!(docs_info.is_some());
assert!(redoc_info.is_some());
}
#[test]
fn test_generate_and_write_openapi_file_output() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let output_path = temp_dir.path().join("test-openapi.json");
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![output_path.to_string_lossy().to_string()],
title: Some("File Test".to_string()),
version: Some("2.0.0".to_string()),
docs_url: None,
redoc_url: None,
servers: None,
merge: vec![],
};
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata);
assert!(result.is_ok());
// Verify file was written
assert!(output_path.exists());
let content = fs::read_to_string(&output_path).unwrap();
assert!(content.contains("\"openapi\""));
assert!(content.contains("File Test"));
assert!(content.contains("2.0.0"));
}
#[test]
fn test_generate_and_write_openapi_creates_directories() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let output_path = temp_dir.path().join("nested/dir/openapi.json");
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![output_path.to_string_lossy().to_string()],
title: None,
version: None,
docs_url: None,
redoc_url: None,
servers: None,
merge: vec![],
};
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata);
assert!(result.is_ok());
// Verify nested directories and file were created
assert!(output_path.exists());
}
// ========== Tests for find_folder_path ==========
// Note: find_folder_path uses CARGO_MANIFEST_DIR which is set during cargo test
#[test]
fn test_find_folder_path_nonexistent_returns_path() {
// When the constructed path doesn't exist, it falls back to using folder_name directly
let result = find_folder_path("nonexistent_folder_xyz");
// It should return a PathBuf (either from src/nonexistent... or just the folder name)
assert!(result.to_string_lossy().contains("nonexistent_folder_xyz"));
}
// ========== Tests for extract_schema_name_attr ==========
#[test]
fn test_extract_schema_name_attr_with_name() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[schema(name = "CustomName")]
};
let result = extract_schema_name_attr(&attrs);
assert_eq!(result, Some("CustomName".to_string()));
}
#[test]
fn test_extract_schema_name_attr_without_name() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[derive(Debug)]
};
let result = extract_schema_name_attr(&attrs);
assert_eq!(result, None);
}
#[test]
fn test_extract_schema_name_attr_empty_schema() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[schema]
};
let result = extract_schema_name_attr(&attrs);
assert_eq!(result, None);
}
#[test]
fn test_extract_schema_name_attr_with_other_attrs() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[derive(Clone)]
#[schema(name = "MySchema")]
#[serde(rename_all = "camelCase")]
};
let result = extract_schema_name_attr(&attrs);
assert_eq!(result, Some("MySchema".to_string()));
}
// ========== Tests for process_derive_schema ==========
#[test]
fn test_process_derive_schema_simple() {
let input: syn::DeriveInput = syn::parse_quote! {
struct User {
id: i32,
name: String,
}
};
let (metadata, tokens) = process_derive_schema(&input);
assert_eq!(metadata.name, "User");
assert!(metadata.definition.contains("User"));
let tokens_str = tokens.to_string();
assert!(tokens_str.contains("SchemaBuilder"));
}
#[test]
fn test_process_derive_schema_with_custom_name() {
let input: syn::DeriveInput = syn::parse_quote! {
#[schema(name = "CustomUserSchema")]
struct User {
id: i32,
}
};
let (metadata, _) = process_derive_schema(&input);
assert_eq!(metadata.name, "CustomUserSchema");
}
#[test]
fn test_process_derive_schema_with_generics() {
let input: syn::DeriveInput = syn::parse_quote! {
struct Container<T> {
value: T,
}
};
let (metadata, tokens) = process_derive_schema(&input);
assert_eq!(metadata.name, "Container");
let tokens_str = tokens.to_string();
assert!(tokens_str.contains("< T >") || tokens_str.contains("<T>"));
}
// ========== Tests for parse_merge_values ==========
#[test]
fn test_parse_merge_values_single() {
let tokens = quote::quote!(merge = [some::path::App]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert_eq!(merge.len(), 1);
// Check the path segments
let path = &merge[0];
let segments: Vec<_> = path.segments.iter().map(|s| s.ident.to_string()).collect();
assert_eq!(segments, vec!["some", "path", "App"]);
}
#[test]
fn test_parse_merge_values_multiple() {
let tokens = quote::quote!(merge = [first::App, second::Other]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert_eq!(merge.len(), 2);
}
#[test]
fn test_parse_merge_values_empty() {
let tokens = quote::quote!(merge = []);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert!(merge.is_empty());
}
#[test]
fn test_parse_merge_values_with_trailing_comma() {
let tokens = quote::quote!(merge = [app::MyApp,]);
let input: AutoRouterInput = syn::parse2(tokens).unwrap();
let merge = input.merge.unwrap();
assert_eq!(merge.len(), 1);
}
// ========== Tests for find_target_dir ==========
#[test]
fn test_find_target_dir_no_workspace() {
// Test fallback to manifest dir's target
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let manifest_path = temp_dir.path();
let result = find_target_dir(manifest_path);
assert_eq!(result, manifest_path.join("target"));
}
#[test]
fn test_find_target_dir_with_cargo_lock() {
// Test finding target dir with Cargo.lock present
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let manifest_path = temp_dir.path();
// Create Cargo.lock (but no [workspace] in Cargo.toml)
fs::write(manifest_path.join("Cargo.lock"), "").expect("Failed to write Cargo.lock");
let result = find_target_dir(manifest_path);
// Should use the directory with Cargo.lock
assert_eq!(result, manifest_path.join("target"));
}
#[test]
fn test_find_target_dir_with_workspace() {
// Test finding workspace root
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace_root = temp_dir.path();
// Create a workspace Cargo.toml
fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crate1\"]",
)
.expect("Failed to write Cargo.toml");
// Create nested crate directory
let crate_dir = workspace_root.join("crate1");
fs::create_dir(&crate_dir).expect("Failed to create crate dir");
fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"")
.expect("Failed to write Cargo.toml");
let result = find_target_dir(&crate_dir);
// Should return workspace root's target
assert_eq!(result, workspace_root.join("target"));
}
#[test]
fn test_find_target_dir_workspace_with_cargo_lock() {
// Test that [workspace] takes priority over Cargo.lock
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace_root = temp_dir.path();
// Create workspace Cargo.toml and Cargo.lock
fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crate1\"]",
)
.expect("Failed to write Cargo.toml");
fs::write(workspace_root.join("Cargo.lock"), "").expect("Failed to write Cargo.lock");
// Create nested crate
let crate_dir = workspace_root.join("crate1");
fs::create_dir(&crate_dir).expect("Failed to create crate dir");
fs::write(crate_dir.join("Cargo.toml"), "[package]\nname = \"crate1\"")
.expect("Failed to write Cargo.toml");
let result = find_target_dir(&crate_dir);
assert_eq!(result, workspace_root.join("target"));
}
#[test]
fn test_find_target_dir_deeply_nested() {
// Test deeply nested crate structure
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace_root = temp_dir.path();
// Create workspace
fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]",
)
.expect("Failed to write Cargo.toml");
// Create deeply nested crate
let deep_crate = workspace_root.join("crates/group/my-crate");
fs::create_dir_all(&deep_crate).expect("Failed to create nested dirs");
fs::write(deep_crate.join("Cargo.toml"), "[package]").expect("Failed to write Cargo.toml");
let result = find_target_dir(&deep_crate);
assert_eq!(result, workspace_root.join("target"));
}
// ========== Tests for generate_router_code with merge ==========
#[test]
fn test_generate_router_code_with_merge_apps() {
let metadata = CollectedMetadata::new();
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(third::ThirdApp)];
let result = generate_router_code(&metadata, None, None, &merge_apps);
let code = result.to_string();
// Should use VesperaRouter instead of plain Router
assert!(
code.contains("VesperaRouter"),
"Should use VesperaRouter for merge, got: {}",
code
);
assert!(
code.contains("third :: ThirdApp") || code.contains("third::ThirdApp"),
"Should reference merged app, got: {}",
code
);
}
#[test]
fn test_generate_router_code_with_docs_and_merge() {
let metadata = CollectedMetadata::new();
let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(app::MyApp)];
let result = generate_router_code(&metadata, docs_info, None, &merge_apps);
let code = result.to_string();
// Should have merge code for docs
assert!(
code.contains("OnceLock"),
"Should use OnceLock for merged docs, got: {}",
code
);
assert!(
code.contains("MERGED_SPEC"),
"Should have MERGED_SPEC, got: {}",
code
);
// quote! generates "merged . merge" with spaces
assert!(
code.contains("merged . merge") || code.contains("merged.merge"),
"Should call merge on spec, got: {}",
code
);
}
#[test]
fn test_generate_router_code_with_redoc_and_merge() {
let metadata = CollectedMetadata::new();
let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(other::OtherApp)];
let result = generate_router_code(&metadata, None, redoc_info, &merge_apps);
let code = result.to_string();
// Should have merge code for redoc
assert!(
code.contains("OnceLock"),
"Should use OnceLock for merged redoc"
);
assert!(code.contains("redoc"), "Should contain redoc");
}
#[test]
fn test_generate_router_code_with_both_docs_and_merge() {
let metadata = CollectedMetadata::new();
let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string()));
let merge_apps: Vec<syn::Path> = vec![syn::parse_quote!(merged::App)];
let result = generate_router_code(&metadata, docs_info, redoc_info, &merge_apps);
let code = result.to_string();
// Both docs should have merge code
// Count MERGED_SPEC occurrences - should be at least 2 (static declarations for docs and redoc)
let merged_spec_count = code.matches("MERGED_SPEC").count();
assert!(
merged_spec_count >= 2,
"Should have at least 2 MERGED_SPEC for docs and redoc, got: {}",
merged_spec_count
);
// Both docs_url and redoc_url should be present
assert!(
code.contains("/docs") && code.contains("/redoc"),
"Should contain both /docs and /redoc"
);
}
#[test]
fn test_generate_router_code_with_multiple_merge_apps() {
let metadata = CollectedMetadata::new();
let merge_apps: Vec<syn::Path> = vec![
syn::parse_quote!(first::App),
syn::parse_quote!(second::App),
];
let result = generate_router_code(&metadata, None, None, &merge_apps);
let code = result.to_string();
// Should reference both apps
assert!(
code.contains("first") && code.contains("second"),
"Should reference both merge apps, got: {}",
code
);
}
// ========== Tests for ExportAppInput parsing ==========
#[test]
fn test_export_app_input_name_only() {
let tokens = quote::quote!(MyApp);
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert!(input.dir.is_none());
}
#[test]
fn test_export_app_input_with_dir() {
let tokens = quote::quote!(MyApp, dir = "api");
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert_eq!(input.dir.unwrap().value(), "api");
}
#[test]
fn test_export_app_input_with_trailing_comma() {
let tokens = quote::quote!(MyApp,);
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert!(input.dir.is_none());
}
#[test]
fn test_export_app_input_unknown_field() {
let tokens = quote::quote!(MyApp, unknown = "value");
let result: syn::Result<ExportAppInput> = syn::parse2(tokens);
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.to_compile_error().to_string().contains("unknown field"));
}
#[test]
fn test_export_app_input_multiple_commas() {
let tokens = quote::quote!(MyApp, dir = "api",);
let input: ExportAppInput = syn::parse2(tokens).unwrap();
assert_eq!(input.name.to_string(), "MyApp");
assert_eq!(input.dir.unwrap().value(), "api");
}
// ========== Tests for process_route_attribute ==========
#[test]
fn test_process_route_attribute_valid() {
let attr = quote::quote!(get);
let item = quote::quote!(
pub async fn handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item.clone());
assert!(result.is_ok());
// Should return the original item unchanged
assert_eq!(result.unwrap().to_string(), item.to_string());
}
#[test]
fn test_process_route_attribute_invalid_attr() {
let attr = quote::quote!(invalid_method);
let item = quote::quote!(
pub async fn handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
}
#[test]
fn test_process_route_attribute_not_function() {
let attr = quote::quote!(get);
let item = quote::quote!(
struct NotAFunction;
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("can only be applied to functions"));
}
#[test]
fn test_process_route_attribute_not_public() {
let attr = quote::quote!(get);
let item = quote::quote!(
async fn private_handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("must be public"));
}
#[test]
fn test_process_route_attribute_not_async() {
let attr = quote::quote!(get);
let item = quote::quote!(
pub fn sync_handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("must be async"));
}
#[test]
fn test_process_route_attribute_with_path() {
let attr = quote::quote!(get, path = "/users/{id}");
let item = quote::quote!(
pub async fn get_user() -> String {
"user".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok());
}
#[test]
fn test_process_route_attribute_with_tags() {
let attr = quote::quote!(post, tags = ["users", "admin"]);
let item = quote::quote!(
pub async fn create_user() -> String {
"created".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok());
}
#[test]
fn test_process_route_attribute_all_methods() {
let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
for method in methods {
let attr: proc_macro2::TokenStream = method.parse().unwrap();
let item = quote::quote!(
pub async fn handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok(), "Method {} should be valid", method);
}
}
// ========== Tests for process_vespera_macro ==========
#[test]
fn test_process_vespera_macro_folder_not_found() {
let processed = ProcessedVesperaInput {
folder_name: "nonexistent_folder_xyz_123".to_string(),
openapi_file_names: vec![],
title: None,
version: None,
docs_url: None,
redoc_url: None,
servers: None,
merge: vec![],
};
let result = process_vespera_macro(&processed, &[]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Folder not found"));
}
#[test]
fn test_process_vespera_macro_collect_metadata_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
// Create an invalid route file (will cause parse error but collect_metadata handles it)
create_temp_file(&temp_dir, "invalid.rs", "not valid rust code {{{");
let processed = ProcessedVesperaInput {
folder_name: temp_dir.path().to_string_lossy().to_string(),
openapi_file_names: vec![],
title: Some("Test API".to_string()),
version: Some("1.0.0".to_string()),
docs_url: None,
redoc_url: None,
servers: None,
merge: vec![],
};
// This exercises the collect_metadata path (which handles parse errors gracefully)
let result = process_vespera_macro(&processed, &[]);
// Result may succeed or fail depending on how collect_metadata handles invalid files
let _ = result;
}
#[test]
fn test_process_vespera_macro_with_schema_storage() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
// Create an empty file (valid but no routes)
create_temp_file(&temp_dir, "empty.rs", "// empty file\n");
let schema_storage = vec![StructMetadata::new(
"TestSchema".to_string(),
"struct TestSchema { id: i32 }".to_string(),
)];
let processed = ProcessedVesperaInput {
folder_name: temp_dir.path().to_string_lossy().to_string(),
openapi_file_names: vec![],
title: None,
version: None,
docs_url: Some("/docs".to_string()),
redoc_url: Some("/redoc".to_string()),
servers: None,
merge: vec![],
};
// This exercises the schema_storage extend path
let result = process_vespera_macro(&processed, &schema_storage);
// We only care about exercising the code path
let _ = result;
}
// ========== Tests for process_export_app ==========
#[test]
fn test_process_export_app_folder_not_found() {
let name: syn::Ident = syn::parse_quote!(TestApp);
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let result = process_export_app(
&name,
"nonexistent_folder_xyz",
&[],
&temp_dir.path().to_string_lossy(),
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Folder not found"));
}
#[test]
fn test_process_export_app_with_empty_folder() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
// Create an empty file
create_temp_file(&temp_dir, "empty.rs", "// empty\n");
let name: syn::Ident = syn::parse_quote!(TestApp);
let folder_path = temp_dir.path().to_string_lossy().to_string();
// This exercises collect_metadata and other paths
let result =
process_export_app(&name, &folder_path, &[], &temp_dir.path().to_string_lossy());
// We only care about exercising the code path
let _ = result;
}
#[test]
fn test_process_export_app_with_schema_storage() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
// Create an empty but valid Rust file
create_temp_file(&temp_dir, "mod.rs", "// module file\n");
let schema_storage = vec![StructMetadata::new(
"AppSchema".to_string(),
"struct AppSchema { name: String }".to_string(),
)];
let name: syn::Ident = syn::parse_quote!(MyExportedApp);
let folder_path = temp_dir.path().to_string_lossy().to_string();
let result = process_export_app(
&name,
&folder_path,
&schema_storage,
&temp_dir.path().to_string_lossy(),
);
// Exercises the schema_storage.extend path
let _ = result;
}
// ========== Tests for generate_and_write_openapi with merge ==========
#[test]
fn test_generate_and_write_openapi_with_merge_no_manifest_dir() {
// When CARGO_MANIFEST_DIR is not set or merge is empty, it should work normally
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: Some("Test".to_string()),
version: None,
docs_url: Some("/docs".to_string()),
redoc_url: None,
servers: None,
merge: vec![syn::parse_quote!(app::TestApp)], // Has merge but no valid manifest dir
};
let metadata = CollectedMetadata::new();
// This should still work - merge logic is skipped when CARGO_MANIFEST_DIR lookup fails
let result = generate_and_write_openapi(&processed, &metadata);
assert!(result.is_ok());
}
#[test]
fn test_generate_and_write_openapi_with_merge_and_valid_spec() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
// Create the vespera directory with a spec file
let target_dir = temp_dir.path().join("target").join("vespera");
fs::create_dir_all(&target_dir).expect("Failed to create target/vespera dir");
// Write a valid OpenAPI spec file
let spec_content =
r#"{"openapi":"3.1.0","info":{"title":"Child API","version":"1.0.0"},"paths":{}}"#;
fs::write(target_dir.join("ChildApp.openapi.json"), spec_content)
.expect("Failed to write spec file");
// Save and set CARGO_MANIFEST_DIR
let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok();
// SAFETY: We're in a single-threaded test context
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) };
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: Some("Parent API".to_string()),
version: Some("2.0.0".to_string()),
docs_url: Some("/docs".to_string()),
redoc_url: None,
servers: None,
merge: vec![syn::parse_quote!(child::ChildApp)],
};
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata);
// Restore CARGO_MANIFEST_DIR
if let Some(old_value) = old_manifest_dir {
// SAFETY: We're in a single-threaded test context
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) };
}
assert!(result.is_ok());
}
// ========== Tests for find_folder_path ==========
#[test]
fn test_find_folder_path_absolute_path() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let absolute_path = temp_dir.path().to_string_lossy().to_string();
// When given an absolute path that exists, it should return it
let result = find_folder_path(&absolute_path);
// The function tries src/{folder_name} first, then falls back to the folder_name directly
assert!(
result.to_string_lossy().contains(&absolute_path)
|| result == Path::new(&absolute_path)
);
}
#[test]
fn test_find_folder_path_with_src_folder() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
// Create src/routes directory
let src_routes = temp_dir.path().join("src").join("routes");
fs::create_dir_all(&src_routes).expect("Failed to create src/routes dir");
// Save and set CARGO_MANIFEST_DIR
let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok();
// SAFETY: We're in a single-threaded test context
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) };
let result = find_folder_path("routes");
// Restore CARGO_MANIFEST_DIR
if let Some(old_value) = old_manifest_dir {
// SAFETY: We're in a single-threaded test context
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) };
}
// Should return the src/routes path since it exists
assert!(
result.to_string_lossy().contains("src") && result.to_string_lossy().contains("routes")
);
}
}