use std::{
collections::HashMap,
hash::{Hash, Hasher},
path::Path,
};
use proc_macro2::Span;
use quote::quote;
use serde::{Deserialize, Serialize};
use crate::{
collector::{collect_file_fingerprints, collect_metadata},
error::{MacroResult, err_call_site},
metadata::{CollectedMetadata, StructMetadata},
openapi_generator::generate_openapi_doc_with_metadata,
route_impl::StoredRouteInfo,
router_codegen::{ProcessedVesperaInput, generate_router_code},
};
pub type DocsInfo = (Option<String>, Option<String>, Option<String>);
#[derive(Serialize, Deserialize)]
struct VesperaCache {
file_fingerprints: HashMap<String, u64>,
schema_hash: u64,
config_hash: u64,
metadata: CollectedMetadata,
spec_json: Option<String>,
spec_pretty: Option<String>,
}
fn compute_schema_hash(schema_storage: &HashMap<String, StructMetadata>) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let mut keys: Vec<&String> = schema_storage.keys().collect();
keys.sort();
for key in keys {
key.hash(&mut hasher);
let meta = &schema_storage[key];
meta.name.hash(&mut hasher);
meta.definition.hash(&mut hasher);
meta.include_in_openapi.hash(&mut hasher);
}
hasher.finish()
}
fn compute_config_hash(processed: &ProcessedVesperaInput) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
processed.title.hash(&mut hasher);
processed.version.hash(&mut hasher);
processed.docs_url.hash(&mut hasher);
processed.redoc_url.hash(&mut hasher);
processed.openapi_file_names.hash(&mut hasher);
if let Some(ref servers) = processed.servers {
for s in servers {
s.url.hash(&mut hasher);
}
}
for merge_path in &processed.merge {
quote!(#merge_path).to_string().hash(&mut hasher);
}
hasher.finish()
}
fn get_cache_path() -> std::path::PathBuf {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let manifest_path = Path::new(&manifest_dir);
find_target_dir(manifest_path)
.join("vespera")
.join("routes.cache")
}
fn read_cache(cache_path: &Path) -> Option<VesperaCache> {
let content = std::fs::read_to_string(cache_path).ok()?;
serde_json::from_str(&content).ok()
}
fn write_cache(cache_path: &Path, cache: &VesperaCache) {
if let Some(parent) = cache_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string(cache) {
let _ = std::fs::write(cache_path, json);
}
}
pub fn generate_and_write_openapi(
input: &ProcessedVesperaInput,
metadata: &CollectedMetadata,
file_asts: HashMap<String, syn::File>,
route_storage: &[StoredRouteInfo],
) -> MacroResult<DocsInfo> {
if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none()
{
return Ok((None, None, None));
}
let mut openapi_doc = generate_openapi_doc_with_metadata(
input.title.clone(),
input.version.clone(),
input.servers.clone(),
metadata,
Some(file_asts),
route_storage,
);
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 {
if let Some(last_segment) = merge_path.segments.last() {
let struct_name = last_segment.ident.to_string();
let spec_file = vespera_dir.join(format!("{struct_name}.openapi.json"));
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);
}
}
}
}
if !input.openapi_file_names.is_empty() {
let json_pretty = serde_json::to_string_pretty(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?;
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| err_call_site(format!("OpenAPI output: failed to create directory '{}'. Error: {}. Ensure the path is valid and writable.", parent.display(), e)))?;
}
let should_write = std::fs::read_to_string(file_path)
.map(|existing| existing != json_pretty)
.unwrap_or(true);
if should_write {
std::fs::write(file_path, &json_pretty).map_err(|e| err_call_site(format!("OpenAPI output: failed to write file '{openapi_file_name}'. Error: {e}. Ensure the file path is writable.")))?;
}
}
}
let spec_json = if input.docs_url.is_some() || input.redoc_url.is_some() {
Some(serde_json::to_string(&openapi_doc).map_err(|e| err_call_site(format!("OpenAPI generation: failed to serialize document to JSON. Error: {e}. Check that all schema types are serializable.")))?)
} else {
None
};
Ok((input.docs_url.clone(), input.redoc_url.clone(), spec_json))
}
pub fn find_folder_path(folder_name: &str) -> MacroResult<std::path::PathBuf> {
let root = std::env::var("CARGO_MANIFEST_DIR").map_err(|_| {
err_call_site(
"CARGO_MANIFEST_DIR is not set. vespera macros must be used within a cargo build.",
)
})?;
let path = format!("{root}/src/{folder_name}");
let path = Path::new(&path);
if path.exists() && path.is_dir() {
return Ok(path.to_path_buf());
}
Ok(Path::new(folder_name).to_path_buf())
}
pub fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf {
let mut current = Some(manifest_path);
let mut last_with_lock = None;
while let Some(dir) = current {
if dir.join("Cargo.lock").exists() {
last_with_lock = Some(dir.to_path_buf());
}
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 let Some(lock_dir) = last_with_lock {
return lock_dir.join("target");
}
manifest_path.join("target")
}
fn merge_route_storage_data(metadata: &mut CollectedMetadata, route_storage: &[StoredRouteInfo]) {
if route_storage.is_empty() {
return;
}
for route in &mut metadata.routes {
let mut matches = route_storage
.iter()
.filter(|s| s.fn_name == route.function_name);
let Some(stored) = matches.next() else {
continue;
};
if matches.next().is_some() {
continue;
}
if let Some(ref tags) = stored.tags {
route.tags = Some(tags.clone());
}
if let Some(ref desc) = stored.description {
route.description = Some(desc.clone());
}
if let Some(ref status) = stored.error_status {
route.error_status = Some(status.clone());
}
}
}
pub fn ensure_openapi_files_from_cache(
openapi_file_names: &[String],
spec_pretty: Option<&str>,
) -> syn::Result<()> {
let Some(pretty) = spec_pretty else {
return Ok(());
};
for openapi_file_name in openapi_file_names {
let file_path = Path::new(openapi_file_name);
let should_write = std::fs::read_to_string(file_path)
.map(|existing| existing != *pretty)
.unwrap_or(true);
if should_write {
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!(
"OpenAPI output: failed to create directory '{}': {}",
parent.display(),
e
),
)
})?;
}
std::fs::write(file_path, pretty).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!("OpenAPI output: failed to write file '{openapi_file_name}': {e}"),
)
})?;
}
}
Ok(())
}
fn write_spec_for_embedding(
spec_json: Option<String>,
) -> syn::Result<Option<proc_macro2::TokenStream>> {
let Some(json) = spec_json else {
return Ok(None);
};
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
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!(
"vespera! macro: failed to create directory '{}': {}",
vespera_dir.display(),
e
),
)
})?;
let spec_file = vespera_dir.join("vespera_spec.json");
let should_write = std::fs::read_to_string(&spec_file)
.map(|existing| existing != json)
.unwrap_or(true);
if should_write {
std::fs::write(&spec_file, &json).map_err(|e| {
syn::Error::new(
Span::call_site(),
format!(
"vespera! macro: failed to write spec file '{}': {}",
spec_file.display(),
e
),
)
})?;
}
let path_str = spec_file.display().to_string().replace('\\', "/");
Ok(Some(quote::quote! { include_str!(#path_str) }))
}
pub fn process_vespera_macro(
processed: &ProcessedVesperaInput,
schema_storage: &HashMap<String, StructMetadata>,
route_storage: &[StoredRouteInfo],
) -> syn::Result<proc_macro2::TokenStream> {
let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() {
Some(std::time::Instant::now())
} else {
None
};
let folder_path = find_folder_path(&processed.folder_name)?;
if !folder_path.exists() {
return Err(syn::Error::new(
Span::call_site(),
format!(
"vespera! macro: route folder '{}' not found. Create src/{} or specify a different folder with `dir = \"your_folder\"`.",
processed.folder_name, processed.folder_name
),
));
}
let cache_path = get_cache_path();
let fingerprints = collect_file_fingerprints(&folder_path)
.map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: {e}")))?;
let schema_hash = compute_schema_hash(schema_storage);
let config_hash = compute_config_hash(processed);
let cached = read_cache(&cache_path);
let cache_hit = cached.as_ref().is_some_and(|c| {
c.file_fingerprints == fingerprints
&& c.schema_hash == schema_hash
&& c.config_hash == config_hash
});
let (metadata, spec_json) = if cache_hit {
let cache = cached.unwrap();
let mut metadata = cache.metadata;
metadata.structs.extend(schema_storage.values().cloned());
merge_route_storage_data(&mut metadata, route_storage);
metadata
.check_duplicate_schema_names()
.map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?;
ensure_openapi_files_from_cache(
&processed.openapi_file_names,
cache.spec_pretty.as_deref(),
)?;
(metadata, cache.spec_json)
} else {
let (mut metadata, file_asts) = collect_metadata(&folder_path, &processed.folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("vespera! macro: failed to scan route folder '{}'. Error: {}. Check that all .rs files have valid Rust syntax.", processed.folder_name, e)))?;
let cache_metadata = metadata.clone();
metadata.structs.extend(schema_storage.values().cloned());
merge_route_storage_data(&mut metadata, route_storage);
metadata
.check_duplicate_schema_names()
.map_err(|msg| syn::Error::new(Span::call_site(), format!("vespera! macro: {msg}")))?;
let (_, _, spec_json) =
generate_and_write_openapi(processed, &metadata, file_asts, route_storage)?;
let spec_pretty = processed
.openapi_file_names
.first()
.and_then(|f| std::fs::read_to_string(f).ok());
write_cache(
&cache_path,
&VesperaCache {
file_fingerprints: fingerprints,
schema_hash,
config_hash,
metadata: cache_metadata,
spec_json: spec_json.clone(),
spec_pretty,
},
);
(metadata, spec_json)
};
let spec_tokens = write_spec_for_embedding(spec_json)?;
let result = Ok(generate_router_code(
&metadata,
processed.docs_url.as_deref(),
processed.redoc_url.as_deref(),
spec_tokens,
&processed.merge,
));
if let Some(start) = profile_start {
eprintln!(
"[vespera-profile] vespera! macro total: {:?}",
start.elapsed()
);
crate::schema_macro::print_profile_summary();
}
result
}
pub fn process_export_app(
name: &syn::Ident,
folder_name: &str,
schema_storage: &HashMap<String, StructMetadata>,
manifest_dir: &str,
route_storage: &[StoredRouteInfo],
) -> syn::Result<proc_macro2::TokenStream> {
let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() {
Some(std::time::Instant::now())
} else {
None
};
let folder_path = find_folder_path(folder_name)?;
if !folder_path.exists() {
return Err(syn::Error::new(
Span::call_site(),
format!(
"export_app! macro: route folder '{folder_name}' not found. Create src/{folder_name} or specify a different folder with `dir = \"your_folder\"`.",
),
));
}
let (mut metadata, file_asts) = collect_metadata(&folder_path, folder_name, route_storage).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to scan route folder '{folder_name}'. Error: {e}. Check that all .rs files have valid Rust syntax.")))?;
metadata.structs.extend(schema_storage.values().cloned());
merge_route_storage_data(&mut metadata, route_storage);
metadata
.check_duplicate_schema_names()
.map_err(|msg| syn::Error::new(Span::call_site(), format!("export_app! macro: {msg}")))?;
let openapi_doc = generate_openapi_doc_with_metadata(
None,
None,
None,
&metadata,
Some(file_asts),
route_storage,
);
let spec_json = serde_json::to_string(&openapi_doc).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to serialize OpenAPI spec to JSON. Error: {e}. Check that all schema types are serializable.")))?;
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!("export_app! macro: failed to create build cache directory '{}'. Error: {}. Ensure the target directory is writable.", vespera_dir.display(), e)))?;
let spec_file = vespera_dir.join(format!("{name_str}.openapi.json"));
std::fs::write(&spec_file, &spec_json).map_err(|e| syn::Error::new(Span::call_site(), format!("export_app! macro: failed to write OpenAPI spec file '{}'. Error: {}. Ensure the file path is writable.", spec_file.display(), e)))?;
let spec_path_str = spec_file.display().to_string().replace('\\', "/");
let router_code = generate_router_code(&metadata, None, None, None, &[]);
let result = Ok(quote! {
pub struct #name;
impl #name {
pub const OPENAPI_SPEC: &'static str = include_str!(#spec_path_str);
pub fn router() -> vespera::axum::Router<()> {
#router_code
}
}
});
if let Some(start) = profile_start {
eprintln!(
"[vespera-profile] export_app! macro total: {:?}",
start.elapsed()
);
crate::schema_macro::print_profile_summary();
}
result
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
use crate::metadata::RouteMetadata;
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_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, HashMap::new(), &[]);
assert!(result.is_ok());
let (docs_url, redoc_url, spec_json) = result.unwrap();
assert!(docs_url.is_none());
assert!(redoc_url.is_none());
assert!(spec_json.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, HashMap::new(), &[]);
assert!(result.is_ok());
let (docs_url, redoc_url, spec_json) = result.unwrap();
assert!(docs_url.is_some());
assert_eq!(docs_url.unwrap(), "/docs");
assert!(spec_json.is_some());
let json = spec_json.unwrap();
assert!(json.contains("\"openapi\""));
assert!(json.contains("Test API"));
assert!(redoc_url.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, HashMap::new(), &[]);
assert!(result.is_ok());
let (docs_url, redoc_url, spec_json) = result.unwrap();
assert!(docs_url.is_none());
assert!(redoc_url.is_some());
assert_eq!(redoc_url.unwrap(), "/redoc");
assert!(spec_json.is_some());
}
#[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, HashMap::new(), &[]);
assert!(result.is_ok());
let (docs_url, redoc_url, spec_json) = result.unwrap();
assert!(docs_url.is_some());
assert!(redoc_url.is_some());
assert!(spec_json.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, HashMap::new(), &[]);
assert!(result.is_ok());
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, HashMap::new(), &[]);
assert!(result.is_ok());
assert!(output_path.exists());
}
#[test]
fn test_find_folder_path_nonexistent_returns_path() {
let result = find_folder_path("nonexistent_folder_xyz").unwrap();
assert!(result.to_string_lossy().contains("nonexistent_folder_xyz"));
}
#[test]
fn test_find_target_dir_no_workspace() {
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() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let manifest_path = temp_dir.path();
fs::write(manifest_path.join("Cargo.lock"), "").expect("Failed to write Cargo.lock");
let result = find_target_dir(manifest_path);
assert_eq!(result, manifest_path.join("target"));
}
#[test]
fn test_find_target_dir_with_workspace() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace_root = temp_dir.path();
fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crate1\"]",
)
.expect("Failed to write Cargo.toml");
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_workspace_with_cargo_lock() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace_root = temp_dir.path();
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");
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() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let workspace_root = temp_dir.path();
fs::write(
workspace_root.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\"]",
)
.expect("Failed to write Cargo.toml");
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"));
}
#[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, &HashMap::new(), &[]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("route folder") && err.contains("not found"));
}
#[test]
fn test_process_vespera_macro_collect_metadata_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
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![],
};
let result = process_vespera_macro(&processed, &HashMap::new(), &[]);
let _ = result;
}
#[test]
fn test_process_vespera_macro_with_schema_storage() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "empty.rs", "// empty file\n");
let schema_storage = HashMap::from([(
"TestSchema".to_string(),
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![],
};
let result = process_vespera_macro(&processed, &schema_storage, &[]);
let _ = result;
}
#[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",
&HashMap::new(),
&temp_dir.path().to_string_lossy(),
&[],
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("route folder") && err.contains("not found"));
}
#[test]
fn test_process_export_app_with_empty_folder() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
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();
let result = process_export_app(
&name,
&folder_path,
&HashMap::new(),
&temp_dir.path().to_string_lossy(),
&[],
);
let _ = result;
}
#[test]
fn test_process_export_app_with_schema_storage() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "mod.rs", "// module file\n");
let schema_storage = HashMap::from([(
"AppSchema".to_string(),
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(),
&[],
);
let _ = result;
}
#[test]
fn test_generate_and_write_openapi_with_merge_no_manifest_dir() {
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)], };
let metadata = CollectedMetadata::new();
let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]);
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");
let target_dir = temp_dir.path().join("target").join("vespera");
fs::create_dir_all(&target_dir).expect("Failed to create target/vespera dir");
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");
let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok();
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, HashMap::new(), &[]);
if let Some(old_value) = old_manifest_dir {
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) };
}
assert!(result.is_ok());
}
#[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();
let result = find_folder_path(&absolute_path).unwrap();
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");
let src_routes = temp_dir.path().join("src").join("routes");
fs::create_dir_all(&src_routes).expect("Failed to create src/routes dir");
let old_manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok();
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) };
let result = find_folder_path("routes").unwrap();
if let Some(old_value) = old_manifest_dir {
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", old_value) };
}
assert!(
result.to_string_lossy().contains("src") && result.to_string_lossy().contains("routes")
);
}
#[test]
fn test_generate_and_write_openapi_file_write_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let output_path = temp_dir.path().join("openapi.json");
fs::create_dir(&output_path).expect("Failed to create directory");
let processed = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![output_path.to_string_lossy().to_string()],
title: Some("Test API".to_string()),
version: Some("1.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, HashMap::new(), &[]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("failed to write file"));
}
#[test]
fn test_process_export_app_collect_metadata_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "invalid.rs", "fn broken( { syntax error");
let name: syn::Ident = syn::parse_quote!(TestApp);
let folder_path = temp_dir.path().to_string_lossy().to_string();
let result = process_export_app(
&name,
&folder_path,
&HashMap::new(),
&temp_dir.path().to_string_lossy(),
&[],
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("failed to scan route folder"));
}
#[test]
fn test_process_export_app_create_dir_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "empty.rs", "// empty file\n");
let target_dir = temp_dir.path().join("target");
fs::create_dir(&target_dir).expect("Failed to create target dir");
fs::write(target_dir.join("vespera"), "blocking file").expect("Failed to write file");
let name: syn::Ident = syn::parse_quote!(TestApp);
let folder_path = temp_dir.path().to_string_lossy().to_string();
let result = process_export_app(
&name,
&folder_path,
&HashMap::new(),
&temp_dir.path().to_string_lossy(),
&[],
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("failed to create build cache directory"));
}
#[test]
fn test_process_export_app_write_spec_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "empty.rs", "// empty file\n");
let vespera_dir = temp_dir.path().join("target").join("vespera");
fs::create_dir_all(&vespera_dir).expect("Failed to create vespera dir");
fs::create_dir(vespera_dir.join("TestApp.openapi.json"))
.expect("Failed to create blocking dir");
let name: syn::Ident = syn::parse_quote!(TestApp);
let folder_path = temp_dir.path().to_string_lossy().to_string();
let result = process_export_app(
&name,
&folder_path,
&HashMap::new(),
&temp_dir.path().to_string_lossy(),
&[],
);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("failed to write OpenAPI spec file"));
}
#[test]
fn test_process_vespera_macro_no_openapi_output() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "empty.rs", "// empty route file\n");
let processed = ProcessedVesperaInput {
folder_name: temp_dir.path().to_string_lossy().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, &HashMap::new(), &[]);
assert!(
result.is_ok(),
"Should succeed with no openapi output configured"
);
}
#[test]
#[serial_test::serial]
fn test_process_vespera_macro_with_profiling() {
let old_profile = std::env::var("VESPERA_PROFILE").ok();
unsafe { std::env::set_var("VESPERA_PROFILE", "1") };
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "empty.rs", "// empty\n");
let processed = ProcessedVesperaInput {
folder_name: temp_dir.path().to_string_lossy().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, &HashMap::new(), &[]);
unsafe {
if let Some(val) = old_profile {
std::env::set_var("VESPERA_PROFILE", val);
} else {
std::env::remove_var("VESPERA_PROFILE");
}
};
assert!(result.is_ok());
}
#[test]
#[serial_test::serial]
fn test_process_export_app_with_profiling() {
let old_profile = std::env::var("VESPERA_PROFILE").ok();
unsafe { std::env::set_var("VESPERA_PROFILE", "1") };
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(&temp_dir, "empty.rs", "// empty\n");
let name: syn::Ident = syn::parse_quote!(TestProfileApp);
let folder_path = temp_dir.path().to_string_lossy().to_string();
let result = process_export_app(
&name,
&folder_path,
&HashMap::new(),
&temp_dir.path().to_string_lossy(),
&[],
);
unsafe {
if let Some(val) = old_profile {
std::env::set_var("VESPERA_PROFILE", val);
} else {
std::env::remove_var("VESPERA_PROFILE");
}
};
let _ = result;
}
#[test]
fn test_merge_route_storage_empty_storage() {
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "get".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "routes".to_string(),
file_path: "routes/users.rs".to_string(),
signature: "pub async fn get_users() -> Json<Vec<User>>".to_string(),
error_status: None,
tags: None,
description: None,
});
merge_route_storage_data(&mut metadata, &[]);
assert!(metadata.routes[0].tags.is_none());
assert!(metadata.routes[0].description.is_none());
assert!(metadata.routes[0].error_status.is_none());
}
#[test]
fn test_merge_route_storage_matching_route() {
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "get".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "routes".to_string(),
file_path: "routes/users.rs".to_string(),
signature: "pub async fn get_users() -> Json<Vec<User>>".to_string(),
error_status: None,
tags: None,
description: None,
});
let storage = vec![StoredRouteInfo {
fn_name: "get_users".to_string(),
method: Some("get".to_string()),
custom_path: None,
error_status: Some(vec![400, 404]),
tags: Some(vec!["users".to_string()]),
description: Some("List all users".to_string()),
fn_item_str: String::new(),
file_path: None,
}];
merge_route_storage_data(&mut metadata, &storage);
assert_eq!(metadata.routes[0].tags, Some(vec!["users".to_string()]));
assert_eq!(
metadata.routes[0].description,
Some("List all users".to_string())
);
assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404]));
}
#[test]
fn test_merge_route_storage_no_match() {
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "get".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "routes".to_string(),
file_path: "routes/users.rs".to_string(),
signature: String::new(),
error_status: None,
tags: None,
description: None,
});
let storage = vec![StoredRouteInfo {
fn_name: "create_user".to_string(),
method: Some("post".to_string()),
custom_path: None,
error_status: Some(vec![400]),
tags: Some(vec!["users".to_string()]),
description: None,
fn_item_str: String::new(),
file_path: None,
}];
merge_route_storage_data(&mut metadata, &storage);
assert!(metadata.routes[0].tags.is_none());
assert!(metadata.routes[0].error_status.is_none());
}
#[test]
fn test_merge_route_storage_ambiguous_skipped() {
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "get".to_string(),
path: "/users".to_string(),
function_name: "handler".to_string(),
module_path: "routes".to_string(),
file_path: "routes/users.rs".to_string(),
signature: String::new(),
error_status: None,
tags: None,
description: None,
});
let storage = vec![
StoredRouteInfo {
fn_name: "handler".to_string(),
method: Some("get".to_string()),
custom_path: None,
error_status: None,
tags: Some(vec!["file-a".to_string()]),
description: None,
fn_item_str: String::new(),
file_path: None,
},
StoredRouteInfo {
fn_name: "handler".to_string(),
method: Some("post".to_string()),
custom_path: None,
error_status: None,
tags: Some(vec!["file-b".to_string()]),
description: None,
fn_item_str: String::new(),
file_path: None,
},
];
merge_route_storage_data(&mut metadata, &storage);
assert!(metadata.routes[0].tags.is_none());
}
#[test]
fn test_merge_route_storage_preserves_existing() {
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "get".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "routes".to_string(),
file_path: "routes/users.rs".to_string(),
signature: String::new(),
error_status: Some(vec![500]),
tags: Some(vec!["existing-tag".to_string()]),
description: Some("Existing description".to_string()),
});
let storage = vec![StoredRouteInfo {
fn_name: "get_users".to_string(),
method: Some("get".to_string()),
custom_path: None,
error_status: Some(vec![400, 404]),
tags: Some(vec!["new-tag".to_string()]),
description: Some("New description".to_string()),
fn_item_str: String::new(),
file_path: None,
}];
merge_route_storage_data(&mut metadata, &storage);
assert_eq!(metadata.routes[0].tags, Some(vec!["new-tag".to_string()]));
assert_eq!(
metadata.routes[0].description,
Some("New description".to_string())
);
assert_eq!(metadata.routes[0].error_status, Some(vec![400, 404]));
}
#[test]
fn test_merge_route_storage_partial_fields() {
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "get".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "routes".to_string(),
file_path: "routes/users.rs".to_string(),
signature: String::new(),
error_status: None,
tags: Some(vec!["from-collector".to_string()]),
description: Some("From doc comment".to_string()),
});
let storage = vec![StoredRouteInfo {
fn_name: "get_users".to_string(),
method: Some("get".to_string()),
custom_path: None,
error_status: Some(vec![400]),
tags: None,
description: None,
fn_item_str: String::new(),
file_path: None,
}];
merge_route_storage_data(&mut metadata, &storage);
assert_eq!(
metadata.routes[0].tags,
Some(vec!["from-collector".to_string()])
);
assert_eq!(
metadata.routes[0].description,
Some("From doc comment".to_string())
);
assert_eq!(metadata.routes[0].error_status, Some(vec![400]));
}
#[test]
fn test_compute_config_hash_with_servers() {
let processed_no_servers = 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 processed_with_servers = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: None,
version: None,
docs_url: None,
redoc_url: None,
servers: Some(vec![
vespera_core::openapi::Server {
url: "https://api.example.com".to_string(),
description: None,
variables: None,
},
vespera_core::openapi::Server {
url: "http://localhost:3000".to_string(),
description: None,
variables: None,
},
]),
merge: vec![],
};
let hash_no_servers = compute_config_hash(&processed_no_servers);
let hash_with_servers = compute_config_hash(&processed_with_servers);
assert_ne!(
hash_no_servers, hash_with_servers,
"Servers should affect config hash"
);
}
#[test]
fn test_compute_config_hash_with_merge() {
let processed_no_merge = 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 processed_with_merge = ProcessedVesperaInput {
folder_name: "routes".to_string(),
openapi_file_names: vec![],
title: None,
version: None,
docs_url: None,
redoc_url: None,
servers: None,
merge: vec![syn::parse_quote!(app::TestApp)],
};
let hash_no_merge = compute_config_hash(&processed_no_merge);
let hash_with_merge = compute_config_hash(&processed_with_merge);
assert_ne!(
hash_no_merge, hash_with_merge,
"Merge paths should affect config hash"
);
}
#[test]
fn test_ensure_openapi_files_from_cache_none_spec() {
let result = ensure_openapi_files_from_cache(&["dummy.json".to_string()], None);
assert!(result.is_ok());
}
#[test]
fn test_ensure_openapi_files_from_cache_writes_file() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let output_path = temp_dir.path().join("api.json");
let spec = r#"{"openapi":"3.1.0"}"#;
let result = ensure_openapi_files_from_cache(
&[output_path.to_string_lossy().to_string()],
Some(spec),
);
assert!(result.is_ok());
assert_eq!(fs::read_to_string(&output_path).unwrap(), spec);
}
#[test]
fn test_ensure_openapi_files_from_cache_skip_unchanged() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let output_path = temp_dir.path().join("api.json");
let spec = r#"{"openapi":"3.1.0"}"#;
fs::write(&output_path, spec).unwrap();
let result = ensure_openapi_files_from_cache(
&[output_path.to_string_lossy().to_string()],
Some(spec),
);
assert!(result.is_ok());
assert_eq!(fs::read_to_string(&output_path).unwrap(), spec);
}
#[test]
fn test_ensure_openapi_files_from_cache_creates_parent_dirs() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let output_path = temp_dir.path().join("nested").join("dir").join("api.json");
let spec = r#"{"openapi":"3.1.0"}"#;
let result = ensure_openapi_files_from_cache(
&[output_path.to_string_lossy().to_string()],
Some(spec),
);
assert!(result.is_ok());
assert!(output_path.exists());
assert_eq!(fs::read_to_string(&output_path).unwrap(), spec);
}
#[test]
fn test_ensure_openapi_files_from_cache_write_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let output_path = temp_dir.path().join("api.json");
fs::create_dir(&output_path).unwrap();
let result = ensure_openapi_files_from_cache(
&[output_path.to_string_lossy().to_string()],
Some("spec"),
);
assert!(result.is_err());
}
#[test]
fn test_ensure_openapi_files_from_cache_multiple_files() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let path1 = temp_dir.path().join("api1.json");
let path2 = temp_dir.path().join("api2.json");
let spec = r#"{"openapi":"3.1.0"}"#;
let result = ensure_openapi_files_from_cache(
&[
path1.to_string_lossy().to_string(),
path2.to_string_lossy().to_string(),
],
Some(spec),
);
assert!(result.is_ok());
assert_eq!(fs::read_to_string(&path1).unwrap(), spec);
assert_eq!(fs::read_to_string(&path2).unwrap(), spec);
}
#[test]
#[serial_test::serial]
fn test_process_vespera_macro_cache_hit() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
create_temp_file(
&temp_dir,
"users.rs",
"pub async fn list_users() -> String { \"users\".to_string() }\n",
);
let folder_path = temp_dir.path().to_string_lossy().to_string();
let openapi_path = temp_dir.path().join("openapi.json");
let old_manifest = std::env::var("CARGO_MANIFEST_DIR").ok();
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", temp_dir.path()) };
let processed = ProcessedVesperaInput {
folder_name: folder_path.clone(),
openapi_file_names: vec![openapi_path.to_string_lossy().to_string()],
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 result1 = process_vespera_macro(&processed, &HashMap::new(), &[]);
assert!(
result1.is_ok(),
"First call (cache miss) should succeed: {:?}",
result1.err()
);
assert!(
openapi_path.exists(),
"openapi.json should be written on first call"
);
let result2 = process_vespera_macro(&processed, &HashMap::new(), &[]);
assert!(
result2.is_ok(),
"Second call (cache hit) should succeed: {:?}",
result2.err()
);
unsafe {
if let Some(val) = old_manifest {
std::env::set_var("CARGO_MANIFEST_DIR", val);
} else {
std::env::remove_var("CARGO_MANIFEST_DIR");
}
};
}
}