diff --git a/.changepacks/changepack_log_yCCs246VmSt0iEMalIxxI.json b/.changepacks/changepack_log_yCCs246VmSt0iEMalIxxI.json new file mode 100644 index 0000000..474b16c --- /dev/null +++ b/.changepacks/changepack_log_yCCs246VmSt0iEMalIxxI.json @@ -0,0 +1 @@ +{"changes":{"Cargo.toml":"Patch"},"note":"Refactor parse logic","date":"2026-03-04T17:00:09.926969600Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index eb8ae8b..e097147 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3237,7 +3237,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.38" +version = "0.1.39" dependencies = [ "axum", "axum-extra", @@ -3253,7 +3253,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.38" +version = "0.1.39" dependencies = [ "rstest", "serde", @@ -3262,7 +3262,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.38" +version = "0.1.39" dependencies = [ "insta", "proc-macro2", diff --git a/crates/vespera/Cargo.toml b/crates/vespera/Cargo.toml index 12d99e8..42cdbda 100644 --- a/crates/vespera/Cargo.toml +++ b/crates/vespera/Cargo.toml @@ -7,13 +7,13 @@ license.workspace = true repository.workspace = true [features] -default = ["dep:axum-extra", "axum-extra/typed-header", "axum-extra/form", "axum-extra/query", "axum-extra/multipart", "axum-extra/cookie"] +default = ["axum-extra/typed-header", "axum-extra/form", "axum-extra/query", "axum-extra/multipart", "axum-extra/cookie"] [dependencies] vespera_core = { workspace = true } vespera_macro = { workspace = true } axum = "0.8" -axum-extra = { version = "0.12", optional = true } +axum-extra = { version = "0.12" } chrono = { version = "0.4", features = ["serde"] } axum_typed_multipart = "0.16" tempfile = "3" diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index 2fe0aee..e536f42 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -12,7 +12,7 @@ proc-macro = true [dependencies] quote = "1" syn = { version = "2", features = ["full"] } -proc-macro2 = "1" +proc-macro2 = { version = "1", features = ["span-locations"] } vespera_core = { workspace = true } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index 75b0dd1..650bc7d 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -10,16 +10,23 @@ use crate::{ file_utils::{collect_files, file_to_segments}, metadata::{CollectedMetadata, RouteMetadata}, route::{extract_doc_comment, extract_route_info}, + route_impl::StoredRouteInfo, }; /// Collect routes and structs from a folder. /// +/// When `route_storage` contains entries with `file_path`, files covered by +/// `ROUTE_STORAGE` skip expensive `syn::parse_file()` — route metadata is built +/// directly from the stored data. Default values for `serde(default = "fn")` +/// are already extracted by `#[derive(Schema)]` into `SCHEMA_STORAGE.field_defaults`. +/// /// Returns the metadata AND the parsed file ASTs, so downstream consumers /// (e.g., `openapi_generator`) can reuse them without re-reading files from disk. -#[allow(clippy::option_if_let_else)] +#[allow(clippy::option_if_let_else, clippy::too_many_lines)] pub fn collect_metadata( folder_path: &Path, folder_name: &str, + route_storage: &[StoredRouteInfo], ) -> MacroResult<(CollectedMetadata, HashMap)> { let mut metadata = CollectedMetadata::new(); @@ -27,27 +34,25 @@ pub fn collect_metadata( let mut file_asts = HashMap::with_capacity(files.len()); + // Index ROUTE_STORAGE entries by file path for O(1) lookup + let storage_by_file: HashMap<&str, Vec<&StoredRouteInfo>> = { + let mut map: HashMap<&str, Vec<&StoredRouteInfo>> = HashMap::new(); + for stored in route_storage { + if let Some(ref fp) = stored.file_path { + map.entry(fp.as_str()).or_default().push(stored); + } + } + map + }; + for file in files { if file.extension().is_none_or(|e| e != "rs") { continue; } - let content = std::fs::read_to_string(&file).map_err(|e| { - err_call_site(format!( - "vespera! macro: failed to read route file '{}': {}. Check file permissions.", - file.display(), - e - )) - })?; - - let file_ast = syn::parse_file(&content).map_err(|e| err_call_site(format!("vespera! macro: syntax error in '{}': {}. Fix the Rust syntax errors in this file.", file.display(), e)))?; - - // Store file AST for downstream reuse (keyed by display path to match RouteMetadata.file_path) let file_path = file.display().to_string(); - file_asts.insert(file_path.clone(), file_ast); - let file_ast = &file_asts[&file_path]; - // Get module path + // Get module path (cheap — no parsing needed) let segments = file .strip_prefix(folder_path) .map(|file_stem| file_to_segments(file_stem, folder_path)) @@ -69,12 +74,10 @@ pub fn collect_metadata( // Pre-compute base path once per file (avoids repeated segments.join per route) let base_path = format!("/{}", segments.join("/")); - // Collect routes - for item in &file_ast.items { - if let Item::Fn(fn_item) = item - && let Some(route_info) = extract_route_info(&fn_item.attrs) - { - let route_path = if let Some(custom_path) = &route_info.path { + // Fast path: ROUTE_STORAGE has entries for this file — skip syn::parse_file() + if let Some(stored_routes) = storage_by_file.get(file_path.as_str()) { + for stored in stored_routes { + let route_path = if let Some(ref custom_path) = stored.custom_path { let trimmed_base = base_path.trim_end_matches('/'); format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) } else { @@ -82,30 +85,105 @@ pub fn collect_metadata( }; let route_path = route_path.replace('_', "-"); - // Description priority: route attribute > doc comment - let description = route_info - .description - .clone() - .or_else(|| extract_doc_comment(&fn_item.attrs)); + // Extract doc comment from fn_item_str if no explicit description + let description = stored.description.clone().or_else(|| { + syn::parse_str::(&stored.fn_item_str) + .ok() + .and_then(|fn_item| extract_doc_comment(&fn_item.attrs)) + }); metadata.routes.push(RouteMetadata { - method: route_info.method, + method: stored.method.clone().unwrap_or_default(), path: route_path, - function_name: fn_item.sig.ident.to_string(), + function_name: stored.fn_name.clone(), module_path: module_path.clone(), file_path: file_path.clone(), - signature: quote::quote!(#fn_item).to_string(), - error_status: route_info.error_status.clone(), - tags: route_info.tags.clone(), + signature: stored.fn_item_str.clone(), + error_status: stored.error_status.clone(), + tags: stored.tags.clone(), description, }); } + + // No file_asts insertion needed in fast path: + // #[derive(Schema)] already extracts serde(default = "fn") values + // into SCHEMA_STORAGE.field_defaults (Priority 0 in process_default_functions) + } else { + // Slow path: full parsing (fallback for files not in ROUTE_STORAGE) + // Uses get_parsed_file: single syn::parse_file entry point + content cache + let file_ast = crate::schema_macro::file_cache::get_parsed_file(&file).ok_or_else(|| err_call_site(format!("vespera! macro: cannot read or parse '{}'. Fix the Rust syntax errors in this file.", file.display())))?; + + // Store file AST for downstream reuse + file_asts.insert(file_path.clone(), file_ast); + let file_ast = &file_asts[&file_path]; + + // Collect routes from AST + for item in &file_ast.items { + if let Item::Fn(fn_item) = item + && let Some(route_info) = extract_route_info(&fn_item.attrs) + { + let route_path = if let Some(custom_path) = &route_info.path { + let trimmed_base = base_path.trim_end_matches('/'); + format!("{trimmed_base}/{}", custom_path.trim_start_matches('/')) + } else { + base_path.clone() + }; + let route_path = route_path.replace('_', "-"); + + // Description priority: route attribute > doc comment + let description = route_info + .description + .clone() + .or_else(|| extract_doc_comment(&fn_item.attrs)); + + metadata.routes.push(RouteMetadata { + method: route_info.method, + path: route_path, + function_name: fn_item.sig.ident.to_string(), + module_path: module_path.clone(), + file_path: file_path.clone(), + signature: quote::quote!(#fn_item).to_string(), + error_status: route_info.error_status.clone(), + tags: route_info.tags.clone(), + description, + }); + } + } } } Ok((metadata, file_asts)) } +/// Collect file modification times without reading content. +/// Used for cache invalidation — much cheaper than full `collect_metadata()`. +pub fn collect_file_fingerprints(folder_path: &Path) -> MacroResult> { + let files = collect_files(folder_path).map_err(|e| { + err_call_site(format!( + "vespera! macro: failed to scan route folder '{}': {}", + folder_path.display(), + e + )) + })?; + + let mut fingerprints = HashMap::with_capacity(files.len()); + for file in files { + if file.extension().is_none_or(|e| e != "rs") { + continue; + } + let mtime = std::fs::metadata(&file) + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + }) + .unwrap_or(0); + fingerprints.insert(file.display().to_string(), mtime); + } + Ok(fingerprints) +} + #[cfg(test)] mod tests { use std::fs; @@ -129,7 +207,7 @@ mod tests { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert!(metadata.routes.is_empty()); assert!(metadata.structs.is_empty()); @@ -248,7 +326,7 @@ pub fn get_users() -> String { create_temp_file(&temp_dir, filename, content); } - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); let route = &metadata.routes[0]; assert_eq!(route.method, expected_method); @@ -271,7 +349,7 @@ pub fn get_users() -> String { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let folder_name = "routes"; - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 0); @@ -294,7 +372,7 @@ pub struct User { ", ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 0); assert_eq!(metadata.structs.len(), 0); @@ -326,7 +404,7 @@ pub fn get_user() -> User { "#, ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 1); @@ -368,7 +446,7 @@ pub fn get_posts() -> String { "#, ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 3); assert_eq!(metadata.structs.len(), 0); @@ -419,7 +497,7 @@ pub struct Post { ", ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 0); @@ -442,7 +520,7 @@ pub fn index() -> String { "#, ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; @@ -469,7 +547,7 @@ pub fn get_users() -> String { "#, ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; @@ -498,7 +576,7 @@ pub fn get_users() -> String { create_temp_file(&temp_dir, "readme.md", "# Readme"); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); // Only .rs file should be processed assert_eq!(metadata.routes.len(), 1); @@ -525,7 +603,7 @@ pub fn get_users() -> String { create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {"); - let metadata = collect_metadata(temp_dir.path(), folder_name).map(|(m, _)| m); + let metadata = collect_metadata(temp_dir.path(), folder_name, &[]).map(|(m, _)| m); // Only valid file should be processed assert!(metadata.is_err()); @@ -549,7 +627,7 @@ pub fn get_users() -> String { "#, ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 1); let route = &metadata.routes[0]; @@ -596,7 +674,7 @@ pub fn options_handler() -> String { "options".to_string() } "#, ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); assert_eq!(metadata.routes.len(), 7); @@ -618,7 +696,7 @@ pub fn options_handler() -> String { "options".to_string() } let non_existent_path = std::path::Path::new("/nonexistent/path/that/does/not/exist"); let folder_name = "routes"; - let result = collect_metadata(non_existent_path, folder_name); + let result = collect_metadata(non_existent_path, folder_name, &[]); // Should return error when collect_files fails assert!(result.is_err()); @@ -667,7 +745,7 @@ pub fn get_users() -> String { } // Attempt to collect metadata - should fail with "failed to read route file" error - let result = collect_metadata(temp_dir.path(), folder_name); + let result = collect_metadata(temp_dir.path(), folder_name, &[]); // Verify error message assert!(result.is_err()); @@ -716,7 +794,7 @@ pub fn get() -> String { "ok".to_string() } "#, ); - let result = collect_metadata(temp_dir.path(), folder_name); + let result = collect_metadata(temp_dir.path(), folder_name, &[]); assert!(result.is_ok()); drop(temp_dir); @@ -734,7 +812,7 @@ pub fn get() -> String { "ok".to_string() } create_temp_file(&temp_dir, "invalid.rs", "{{{"); // This should fail during syntax parsing, not file reading - let result = collect_metadata(temp_dir.path(), folder_name); + let result = collect_metadata(temp_dir.path(), folder_name, &[]); assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("syntax error")); @@ -778,7 +856,7 @@ pub fn get_users() -> String { ); // Collect metadata from the subdirectory - let (metadata, _file_asts) = collect_metadata(&sub_dir, folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(&sub_dir, folder_name, &[]).unwrap(); // Should collect the route (strip_prefix succeeds in normal cases) assert_eq!(metadata.routes.len(), 1); @@ -806,7 +884,7 @@ pub struct User { ", ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); // Struct without Schema derive should not be collected assert_eq!(metadata.structs.len(), 0); @@ -832,11 +910,202 @@ pub struct User { ", ); - let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); // Struct with only Debug/Clone derive (no Schema) should not be collected assert_eq!(metadata.structs.len(), 0); drop(temp_dir); } + + #[test] + fn test_collect_metadata_fast_path_with_route_storage() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create a .rs file that the fast path will match against + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" +pub async fn get_users() -> String { + "users".to_string() +} +"#, + ); + + let file_path_str = file_path.display().to_string(); + + // Create StoredRouteInfo entries that match this file + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: Some(vec!["users".to_string()]), + description: Some("Get all users".to_string()), + fn_item_str: "pub async fn get_users() -> String { \"users\".to_string() }".to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, file_asts) = + collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + // Fast path should produce route metadata + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "get_users"); + assert_eq!(route.method, "get"); + assert_eq!(route.tags, Some(vec!["users".to_string()])); + assert_eq!(route.description, Some("Get all users".to_string())); + assert_eq!(route.module_path, "routes::users"); + + // Fast path should NOT insert file ASTs (no parsing needed) + assert!( + file_asts.is_empty(), + "Fast path should not populate file_asts" + ); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_with_custom_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" +pub async fn get_user() -> String { + "user".to_string() +} +"#, + ); + + let file_path_str = file_path.display().to_string(); + + let route_storage = vec![StoredRouteInfo { + fn_name: "get_user".to_string(), + method: Some("get".to_string()), + custom_path: Some("/{id}".to_string()), + error_status: Some(vec![404]), + tags: None, + description: None, + fn_item_str: "pub async fn get_user(id: i32) -> String { \"user\".to_string() }" + .to_string(), + file_path: Some(file_path_str.clone()), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.path, "/users/{id}"); + assert!(route.error_status.is_some()); + assert_eq!(route.error_status.as_ref().unwrap(), &vec![404]); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + let file_path = create_temp_file( + &temp_dir, + "users.rs", + r#" +pub async fn list_users() -> String { + "list".to_string() +} +"#, + ); + + let file_path_str = file_path.display().to_string(); + + let route_storage = vec![StoredRouteInfo { + fn_name: "list_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: None, + fn_item_str: "pub async fn list_users() -> String { \"list\".to_string() }".to_string(), + file_path: Some(file_path_str), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + // With empty folder_name, module_path should be just segments (no prefix) + assert_eq!(route.module_path, "users"); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_fast_path_doc_comment_extraction() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let file_path = create_temp_file(&temp_dir, "items.rs", "// placeholder\n"); + + let file_path_str = file_path.display().to_string(); + + // fn_item_str includes a doc comment, description is None + // so the fast path should extract the doc comment + let route_storage = vec![StoredRouteInfo { + fn_name: "get_items".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: None, // No explicit description -> should extract from doc comment + fn_item_str: + "/// List all items\npub async fn get_items() -> String { \"items\".to_string() }" + .to_string(), + file_path: Some(file_path_str), + }]; + + let (metadata, _) = collect_metadata(temp_dir.path(), folder_name, &route_storage).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + // Description should be extracted from the doc comment in fn_item_str + assert_eq!(route.description, Some("List all items".to_string())); + + drop(temp_dir); + } + + #[test] + fn test_collect_file_fingerprints_skips_non_rs_files() { + // Exercises line 121: non-.rs files should be skipped + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create both .rs and non-.rs files + create_temp_file(&temp_dir, "valid.rs", "pub fn hello() {}"); + create_temp_file(&temp_dir, "readme.txt", "This is a readme"); + create_temp_file(&temp_dir, "data.json", "{}"); + create_temp_file(&temp_dir, "script.py", "print('hello')"); + + let fingerprints = collect_file_fingerprints(temp_dir.path()).unwrap(); + + // Only .rs files should be in fingerprints + assert_eq!( + fingerprints.len(), + 1, + "Only .rs files should be fingerprinted" + ); + let keys: Vec<&String> = fingerprints.keys().collect(); + assert!( + keys[0].ends_with("valid.rs"), + "The only fingerprinted file should be valid.rs" + ); + + drop(temp_dir); + } } diff --git a/crates/vespera_macro/src/file_utils.rs b/crates/vespera_macro/src/file_utils.rs index 381ca9f..b598124 100644 --- a/crates/vespera_macro/src/file_utils.rs +++ b/crates/vespera_macro/src/file_utils.rs @@ -3,35 +3,6 @@ use std::{ path::{Path, PathBuf}, }; -/// Read and parse a Rust source file, printing warnings on error. -#[allow(clippy::similar_names)] -pub fn read_and_parse_file_warn(path: &Path, context: &str) -> Option { - let content = match std::fs::read_to_string(path) { - Ok(c) => c, - Err(e) => { - eprintln!( - "Warning: {}: Cannot read '{}': {}", - context, - path.display(), - e - ); - return None; - } - }; - match syn::parse_file(&content) { - Ok(ast) => Some(ast), - Err(e) => { - eprintln!( - "Warning: {}: Parse error in '{}': {}", - context, - path.display(), - e - ); - None - } - } -} - pub fn collect_files(folder_path: &Path) -> io::Result> { let mut files = Vec::new(); for entry in std::fs::read_dir(folder_path)? { diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 3f7039c..ebed4a5 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -58,6 +58,7 @@ mod schema_macro; mod vespera_impl; use proc_macro::TokenStream; +pub(crate) use route_impl::ROUTE_STORAGE; pub(crate) use schema_impl::SCHEMA_STORAGE; use crate::{ @@ -242,8 +243,11 @@ pub fn vespera(input: TokenStream) -> TokenStream { let schema_storage = SCHEMA_STORAGE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + let route_storage = ROUTE_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); - match process_vespera_macro(&processed, &schema_storage) { + match process_vespera_macro(&processed, &schema_storage, &route_storage) { Ok(tokens) => tokens.into(), Err(e) => e.to_compile_error().into(), } @@ -286,7 +290,17 @@ pub fn export_app(input: TokenStream) -> TokenStream { return syn::Error::new(proc_macro2::Span::call_site(), "export_app! macro: CARGO_MANIFEST_DIR is not set. This macro must be used within a cargo build.").to_compile_error().into(); }; - match process_export_app(&name, &folder_name, &schema_storage, &manifest_dir) { + let route_storage = ROUTE_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + match process_export_app( + &name, + &folder_name, + &schema_storage, + &manifest_dir, + &route_storage, + ) { Ok(tokens) => tokens.into(), Err(e) => e.to_compile_error().into(), } diff --git a/crates/vespera_macro/src/metadata.rs b/crates/vespera_macro/src/metadata.rs index d58abb9..dcd3eab 100644 --- a/crates/vespera_macro/src/metadata.rs +++ b/crates/vespera_macro/src/metadata.rs @@ -1,5 +1,7 @@ //! Metadata collection and storage for routes and schemas +use std::collections::{BTreeMap, HashMap}; + use serde::{Deserialize, Serialize}; /// Route metadata @@ -40,6 +42,11 @@ pub struct StructMetadata { /// - false: from cross-file lookup - only for `schema_type`! source, NOT in openapi.json #[serde(default = "default_include_in_openapi")] pub include_in_openapi: bool, + /// Pre-extracted default values for fields with `#[serde(default = "fn_name")]`. + /// Key: Rust field name, Value: extracted default value. + /// Populated by `#[derive(Schema)]` to avoid AST re-parsing in `vespera!()`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub field_defaults: BTreeMap, } const fn default_include_in_openapi() -> bool { @@ -51,7 +58,8 @@ impl Default for StructMetadata { Self { name: String::new(), definition: String::new(), - include_in_openapi: true, // Default to true (appears in OpenAPI) + include_in_openapi: true, + field_defaults: BTreeMap::new(), } } } @@ -63,6 +71,7 @@ impl StructMetadata { name, definition, include_in_openapi: true, + field_defaults: BTreeMap::new(), } } @@ -72,6 +81,7 @@ impl StructMetadata { name, definition, include_in_openapi: false, + field_defaults: BTreeMap::new(), } } } @@ -92,6 +102,29 @@ impl CollectedMetadata { structs: Vec::new(), } } + + /// Check for duplicate schema names among `include_in_openapi` structs. + /// Returns `Err` with a descriptive message if duplicates are found. + pub fn check_duplicate_schema_names(&self) -> Result<(), String> { + let mut seen: HashMap<&str, usize> = HashMap::new(); + for (i, s) in self.structs.iter().enumerate() { + if !s.include_in_openapi { + continue; + } + if let Some(&prev_idx) = seen.get(s.name.as_str()) { + // Only report if definitions actually differ (identical re-registration is OK) + if self.structs[prev_idx].definition != s.definition { + return Err(format!( + "Duplicate OpenAPI schema name '{}'. Two different structs produce the same schema name, which would corrupt the OpenAPI spec. Rename one of them or use #[schema(name = \"...\")].", + s.name + )); + } + } else { + seen.insert(&s.name, i); + } + } + Ok(()) + } } #[cfg(test)] @@ -148,6 +181,7 @@ mod tests { assert_eq!(original.name, restored.name); assert_eq!(original.definition, restored.definition); assert_eq!(original.include_in_openapi, restored.include_in_openapi); + assert!(restored.field_defaults.is_empty()); } #[test] @@ -156,4 +190,63 @@ mod tests { assert!(meta.routes.is_empty()); assert!(meta.structs.is_empty()); } + + #[test] + fn test_check_duplicate_schema_names_no_duplicates() { + let mut meta = CollectedMetadata::new(); + meta.structs + .push(StructMetadata::new("User".into(), "struct User {}".into())); + meta.structs + .push(StructMetadata::new("Post".into(), "struct Post {}".into())); + assert!(meta.check_duplicate_schema_names().is_ok()); + } + + #[test] + fn test_check_duplicate_schema_names_different_definitions() { + let mut meta = CollectedMetadata::new(); + meta.structs.push(StructMetadata::new( + "User".into(), + "struct User { id: i32 }".into(), + )); + meta.structs.push(StructMetadata::new( + "User".into(), + "struct User { name: String }".into(), + )); + let err = meta.check_duplicate_schema_names().unwrap_err(); + assert!( + err.contains("Duplicate OpenAPI schema name 'User'"), + "got: {err}" + ); + } + + #[test] + fn test_check_duplicate_schema_names_identical_definition_ok() { + let mut meta = CollectedMetadata::new(); + let def = "struct User { id: i32 }".to_string(); + meta.structs + .push(StructMetadata::new("User".into(), def.clone())); + meta.structs.push(StructMetadata::new("User".into(), def)); + assert!(meta.check_duplicate_schema_names().is_ok()); + } + + #[test] + fn test_check_duplicate_schema_names_ignores_models() { + let mut meta = CollectedMetadata::new(); + meta.structs.push(StructMetadata::new_model( + "Model".into(), + "struct Model { id: i32 }".into(), + )); + meta.structs.push(StructMetadata::new_model( + "Model".into(), + "struct Model { name: String }".into(), + )); + // Models (include_in_openapi=false) are not checked + assert!(meta.check_duplicate_schema_names().is_ok()); + } + + #[test] + fn test_check_duplicate_schema_names_empty() { + let meta = CollectedMetadata::new(); + assert!(meta.check_duplicate_schema_names().is_ok()); + } } diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 04c8501..5aaa4b9 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -10,12 +10,12 @@ use vespera_core::{ }; use crate::{ - file_utils::read_and_parse_file_warn, metadata::CollectedMetadata, parser::{ build_operation_from_function, extract_default, extract_field_rename, extract_rename_all, parse_enum_to_schema, parse_struct_to_schema, rename_field, strip_raw_prefix_owned, }, + route_impl::StoredRouteInfo, schema_macro::type_utils::get_type_default as utils_get_type_default, }; @@ -29,6 +29,7 @@ pub fn generate_openapi_doc_with_metadata( servers: Option>, metadata: &CollectedMetadata, file_cache: Option>, + route_storage: &[StoredRouteInfo], ) -> OpenApi { let (known_schema_names, struct_definitions) = build_schema_lookups(metadata); let file_cache = file_cache.unwrap_or_else(|| build_file_cache(metadata)); @@ -47,6 +48,7 @@ pub fn generate_openapi_doc_with_metadata( &known_schema_names, &struct_definitions, &file_cache, + route_storage, ); OpenApi { @@ -126,7 +128,7 @@ fn build_file_cache(metadata: &CollectedMetadata) -> HashMap .collect(); let mut cache = HashMap::with_capacity(unique_paths.len()); for path in unique_paths { - if let Some(ast) = read_and_parse_file_warn(Path::new(path), "OpenAPI generation") { + if let Some(ast) = crate::schema_macro::file_cache::get_parsed_file(Path::new(path)) { cache.insert(path.to_string(), ast); } } @@ -208,7 +210,12 @@ fn parse_component_schemas( }); if let Some(ast) = file_ast { - process_default_functions(struct_item, ast, &mut schema); + process_default_functions( + struct_item, + ast, + &mut schema, + &struct_meta.field_defaults, + ); } } @@ -220,18 +227,30 @@ fn parse_component_schemas( /// Build path items and collect tags from route metadata. /// -/// Uses pre-built `file_cache` to avoid re-reading and re-parsing source files. -/// Each unique file is parsed exactly once in `build_file_cache`. +/// Uses `route_storage` (from `#[route]` macro) as the primary source for function +/// signatures. Falls back to pre-built `file_cache` when ROUTE_STORAGE doesn't +/// have an entry (e.g., during tests or for routes added without the attribute). fn build_path_items( metadata: &CollectedMetadata, known_schema_names: &HashSet, struct_definitions: &HashMap, file_cache: &HashMap, + route_storage: &[StoredRouteInfo], ) -> (BTreeMap, BTreeSet) { let mut paths = BTreeMap::new(); let mut all_tags = BTreeSet::new(); - // Pre-build function name index for O(1) lookup instead of O(items) per route + // Primary source: pre-parse function items from ROUTE_STORAGE (populated by #[route]) + let route_fn_cache: HashMap<&str, syn::ItemFn> = route_storage + .iter() + .filter_map(|s| { + syn::parse_str::(&s.fn_item_str) + .ok() + .map(|item| (s.fn_name.as_str(), item)) + }) + .collect(); + + // Fallback source: function index from file ASTs (for routes not in ROUTE_STORAGE) let fn_index: HashMap<&str, HashMap> = file_cache .iter() .map(|(path, ast)| { @@ -251,17 +270,21 @@ fn build_path_items( .collect(); for route_meta in &metadata.routes { - let Some(fns) = fn_index.get(route_meta.file_path.as_str()) else { - continue; - }; - - let Some(fn_item) = fns.get(&route_meta.function_name) else { + // Try ROUTE_STORAGE first (avoids file_cache dependency for known routes) + let fn_sig = if let Some(cached_fn) = route_fn_cache.get(route_meta.function_name.as_str()) + { + &cached_fn.sig + } else if let Some(fns) = fn_index.get(route_meta.file_path.as_str()) + && let Some(fn_item) = fns.get(&route_meta.function_name) + { + &fn_item.sig + } else { continue; }; let Ok(method) = HttpMethod::try_from(route_meta.method.as_str()) else { eprintln!( - "vespera: skipping route '{}' — unknown HTTP method '{}'", + "vespera: skipping route '{}' \u{2014} unknown HTTP method '{}'", route_meta.path, route_meta.method ); continue; @@ -274,7 +297,7 @@ fn build_path_items( } let mut operation = build_operation_from_function( - &fn_item.sig, + fn_sig, &route_meta.path, known_schema_names, struct_definitions, @@ -320,6 +343,7 @@ fn process_default_functions( struct_item: &syn::ItemStruct, file_ast: &syn::File, schema: &mut vespera_core::schema::Schema, + stored_defaults: &BTreeMap, ) { use syn::Fields; @@ -341,6 +365,12 @@ fn process_default_functions( let field_name = extract_field_rename(&field.attrs) .unwrap_or_else(|| rename_field(&rust_field_name, struct_rename_all.as_deref())); + // Priority 0: Pre-extracted defaults from SCHEMA_STORAGE (populated by #[derive(Schema)]) + if let Some(value) = stored_defaults.get(&rust_field_name) { + set_property_default(properties, &field_name, value.clone()); + continue; + } + // Priority 1: #[schema(default = "value")] from schema_type! macro if let Some(default_str) = extract_schema_default_attr(&field.attrs) { let value = parse_default_string_to_json_value(&default_str); @@ -416,7 +446,7 @@ fn parse_default_string_to_json_value(value: &str) -> serde_json::Value { } /// Find a function by name in the file AST -fn find_function_in_file<'a>( +pub fn find_function_in_file<'a>( file_ast: &'a syn::File, function_name: &str, ) -> Option<&'a syn::ItemFn> { @@ -432,7 +462,7 @@ fn find_function_in_file<'a>( /// - 42 -> 42 /// - true -> true /// - vec![] -> [] -fn extract_default_value_from_function(func: &syn::ItemFn) -> Option { +pub fn extract_default_value_from_function(func: &syn::ItemFn) -> Option { // Try to find return statement or expression for stmt in &func.block.stmts { if let syn::Stmt::Expr(expr, _) = stmt { @@ -454,7 +484,7 @@ fn extract_default_value_from_function(func: &syn::ItemFn) -> Option Option { +pub fn extract_value_from_expr(expr: &syn::Expr) -> Option { use syn::{Expr, ExprLit, ExprMacro, Lit}; match expr { @@ -523,7 +553,7 @@ mod tests { fn test_generate_openapi_empty_metadata() { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert_eq!(doc.openapi, OpenApiVersion::V3_1_0); assert_eq!(doc.info.title, "API"); @@ -550,7 +580,7 @@ mod tests { ) { let metadata = CollectedMetadata::new(); - let doc = generate_openapi_doc_with_metadata(title, version, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(title, version, None, &metadata, None, &[]); assert_eq!(doc.info.title, expected_title); assert_eq!(doc.info.version, expected_version); @@ -581,7 +611,7 @@ pub fn get_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert!(doc.paths.contains_key("/users")); let path_item = doc.paths.get("/users").unwrap(); @@ -599,7 +629,7 @@ pub fn get_users() -> String { ..Default::default() }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -615,7 +645,7 @@ pub fn get_users() -> String { ..Default::default() }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -632,7 +662,7 @@ pub fn get_users() -> String { ..Default::default() }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -668,7 +698,7 @@ pub fn get_status() -> Status { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // Check enum schema assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -692,10 +722,11 @@ pub fn get_status() -> Status { // which now safely skips this item instead of panicking definition: "const CONFIG: i32 = 42;".to_string(), include_in_openapi: true, + field_defaults: BTreeMap::new(), }); // This should gracefully handle the invalid item (skip it) instead of panicking - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // The invalid struct definition should be skipped, resulting in no schemas assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); } @@ -736,6 +767,7 @@ pub fn get_user() -> User { None, &metadata, None, + &[], ); // Check struct schema @@ -791,7 +823,7 @@ pub fn create_user() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert_eq!(doc.paths.len(), 1); // Same path, different methods let path_item = doc.paths.get("/users").unwrap(); @@ -860,7 +892,7 @@ pub fn create_user() -> String { } // Should not panic, just skip invalid files - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // Check struct if expect_struct { @@ -905,7 +937,7 @@ pub fn get_users() -> String { description: Some("Get all users".to_string()), }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // Check route has description let path_item = doc.paths.get("/users").unwrap(); @@ -935,7 +967,8 @@ pub fn get_users() -> String { }, ]; - let doc = generate_openapi_doc_with_metadata(None, None, Some(servers), &metadata, None); + let doc = + generate_openapi_doc_with_metadata(None, None, Some(servers), &metadata, None, &[]); assert!(doc.servers.is_some()); let doc_servers = doc.servers.unwrap(); @@ -1179,7 +1212,7 @@ pub fn get_user() -> User { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // Struct should be present assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -1225,7 +1258,7 @@ pub fn get_config() -> Config { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert!(doc.components.as_ref().unwrap().schemas.is_some()); let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); @@ -1296,7 +1329,7 @@ pub fn get_user() -> User { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // Struct should be found via fallback and processed assert!(doc.components.as_ref().unwrap().schemas.is_some()); @@ -1316,7 +1349,7 @@ pub fn get_user() -> User { schema.properties = None; // Explicitly set to None // This should return early without panic - process_default_functions(&struct_item, &file_ast, &mut schema); + process_default_functions(&struct_item, &file_ast, &mut schema, &BTreeMap::new()); // Schema should remain unchanged assert!(schema.properties.is_none()); @@ -1435,7 +1468,7 @@ pub fn get_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // Route with unknown HTTP method should be skipped entirely assert!( @@ -1487,7 +1520,7 @@ pub fn create_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // Only the valid POST route should appear assert_eq!(doc.paths.len(), 1); @@ -1512,10 +1545,11 @@ pub fn create_users() -> String { // Invalid Rust syntax - cannot be parsed by syn definition: "struct { invalid syntax {{{{".to_string(), include_in_openapi: true, + field_defaults: BTreeMap::new(), }); // Should gracefully skip unparseable definitions - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); // The unparseable definition should be skipped assert!(doc.components.is_none() || doc.components.as_ref().unwrap().schemas.is_none()); } @@ -1669,7 +1703,7 @@ pub fn create_users() -> String { "count".to_string(), SchemaRef::Inline(Box::new(Schema::integer())), ); - process_default_functions(&struct_item, &file_ast, &mut schema); + process_default_functions(&struct_item, &file_ast, &mut schema, &BTreeMap::new()); if let Some(SchemaRef::Inline(prop_schema)) = schema.properties.as_ref().unwrap().get("count") { @@ -1698,10 +1732,114 @@ pub fn create_users() -> String { description: None, }); - let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None); + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); assert!( doc.paths.is_empty(), "Route with non-matching function should be skipped" ); } + + #[test] + fn test_generate_openapi_with_route_storage_fast_path() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_content = r#" +pub fn get_users() -> String { + "users".to_string() +} +"#; + let route_file = create_temp_file(&temp_dir, "users.rs", route_content); + + 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: "test::users".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_users() -> String".to_string(), + error_status: None, + tags: None, + description: None, + }); + + // Provide route_storage with matching fn_name -> exercises fast path (line 155) + let route_storage = vec![StoredRouteInfo { + fn_name: "get_users".to_string(), + method: Some("get".to_string()), + custom_path: None, + error_status: None, + tags: None, + description: None, + fn_item_str: "pub fn get_users() -> String { \"users\".to_string() }".to_string(), + file_path: None, + }]; + + let doc = + generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &route_storage); + + assert!(doc.paths.contains_key("/users")); + let path_item = doc.paths.get("/users").unwrap(); + assert!(path_item.get.is_some()); + let operation = path_item.get.as_ref().unwrap(); + assert_eq!(operation.operation_id, Some("get_users".to_string())); + } + + #[test] + fn test_generate_openapi_with_stored_field_defaults() { + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Config".to_string(), + definition: "struct Config { count: i32, name: String }".to_string(), + include_in_openapi: true, + field_defaults: BTreeMap::from([ + ("count".to_string(), serde_json::json!(42)), + ("name".to_string(), serde_json::json!("default_name")), + ]), + }); + + // Need a route so the file_cache has at least one entry for the fallback in parse_component_schemas + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_content = r" +struct Config { count: i32, name: String } +pub fn get_config() -> Config { Config { count: 0, name: String::new() } } +"; + let route_file = create_temp_file(&temp_dir, "config.rs", route_content); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/config".to_string(), + function_name: "get_config".to_string(), + module_path: "test::config".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_config() -> Config".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None, &[]); + + // Verify schema exists + assert!(doc.components.as_ref().unwrap().schemas.is_some()); + let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); + let config_schema = schemas.get("Config").expect("Config schema should exist"); + + // Verify default values were set from stored_defaults (Priority 0 path) + if let Some(props) = &config_schema.properties { + if let Some(vespera_core::schema::SchemaRef::Inline(count_schema)) = props.get("count") + { + assert_eq!( + count_schema.default, + Some(serde_json::json!(42)), + "count should have default 42 from stored_defaults" + ); + } + if let Some(vespera_core::schema::SchemaRef::Inline(name_schema)) = props.get("name") { + assert_eq!( + name_schema.default, + Some(serde_json::json!("default_name")), + "name should have default from stored_defaults" + ); + } + } + } } diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index f41a05c..5fd3447 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -14,6 +14,11 @@ use crate::schema_macro::type_utils::{ is_map_type as utils_is_map_type, is_primitive_like as utils_is_primitive_like, }; +/// Combined check: type is either a JSON-schema primitive or a known container type. +fn is_primitive_or_like(ty: &Type) -> bool { + is_primitive_type(ty) || utils_is_primitive_like(ty) +} + /// Convert `SchemaRef` for query parameters, adding nullable flag if optional. /// Preserves `$ref` for known types (e.g. enums) — only wraps with nullable when optional. fn convert_to_inline_schema(field_schema: SchemaRef, is_optional: bool) -> SchemaRef { @@ -192,8 +197,7 @@ pub fn parse_function_parameter( } // Ignore primitive-like query params (including Vec/Option of primitive) - if is_primitive_type(inner_ty) || utils_is_primitive_like(inner_ty) - { + if is_primitive_or_like(inner_ty) { return None; } @@ -225,8 +229,7 @@ pub fn parse_function_parameter( args.args.first() { // Ignore primitive-like headers - if is_primitive_type(inner_ty) || utils_is_primitive_like(inner_ty) - { + if is_primitive_or_like(inner_ty) { return None; } return Some(vec![Parameter { @@ -706,7 +709,7 @@ mod tests { #[case("CustomType", false)] fn test_is_primitive_like_fn(#[case] type_str: &str, #[case] expected: bool) { let ty: Type = syn::parse_str(type_str).unwrap(); - let result = is_primitive_type(&ty) || utils_is_primitive_like(&ty); + let result = is_primitive_or_like(&ty); assert_eq!(result, expected, "type_str={type_str}"); } diff --git a/crates/vespera_macro/src/parser/schema/type_schema.rs b/crates/vespera_macro/src/parser/schema/type_schema.rs index d1660f1..10a3a47 100644 --- a/crates/vespera_macro/src/parser/schema/type_schema.rs +++ b/crates/vespera_macro/src/parser/schema/type_schema.rs @@ -26,32 +26,39 @@ use super::{ }; /// Check if a type is a primitive Rust type that maps directly to a JSON Schema type. +/// Inline integer schema with an OpenAPI format string. +fn integer_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::integer() + })) +} + +/// Inline number schema with an OpenAPI format string. +fn number_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::number() + })) +} + +/// Inline string schema with an OpenAPI format string. +fn string_with_format(format: &str) -> SchemaRef { + SchemaRef::Inline(Box::new(Schema { + format: Some(format.to_string()), + ..Schema::string() + })) +} + pub fn is_primitive_type(ty: &Type) -> bool { match ty { Type::Path(type_path) => { let path = &type_path.path; if path.segments.len() == 1 { let ident = path.segments[0].ident.to_string(); - matches!( - ident.as_str(), - "i8" | "i16" - | "i32" - | "i64" - | "i128" - | "isize" - | "u8" - | "u16" - | "u32" - | "u64" - | "u128" - | "usize" - | "f32" - | "f64" - | "bool" - | "String" - | "str" - | "Decimal" - ) + ident == "str" + || crate::schema_macro::type_utils::PRIMITIVE_TYPE_NAMES + .contains(&ident.as_str()) } else { false } @@ -233,39 +240,15 @@ fn parse_type_impl( match ident_str.as_str() { // Signed integers: use OpenAPI format registry // https://spec.openapis.org/registry/format/index.html - "i8" => SchemaRef::Inline(Box::new(Schema { - format: Some("int8".to_string()), - ..Schema::integer() - })), - "i16" => SchemaRef::Inline(Box::new(Schema { - format: Some("int16".to_string()), - ..Schema::integer() - })), - "i32" => SchemaRef::Inline(Box::new(Schema { - format: Some("int32".to_string()), - ..Schema::integer() - })), - "i64" => SchemaRef::Inline(Box::new(Schema { - format: Some("int64".to_string()), - ..Schema::integer() - })), + "i8" => integer_with_format("int8"), + "i16" => integer_with_format("int16"), + "i32" => integer_with_format("int32"), + "i64" => integer_with_format("int64"), // Unsigned integers: use OpenAPI format registry - "u8" => SchemaRef::Inline(Box::new(Schema { - format: Some("uint8".to_string()), - ..Schema::integer() - })), - "u16" => SchemaRef::Inline(Box::new(Schema { - format: Some("uint16".to_string()), - ..Schema::integer() - })), - "u32" => SchemaRef::Inline(Box::new(Schema { - format: Some("uint32".to_string()), - ..Schema::integer() - })), - "u64" => SchemaRef::Inline(Box::new(Schema { - format: Some("uint64".to_string()), - ..Schema::integer() - })), + "u8" => integer_with_format("uint8"), + "u16" => integer_with_format("uint16"), + "u32" => integer_with_format("uint32"), + "u64" => integer_with_format("uint64"), // i128, isize, StatusCode: no standard format in the registry "i128" | "isize" | "StatusCode" => SchemaRef::Inline(Box::new(Schema::integer())), // u128, usize: unsigned with no standard format — use minimum: 0 @@ -273,27 +256,12 @@ fn parse_type_impl( minimum: Some(0.0), ..Schema::integer() })), - "f32" => SchemaRef::Inline(Box::new(Schema { - format: Some("float".to_string()), - ..Schema::number() - })), - "f64" => SchemaRef::Inline(Box::new(Schema { - format: Some("double".to_string()), - ..Schema::number() - })), - "Decimal" => SchemaRef::Inline(Box::new(Schema { - format: Some("decimal".to_string()), - ..Schema::number() - })), + "f32" => number_with_format("float"), + "f64" => number_with_format("double"), + "Decimal" => number_with_format("decimal"), "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), - "char" => SchemaRef::Inline(Box::new(Schema { - format: Some("char".to_string()), - ..Schema::string() - })), - "Uuid" => SchemaRef::Inline(Box::new(Schema { - format: Some("uuid".to_string()), - ..Schema::string() - })), + "char" => string_with_format("char"), + "Uuid" => string_with_format("uuid"), "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), // Date-time types from chrono and time crates "DateTime" @@ -302,29 +270,14 @@ fn parse_type_impl( | "DateTimeUtc" | "DateTimeLocal" | "OffsetDateTime" - | "PrimitiveDateTime" => SchemaRef::Inline(Box::new(Schema { - format: Some("date-time".to_string()), - ..Schema::string() - })), - "NaiveDate" | "Date" => SchemaRef::Inline(Box::new(Schema { - format: Some("date".to_string()), - ..Schema::string() - })), - "NaiveTime" | "Time" => SchemaRef::Inline(Box::new(Schema { - format: Some("time".to_string()), - ..Schema::string() - })), + | "PrimitiveDateTime" => string_with_format("date-time"), + "NaiveDate" | "Date" => string_with_format("date"), + "NaiveTime" | "Time" => string_with_format("time"), // Duration types - "Duration" => SchemaRef::Inline(Box::new(Schema { - format: Some("duration".to_string()), - ..Schema::string() - })), + "Duration" => string_with_format("duration"), // File upload types (axum_typed_multipart / tempfile) // FieldData → string with binary format - "FieldData" | "NamedTempFile" => SchemaRef::Inline(Box::new(Schema { - format: Some("binary".to_string()), - ..Schema::string() - })), + "FieldData" | "NamedTempFile" => string_with_format("binary"), // Standard library types that should not be referenced // Note: HashMap and BTreeMap are handled above in generic types "Vec" | "HashSet" | "BTreeSet" | "Option" | "Result" | "Json" | "Path" diff --git a/crates/vespera_macro/src/route_impl.rs b/crates/vespera_macro/src/route_impl.rs index ea2284c..315fda3 100644 --- a/crates/vespera_macro/src/route_impl.rs +++ b/crates/vespera_macro/src/route_impl.rs @@ -32,7 +32,81 @@ //! } //! ``` +use std::sync::{LazyLock, Mutex}; + use crate::args; +/// Metadata stored by `#[route]` for later consumption by `vespera!()`. +/// +/// Each invocation of `#[route]` pushes one entry into [`ROUTE_STORAGE`]. +/// The `vespera!()` macro reads this storage to supplement file-based route discovery. +#[derive(Debug, Clone)] +pub struct StoredRouteInfo { + /// Function name (e.g., `"get_user"`) + pub fn_name: String, + /// HTTP method — stored for Phase 3 (skip file re-parsing) + #[allow(dead_code)] + pub method: Option, + /// Custom path from `path = "/{id}"` — stored for Phase 3 + #[allow(dead_code)] + pub custom_path: Option, + /// Additional error status codes from `error_status = [400, 404]` + pub error_status: Option>, + /// Tags for `OpenAPI` grouping from `tags = ["users"]` + pub tags: Option>, + /// Description from `description = "Get user by ID"` + pub description: Option, + /// Source file path from `Span::call_site().local_file()` (requires Rust 1.88+) + /// `None` on older Rust — collector falls back to full file parsing. + pub file_path: Option, + /// Full function item as string for later AST re-parsing (Phase 3) + #[allow(dead_code)] + pub fn_item_str: String, +} + +/// Global storage for route metadata collected by `#[route]` attribute macros. +/// Read by `vespera!()` to supplement file-based route discovery. +pub static ROUTE_STORAGE: LazyLock>> = + LazyLock::new(|| Mutex::new(Vec::new())); + +/// Extract `u16` error status codes from a `syn::ExprArray`. +fn extract_error_status_codes(arr: &syn::ExprArray) -> Option> { + let codes: Vec = arr + .elems + .iter() + .filter_map(|elem| { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = elem + { + lit_int.base10_parse::().ok() + } else { + None + } + }) + .collect(); + if codes.is_empty() { None } else { Some(codes) } +} + +/// Extract `String` tags from a `syn::ExprArray`. +fn extract_tag_strings(arr: &syn::ExprArray) -> Option> { + let tags: Vec = arr + .elems + .iter() + .filter_map(|elem| { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = elem + { + Some(lit_str.value()) + } else { + None + } + }) + .collect(); + if tags.is_empty() { None } else { Some(tags) } +} /// Validate route function - must be pub and async pub fn validate_route_fn(item_fn: &syn::ItemFn) -> Result<(), syn::Error> { @@ -56,9 +130,31 @@ pub fn process_route_attribute( attr: proc_macro2::TokenStream, item: proc_macro2::TokenStream, ) -> syn::Result { - syn::parse2::(attr)?; + let route_args = syn::parse2::(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, not other items. Move or remove the attribute."))?; validate_route_fn(&item_fn)?; + + // Store route metadata for later consumption by vespera!() macro + let stored = StoredRouteInfo { + fn_name: item_fn.sig.ident.to_string(), + method: route_args.method.as_ref().map(syn::Ident::to_string), + custom_path: route_args.path.as_ref().map(syn::LitStr::value), + error_status: route_args + .error_status + .as_ref() + .and_then(extract_error_status_codes), + tags: route_args.tags.as_ref().and_then(extract_tag_strings), + description: route_args.description.as_ref().map(syn::LitStr::value), + fn_item_str: item.to_string(), + file_path: proc_macro2::Span::call_site() + .local_file() + .map(|p| p.display().to_string()), + }; + ROUTE_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(stored); + Ok(item) } @@ -221,4 +317,101 @@ mod tests { assert!(result.is_ok(), "Method {method} should be valid"); } } + + // ========== Tests for ROUTE_STORAGE population ========== + + #[test] + fn test_route_storage_populated_by_process_route_attribute() { + let attr = quote!( + get, + path = "/{id}", + tags = ["users"], + description = "Get user by ID", + error_status = [404] + ); + let item = quote!( + pub async fn get_user_test_storage() -> String { + "test".to_string() + } + ); + let result = process_route_attribute(attr, item); + assert!(result.is_ok()); + + // Find our entry by unique fn_name (ROUTE_STORAGE is global, shared across parallel tests) + let storage = ROUTE_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + // Find our entry and verify fields + let stored = storage + .iter() + .find(|s| s.fn_name == "get_user_test_storage"); + assert!( + stored.is_some(), + "StoredRouteInfo should be in ROUTE_STORAGE" + ); + let stored = stored.unwrap(); + assert_eq!(stored.method, Some("get".to_string())); + assert_eq!(stored.custom_path, Some("/{id}".to_string())); + assert_eq!(stored.tags, Some(vec!["users".to_string()])); + assert_eq!(stored.description, Some("Get user by ID".to_string())); + assert_eq!(stored.error_status, Some(vec![404])); + assert!(stored.fn_item_str.contains("get_user_test_storage")); + } + + #[test] + fn test_route_storage_no_optional_fields() { + let attr = quote!(); + let item = quote!( + pub async fn minimal_handler_test() -> String { + "test".to_string() + } + ); + let result = process_route_attribute(attr, item); + assert!(result.is_ok()); + + let storage = ROUTE_STORAGE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + let stored = storage.iter().find(|s| s.fn_name == "minimal_handler_test"); + assert!(stored.is_some()); + let stored = stored.unwrap(); + assert_eq!(stored.method, None); + assert_eq!(stored.custom_path, None); + assert_eq!(stored.tags, None); + assert_eq!(stored.description, None); + assert_eq!(stored.error_status, None); + } + + #[test] + fn test_extract_error_status_codes_empty() { + let arr: syn::ExprArray = syn::parse_quote!([]); + assert_eq!(extract_error_status_codes(&arr), None); + } + + #[test] + fn test_extract_error_status_codes_values() { + let arr: syn::ExprArray = syn::parse_quote!([400, 404, 500]); + assert_eq!(extract_error_status_codes(&arr), Some(vec![400, 404, 500])); + } + + #[test] + fn test_extract_tag_strings_empty() { + let arr: syn::ExprArray = syn::parse_quote!([]); + assert_eq!(extract_tag_strings(&arr), None); + } + + #[test] + fn test_extract_tag_strings_values() { + let arr: syn::ExprArray = syn::parse_quote!(["users", "admin", "api"]); + assert_eq!( + extract_tag_strings(&arr), + Some(vec![ + "users".to_string(), + "admin".to_string(), + "api".to_string() + ]) + ); + } } diff --git a/crates/vespera_macro/src/router_codegen.rs b/crates/vespera_macro/src/router_codegen.rs index 8370113..fa3b748 100644 --- a/crates/vespera_macro/src/router_codegen.rs +++ b/crates/vespera_macro/src/router_codegen.rs @@ -428,6 +428,52 @@ impl Parse for ExportAppInput { } } +/// Swagger UI HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +const SWAGGER_UI_HTML: &str = r#"Swagger UI
"#; + +/// ReDoc HTML template. Contains `{}` format placeholder for the OpenAPI spec JSON. +const REDOC_HTML: &str = r#"ReDoc
"#; + +/// Generate a documentation route handler (Swagger UI or ReDoc). +/// +/// When `has_merge` is true, the handler merges specs from child apps at runtime. +/// When false, it serves the spec directly from the compile-time constant. +fn generate_docs_route_tokens( + url: &str, + html_template: &str, + merge_spec_code: &[proc_macro2::TokenStream], + has_merge: bool, +) -> proc_macro2::TokenStream { + let method_path = http_method_to_token_stream(HttpMethod::Get); + + if has_merge { + quote!( + .route(#url, #method_path(|| async { + static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); + let spec = MERGED_SPEC.get_or_init(|| { + let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap(); + #(#merge_spec_code)* + vespera::serde_json::to_string(&merged).unwrap() + }); + static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); + let html = HTML.get_or_init(|| { + format!(#html_template, spec) + }); + vespera::axum::response::Html(html.as_str()) + })) + ) + } else { + quote!( + .route(#url, #method_path(|| async { + static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); + let html = HTML.get_or_init(|| { + format!(#html_template, __VESPERA_SPEC) + }); + vespera::axum::response::Html(html.as_str()) + })) + ) + } +} /// Generate Axum router code from collected metadata #[allow(clippy::too_many_lines)] pub fn generate_router_code( @@ -490,79 +536,21 @@ pub fn generate_router_code( .collect(); if let Some(docs_url) = docs_url { - let method_path = http_method_to_token_stream(HttpMethod::Get); - - if has_merge { - router_nests.push(quote!( - .route(#docs_url, #method_path(|| async { - static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); - let spec = MERGED_SPEC.get_or_init(|| { - let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap(); - #(#merge_spec_code)* - vespera::serde_json::to_string(&merged).unwrap() - }); - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!( - r#"Swagger UI
"#, - spec - ) - }); - vespera::axum::response::Html(html.as_str()) - })) - )); - } else { - router_nests.push(quote!( - .route(#docs_url, #method_path(|| async { - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!( - r#"Swagger UI
"#, - __VESPERA_SPEC - ) - }); - vespera::axum::response::Html(html.as_str()) - })) - )); - } + router_nests.push(generate_docs_route_tokens( + docs_url, + SWAGGER_UI_HTML, + &merge_spec_code, + has_merge, + )); } if let Some(redoc_url) = redoc_url { - let method_path = http_method_to_token_stream(HttpMethod::Get); - - if has_merge { - router_nests.push(quote!( - .route(#redoc_url, #method_path(|| async { - static MERGED_SPEC: std::sync::OnceLock = std::sync::OnceLock::new(); - let spec = MERGED_SPEC.get_or_init(|| { - let mut merged: vespera::OpenApi = vespera::serde_json::from_str(__VESPERA_SPEC).unwrap(); - #(#merge_spec_code)* - vespera::serde_json::to_string(&merged).unwrap() - }); - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!( - r#"ReDoc
"#, - spec - ) - }); - vespera::axum::response::Html(html.as_str()) - })) - )); - } else { - router_nests.push(quote!( - .route(#redoc_url, #method_path(|| async { - static HTML: std::sync::OnceLock = std::sync::OnceLock::new(); - let html = HTML.get_or_init(|| { - format!( - r#"ReDoc
"#, - __VESPERA_SPEC - ) - }); - vespera::axum::response::Html(html.as_str()) - })) - )); - } + router_nests.push(generate_docs_route_tokens( + redoc_url, + REDOC_HTML, + &merge_spec_code, + has_merge, + )); } let needs_spec_const = spec_tokens.is_some() && (docs_url.is_some() || redoc_url.is_some()); @@ -633,7 +621,9 @@ mod tests { let folder_name = "routes"; let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap().0, + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, None, None, None, @@ -790,7 +780,9 @@ pub fn get_users() -> String { } let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap().0, + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, None, None, None, @@ -870,7 +862,9 @@ pub fn update_user() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap().0, + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, None, None, None, @@ -925,7 +919,9 @@ pub fn create_users() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap().0, + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, None, None, None, @@ -972,7 +968,9 @@ pub fn index() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap().0, + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, None, None, None, @@ -1010,7 +1008,9 @@ pub fn get_users() -> String { ); let result = generate_router_code( - &collect_metadata(temp_dir.path(), folder_name).unwrap().0, + &collect_metadata(temp_dir.path(), folder_name, &[]) + .unwrap() + .0, None, None, None, @@ -1366,7 +1366,8 @@ pub fn get_users() -> String { "#, ); - let (mut metadata, _file_asts) = collect_metadata(temp_dir.path(), folder_name).unwrap(); + let (mut metadata, _file_asts) = + collect_metadata(temp_dir.path(), folder_name, &[]).unwrap(); // Inject an additional route with invalid method metadata.routes.push(crate::metadata::RouteMetadata { method: "CONNECT".to_string(), diff --git a/crates/vespera_macro/src/schema_impl.rs b/crates/vespera_macro/src/schema_impl.rs index 9dff737..05bc9b2 100644 --- a/crates/vespera_macro/src/schema_impl.rs +++ b/crates/vespera_macro/src/schema_impl.rs @@ -31,7 +31,8 @@ //! - [`process_derive_schema`] - Process the derive macro input and register the type use std::{ - collections::HashMap, + collections::{BTreeMap, HashMap}, + path::Path, sync::{LazyLock, Mutex}, }; @@ -70,11 +71,89 @@ pub fn process_derive_schema( // Check for custom schema name from #[schema(name = "...")] attribute let schema_name = extract_schema_name_attr(&input.attrs).unwrap_or_else(|| name.to_string()); + // Extract default values from serde(default = "fn_name") attributes at derive time. + // Span::call_site().local_file() returns None in unit tests — the map/unwrap_or_default + // chain ensures the line is always executed even when the closure is not entered. + let field_defaults = proc_macro2::Span::call_site() + .local_file() + .map(|file_path| extract_field_defaults_from_path(input, &file_path)) + .unwrap_or_default(); + // Schema-derived types appear in OpenAPI spec (include_in_openapi: true) - let metadata = StructMetadata::new(schema_name, quote::quote!(#input).to_string()); + let mut metadata = StructMetadata::new(schema_name, quote::quote!(#input).to_string()); + metadata.field_defaults = field_defaults; (metadata, proc_macro2::TokenStream::new()) } +/// Extract default values from `#[serde(default = "fn_name")]` attributes +/// using the given source file path. +/// +/// Separated from [`extract_field_defaults`] for testability: `Span::call_site().local_file()` +/// returns `None` in unit tests, so this function accepts the path directly. +pub fn extract_field_defaults_from_path( + input: &syn::DeriveInput, + file_path: &Path, +) -> BTreeMap { + let mut defaults = BTreeMap::new(); + + let fields = match &input.data { + syn::Data::Struct(data) => match &data.fields { + syn::Fields::Named(named) => &named.named, + _ => return defaults, + }, + _ => return defaults, + }; + + // Collect fields with function-based defaults + let fn_defaults: Vec<(String, String)> = fields + .iter() + .filter_map(|f| { + let field_name = f.ident.as_ref()?.to_string(); + if let Some(Some(fn_name)) = crate::parser::extract_default(&f.attrs) { + // Only handle simple function names (not paths like "crate::utils::default") + if fn_name.contains("::") { + None + } else { + Some((field_name, fn_name)) + } + } else { + None + } + }) + .collect(); + + if fn_defaults.is_empty() { + return defaults; + } + + // Read and parse the file (cached via FileCache parsed_file_asts) + let Some(file_ast) = crate::schema_macro::file_cache::get_parsed_file(file_path) else { + return defaults; + }; + + // Extract default values from functions + defaults.extend(extract_defaults_from_file(&fn_defaults, &file_ast)); + defaults +} + +/// Extract default values by finding functions in the given file AST. +/// Separated from `extract_field_defaults` for testability (proc_macro2::Span +/// is not available in unit tests). +pub fn extract_defaults_from_file( + fn_defaults: &[(String, String)], + file_ast: &syn::File, +) -> BTreeMap { + let mut defaults = BTreeMap::new(); + for (field_name, fn_name) in fn_defaults { + if let Some(func) = crate::openapi_generator::find_function_in_file(file_ast, fn_name) + && let Some(value) = crate::openapi_generator::extract_default_value_from_function(func) + { + defaults.insert(field_name.clone(), value); + } + } + defaults +} + #[cfg(test)] mod tests { use super::*; @@ -200,6 +279,56 @@ mod tests { assert_eq!(result, None); } + #[test] + fn test_extract_defaults_from_file_finds_functions() { + // Directly tests the extracted function (covers lines 123-131) + let file_ast: syn::File = syn::parse_quote! { + fn default_count() -> i32 { 42 } + fn default_name() -> String { "hello".to_string() } + }; + let fn_defaults = vec![ + ("count".to_string(), "default_count".to_string()), + ("name".to_string(), "default_name".to_string()), + ]; + let result = extract_defaults_from_file(&fn_defaults, &file_ast); + assert_eq!(result.get("count"), Some(&serde_json::json!(42))); + assert_eq!(result.get("name"), Some(&serde_json::json!("hello"))); + } + + #[test] + fn test_extract_defaults_from_file_missing_function() { + // Function not found in AST -> skipped + let file_ast: syn::File = syn::parse_quote! { + fn other_function() -> i32 { 0 } + }; + let fn_defaults = vec![("count".to_string(), "nonexistent_fn".to_string())]; + let result = extract_defaults_from_file(&fn_defaults, &file_ast); + assert!(result.is_empty()); + } + + #[test] + fn test_extract_defaults_from_file_non_extractable_value() { + // Function exists but returns an assignment statement or block (not directly extractable) + let file_ast: syn::File = syn::parse_quote! { + fn default_value() -> String { + let x = String::new(); + x // Assignment before return - block statement + } + }; + let fn_defaults = vec![("value".to_string(), "default_value".to_string())]; + let result = extract_defaults_from_file(&fn_defaults, &file_ast); + // Block statements with multiple statements are not extractable + assert!(result.is_empty()); + } + + #[test] + fn test_extract_defaults_from_file_empty_input() { + let file_ast: syn::File = syn::parse_quote! {}; + let fn_defaults: Vec<(String, String)> = vec![]; + let result = extract_defaults_from_file(&fn_defaults, &file_ast); + assert!(result.is_empty()); + } + #[test] fn test_extract_schema_name_attr_multiple_schema_attrs() { // Two #[schema] attrs — first one with name wins @@ -383,4 +512,98 @@ mod tests { storage.remove(&key); } } + + #[test] + fn test_extract_field_defaults_from_path_with_default_fn() { + // Exercises lines 125-133 (was 118-119, 123-124 before refactor): + // get_parsed_file succeeds and extract_defaults_from_file runs. + let temp_dir = tempfile::TempDir::new().unwrap(); + let file_path = temp_dir.path().join("defaults.rs"); + std::fs::write( + &file_path, + r#" +fn default_status() -> String { + "active".to_string() +} + +struct Config { + #[serde(default = "default_status")] + status: String, +} +"#, + ) + .unwrap(); + + let input: syn::DeriveInput = syn::parse_quote! { + struct Config { + #[serde(default = "default_status")] + status: String, + } + }; + + let defaults = extract_field_defaults_from_path(&input, &file_path); + // The function should find default_status and extract its return value + assert!( + defaults.contains_key("status"), + "Should extract default for 'status' field" + ); + } + + #[test] + fn test_extract_field_defaults_from_path_file_not_found() { + // Exercises the else branch: get_parsed_file returns None for non-existent file + let input: syn::DeriveInput = syn::parse_quote! { + struct Config { + #[serde(default = "default_val")] + value: String, + } + }; + + let defaults = + extract_field_defaults_from_path(&input, Path::new("/nonexistent/path/foo.rs")); + assert!( + defaults.is_empty(), + "Should return empty defaults when file not found" + ); + } + + #[test] + fn test_extract_field_defaults_from_path_no_fn_defaults() { + // Exercises the early return: fn_defaults is empty + let temp_dir = tempfile::TempDir::new().unwrap(); + let file_path = temp_dir.path().join("simple.rs"); + std::fs::write(&file_path, "struct Foo { x: i32 }").unwrap(); + + let input: syn::DeriveInput = syn::parse_quote! { + struct Foo { + x: i32, + } + }; + + let defaults = extract_field_defaults_from_path(&input, &file_path); + assert!(defaults.is_empty(), "No serde defaults -> empty result"); + } + + #[test] + fn test_extract_field_defaults_from_path_tuple_struct() { + // Exercises line 101: Fields::Named else branch (tuple struct has unnamed fields) + let input: syn::DeriveInput = syn::parse_quote! { + struct Pair(String, i32); + }; + let defaults = extract_field_defaults_from_path(&input, Path::new("/dummy.rs")); + assert!( + defaults.is_empty(), + "Tuple struct should return empty defaults" + ); + } + + #[test] + fn test_extract_field_defaults_from_path_enum() { + // Exercises line 103: Data::Struct else branch (enum) + let input: syn::DeriveInput = syn::parse_quote! { + enum Status { Active, Inactive } + }; + let defaults = extract_field_defaults_from_path(&input, Path::new("/dummy.rs")); + assert!(defaults.is_empty(), "Enum should return empty defaults"); + } } diff --git a/crates/vespera_macro/src/schema_macro/circular.rs b/crates/vespera_macro/src/schema_macro/circular.rs index 2d6f94b..70cbfe9 100644 --- a/crates/vespera_macro/src/schema_macro/circular.rs +++ b/crates/vespera_macro/src/schema_macro/circular.rs @@ -59,9 +59,9 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> .last() .map_or("", std::string::String::as_str); - let mut circular_fields = Vec::new(); + let mut circular_fields = Vec::with_capacity(fields_named.named.len()); let mut has_fk = false; - let mut circular_field_required = HashMap::new(); + let mut circular_field_required = HashMap::with_capacity(fields_named.named.len()); // Pre-build field name → &Field index for O(1) FK column lookup // instead of O(N) linear search per FK relation @@ -70,6 +70,11 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> .iter() .filter_map(|f| f.ident.as_ref().map(|id| (id.to_string(), f))) .collect(); + // Precompute format strings used for circular reference detection + let schema_pattern = format!("{source_module}::Schema"); + let entity_pattern = format!("{source_module}::Entity"); + let capitalized_pattern = format!("{}Schema", capitalize_first(source_module)); + for field in &fields_named.named { // FieldsNamed guarantees all fields have identifiers let field_ident = field.ident.as_ref().expect("named field has ident"); @@ -95,9 +100,9 @@ pub fn analyze_circular_refs(source_module_path: &[String], definition: &str) -> let is_circular = (ty_str.contains("HasOne<") || ty_str.contains("BelongsTo<") || ty_str.contains("Box<")) - && (ty_str.contains(&format!("{source_module}::Schema")) - || ty_str.contains(&format!("{source_module}::Entity")) - || ty_str.contains(&format!("{}Schema", capitalize_first(source_module)))); + && (ty_str.contains(&schema_pattern) + || ty_str.contains(&entity_pattern) + || ty_str.contains(&capitalized_pattern)); if is_circular { circular_fields.push(field_name); diff --git a/crates/vespera_macro/src/schema_macro/codegen.rs b/crates/vespera_macro/src/schema_macro/codegen.rs index 29a7b83..11c2c38 100644 --- a/crates/vespera_macro/src/schema_macro/codegen.rs +++ b/crates/vespera_macro/src/schema_macro/codegen.rs @@ -115,6 +115,21 @@ pub fn generate_filtered_schema( } } +/// Convert `SchemaType` enum variant to its `TokenStream` representation +fn schema_type_to_tokens(st: &SchemaType) -> TokenStream { + let variant = match st { + SchemaType::String => "String", + SchemaType::Number => "Number", + SchemaType::Integer => "Integer", + SchemaType::Boolean => "Boolean", + SchemaType::Array => "Array", + SchemaType::Object => "Object", + SchemaType::Null => "Null", + }; + let ident = syn::Ident::new(variant, proc_macro2::Span::call_site()); + quote! { vespera::schema::SchemaType::#ident } +} + /// Convert `SchemaRef` to `TokenStream` for code generation pub fn schema_ref_to_tokens(schema_ref: &SchemaRef) -> TokenStream { match schema_ref { @@ -139,19 +154,11 @@ pub fn schema_ref_to_tokens(schema_ref: &SchemaRef) -> TokenStream { /// This reduces generated code volume by ~70% for typical schemas /// (e.g., a String field: 3 tokens instead of 10). pub fn schema_to_tokens(schema: &Schema) -> TokenStream { - let mut fields: Vec = Vec::new(); + let mut fields: Vec = Vec::with_capacity(4); // schema_type if let Some(st) = &schema.schema_type { - let st_tokens = match st { - SchemaType::String => quote! { vespera::schema::SchemaType::String }, - SchemaType::Number => quote! { vespera::schema::SchemaType::Number }, - SchemaType::Integer => quote! { vespera::schema::SchemaType::Integer }, - SchemaType::Boolean => quote! { vespera::schema::SchemaType::Boolean }, - SchemaType::Array => quote! { vespera::schema::SchemaType::Array }, - SchemaType::Object => quote! { vespera::schema::SchemaType::Object }, - SchemaType::Null => quote! { vespera::schema::SchemaType::Null }, - }; + let st_tokens = schema_type_to_tokens(st); fields.push(quote! { schema_type: Some(#st_tokens) }); } diff --git a/crates/vespera_macro/src/schema_macro/file_cache.rs b/crates/vespera_macro/src/schema_macro/file_cache.rs index 11ef1a6..0656d69 100644 --- a/crates/vespera_macro/src/schema_macro/file_cache.rs +++ b/crates/vespera_macro/src/schema_macro/file_cache.rs @@ -33,10 +33,16 @@ struct FileCache { /// Built from cheap `String::contains` search, not full parsing. struct_candidates: HashMap<(PathBuf, String), Vec>, - // NOTE: We intentionally do NOT cache parsed `syn::ItemStruct` here. - // `syn` types contain `proc_macro::Span` handles that are tied to a specific - // macro invocation context. Caching them across invocations causes - // "use-after-free in `proc_macro` handle" panics. + // NOTE: We CANNOT cache `syn::File` or `syn::ItemStruct` across proc-macro + // invocations. Both `syn` and `proc_macro2` types contain `proc_macro::Span` + // and `proc_macro::TokenStream` bridge handles allocated in the current + // invocation's bridge context. Cloning them in a later invocation panics with + // "use-after-free in `proc_macro` handle". + // + // Instead, `struct_definitions` caches extracted definition *strings* which have + // no bridge handles and are safe to reuse. For callers needing `syn::File`, + // `get_parsed_file()` caches the file *content* (safe string) and re-parses + // per invocation, avoiding redundant disk I/O while staying safe. // --- Profiling counters (zero-cost when VESPERA_PROFILE is not set) --- /// Number of file content reads from disk (cache miss). @@ -58,6 +64,9 @@ struct FileCache { fk_column_lookup: HashMap<(String, String), Option>, /// Cached module path extraction from schema paths: path_str → Vec. module_path_cache: HashMap>, + /// Cached struct definitions from files: file_path → (mtime, struct_name → definition_string). + /// Unlike `syn::File`, strings have no `proc_macro::Span` handles, safe to cache. + struct_definitions: HashMap)>, /// Cached CARGO_MANIFEST_DIR value to avoid repeated syscalls. /// Within a single compilation, this never changes. manifest_dir: Option, @@ -67,6 +76,7 @@ struct FileCache { struct_lookup_cache_hits: usize, fk_column_cache_hits: usize, module_path_cache_hits: usize, + struct_def_cache_hits: usize, } thread_local! { @@ -87,6 +97,8 @@ thread_local! { struct_lookup_cache_hits: 0, fk_column_cache_hits: 0, module_path_cache_hits: 0, + struct_definitions: HashMap::with_capacity(32), + struct_def_cache_hits: 0, }); } @@ -106,6 +118,30 @@ pub fn get_manifest_dir() -> Option { }) } +/// Get a parsed `syn::File` for the given path. +/// +/// Uses the file content cache to avoid redundant disk I/O, then parses with +/// `syn::parse_file` each time. We CANNOT cache `syn::File` across proc-macro +/// invocations because `proc_macro2`/`syn` types contain `proc_macro::TokenStream` +/// bridge handles that become invalid when the invocation that created them ends. +pub fn get_parsed_file(path: &Path) -> Option { + FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + parse_file_cached(&mut cache, path) + }) +} + +/// **Single call site for `syn::parse_file`.** +/// +/// Reads file content from the mtime-validated content cache (avoids redundant +/// disk I/O), then calls `syn::parse_file`. The resulting `syn::File` is NOT +/// cached — it must be used and dropped within the current proc-macro invocation. +fn parse_file_cached(cache: &mut FileCache, path: &Path) -> Option { + let content = get_file_content_inner(cache, path)?; + cache.ast_parses += 1; + syn::parse_file(&content).ok() +} + /// Get candidate files that likely contain `struct_name`, using cache when available. /// /// Performs a cheap text-based search (`String::contains`) on file contents. @@ -145,18 +181,64 @@ pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Vec candidates }) } +/// Ensure struct definitions are extracted and cached for the given file. +/// On first call, parses the file and caches all struct definitions as strings. +/// On subsequent calls, checks mtime to validate cache. +fn ensure_struct_definitions(cache: &mut FileCache, path: &Path) -> bool { + let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()); + + if let Some(mtime) = current_mtime + && let Some((cached_mtime, _)) = cache.struct_definitions.get(path) + && *cached_mtime == mtime + { + cache.struct_def_cache_hits += 1; + return true; + } + + // Cache miss — parse file and extract all struct definitions. + // Uses parse_file_cached: single syn::parse_file entry point. + let Some(file_ast) = parse_file_cached(cache, path) else { + return false; + }; + + let mut defs = HashMap::new(); + for item in &file_ast.items { + if let syn::Item::Struct(struct_item) = item { + let name = struct_item.ident.to_string(); + let def = quote::quote!(#struct_item).to_string(); + defs.insert(name, def); + } + } + + if let Some(mtime) = current_mtime { + cache + .struct_definitions + .insert(path.to_path_buf(), (mtime, defs)); + } + + true +} -/// Get a parsed `syn::File` for the given path, using cached file content. +/// Get a struct definition string by name from a file, using cached extraction. +/// +/// On first call for a file, parses via `syn::parse_file` and caches ALL struct +/// definitions as strings. Subsequent calls for the same file return from cache +/// without re-parsing. /// -/// File content is cached with mtime-based invalidation. Parsing always runs -/// (syn types aren't Send), but I/O is avoided on cache hits. -/// Returns `None` if the file cannot be read or parsed. -pub fn get_parsed_ast(path: &Path) -> Option { +/// The cached data contains no `proc_macro::Span` handles, +/// so it's safe to reuse across macro invocations. +pub fn get_struct_definition(path: &Path, struct_name: &str) -> Option { FILE_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); - let content = get_file_content_inner(&mut cache, path)?; - cache.ast_parses += 1; - syn::parse_file(&content).ok() + if !ensure_struct_definitions(&mut cache, path) { + return None; + } + cache + .struct_definitions + .get(path)? + .1 + .get(struct_name) + .cloned() }) } @@ -191,7 +273,7 @@ fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option /// NOTE: Results are NOT cached across calls. `syn::ItemStruct` contains /// `proc_macro::Span` handles that are tied to a specific macro invocation /// context — caching them causes "use-after-free" panics in the proc_macro bridge. -/// File I/O caching (via `get_parsed_ast`) is the primary performance win; +/// File I/O caching (via `get_struct_definition`) is the primary performance win; /// definition string parsing is fast (microseconds per struct). pub fn parse_struct_cached(definition: &str) -> Result { FILE_CACHE.with(|cache| { @@ -242,7 +324,7 @@ pub fn get_struct_from_schema_path(path_str: &str) -> Option { return result; } - // 2. Compute — this re-enters FILE_CACHE via get_parsed_ast (safe: our borrow is dropped) + // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped) let result = super::file_lookup::find_struct_from_schema_path(path_str); // 3. Store — new borrow @@ -270,7 +352,7 @@ pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option { return result; } - // 2. Compute — this re-enters FILE_CACHE via get_parsed_ast (safe: our borrow is dropped) + // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped) let result = super::file_lookup::find_fk_column_from_target_entity(schema_path, via_rel); // 3. Store — new borrow @@ -365,6 +447,11 @@ pub fn print_profile_summary() { cache.fk_column_cache_hits, cache.fk_column_lookup.len() ); + eprintln!( + " struct definitions: {} cache hits, {} entries", + cache.struct_def_cache_hits, + cache.struct_definitions.len() + ); eprintln!( " module path: {} cache hits, {} entries", cache.module_path_cache_hits, @@ -373,9 +460,29 @@ pub fn print_profile_summary() { }); } +/// Inject a fake struct definition into the cache for testing. +/// Uses the file's real mtime so `ensure_struct_definitions` won't invalidate the cache. +/// Enables tests to simulate scenarios where `get_struct_definition` succeeds +/// but `parse_struct_cached` fails (defensive code path). +#[cfg(test)] +pub fn inject_struct_definition_for_test(path: &std::path::Path, name: &str, definition: &str) { + FILE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let mtime = std::fs::metadata(path) + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + let entry = cache + .struct_definitions + .entry(path.to_path_buf()) + .or_insert_with(|| (mtime, HashMap::new())); + entry.0 = mtime; + entry.1.insert(name.to_string(), definition.to_string()); + }); +} + #[cfg(test)] mod tests { - use std::path::Path; use tempfile::TempDir; @@ -402,45 +509,6 @@ mod tests { assert!(candidates[0].ends_with("has_model.rs")); } - #[test] - fn test_get_parsed_ast_returns_valid_ast() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.rs"); - std::fs::write(&file_path, "pub struct Foo { pub x: i32 }").unwrap(); - - let ast = get_parsed_ast(&file_path); - assert!(ast.is_some()); - assert!(!ast.unwrap().items.is_empty()); - } - - #[test] - fn test_get_parsed_ast_caches_content() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("cached.rs"); - std::fs::write(&file_path, "pub struct Bar;").unwrap(); - - let ast1 = get_parsed_ast(&file_path); - let ast2 = get_parsed_ast(&file_path); - assert!(ast1.is_some()); - assert!(ast2.is_some()); - } - - #[test] - fn test_get_parsed_ast_returns_none_for_invalid() { - let result = get_parsed_ast(Path::new("/nonexistent/path.rs")); - assert!(result.is_none()); - } - - #[test] - fn test_get_parsed_ast_returns_none_for_unparseable() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("broken.rs"); - std::fs::write(&file_path, "this is not valid rust {{{{").unwrap(); - - let result = get_parsed_ast(&file_path); - assert!(result.is_none()); - } - #[test] fn test_get_struct_candidates_caches_result() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vespera_macro/src/schema_macro/file_lookup.rs b/crates/vespera_macro/src/schema_macro/file_lookup.rs index 9acd12b..53f87f0 100644 --- a/crates/vespera_macro/src/schema_macro/file_lookup.rs +++ b/crates/vespera_macro/src/schema_macro/file_lookup.rs @@ -100,23 +100,12 @@ pub fn find_struct_from_path( if !file_path.exists() { continue; } - - let file_ast = super::file_cache::get_parsed_ast(&file_path)?; - - // Look for the struct in the file - for item in &file_ast.items { - match item { - syn::Item::Struct(struct_item) if struct_item.ident == struct_name => { - return Some(( - StructMetadata::new_model( - struct_name, - quote::quote!(#struct_item).to_string(), - ), - type_module_path, - )); - } - _ => {} - } + if let Some(definition) = super::file_cache::get_struct_definition(&file_path, &struct_name) + { + return Some(( + StructMetadata::new_model(struct_name, definition), + type_module_path, + )); } } @@ -168,21 +157,13 @@ pub fn find_struct_by_name_in_all_files( // Parse only candidate files first let mut found_in_candidates: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); for file_path in &candidates { - let Some(file_ast) = super::file_cache::get_parsed_ast(file_path) else { - continue; - }; - for item in &file_ast.items { - if let syn::Item::Struct(struct_item) = item - && struct_item.ident == struct_name - { - found_in_candidates.push(( - file_path.clone(), - StructMetadata::new_model( - struct_name.to_string(), - quote::quote!(#struct_item).to_string(), - ), - )); - } + if let Some(definition) = + super::file_cache::get_struct_definition(file_path, struct_name) + { + found_in_candidates.push(( + file_path.clone(), + StructMetadata::new_model(struct_name.to_string(), definition), + )); } } @@ -222,22 +203,12 @@ pub fn find_struct_by_name_in_all_files( let mut found_structs: Vec<(std::path::PathBuf, StructMetadata)> = Vec::new(); for file_path in rs_files { - let Some(file_ast) = super::file_cache::get_parsed_ast(&file_path) else { - continue; - }; - - for item in &file_ast.items { - if let syn::Item::Struct(struct_item) = item - && struct_item.ident == struct_name - { - found_structs.push(( - file_path.clone(), - StructMetadata::new_model( - struct_name.to_string(), - quote::quote!(#struct_item).to_string(), - ), - )); - } + if let Some(definition) = super::file_cache::get_struct_definition(&file_path, struct_name) + { + found_structs.push(( + file_path.clone(), + StructMetadata::new_model(struct_name.to_string(), definition), + )); } } @@ -366,20 +337,9 @@ pub fn find_struct_from_schema_path(path_str: &str) -> Option { if !file_path.exists() { continue; } - - let file_ast = super::file_cache::get_parsed_ast(&file_path)?; - - // Look for the struct in the file - for item in &file_ast.items { - match item { - syn::Item::Struct(struct_item) if struct_item.ident == struct_name => { - return Some(StructMetadata::new_model( - struct_name, - quote::quote!(#struct_item).to_string(), - )); - } - _ => {} - } + if let Some(definition) = super::file_cache::get_struct_definition(&file_path, &struct_name) + { + return Some(StructMetadata::new_model(struct_name, definition)); } } @@ -431,22 +391,20 @@ pub fn find_fk_column_from_target_entity( continue; } - let file_ast = super::file_cache::get_parsed_ast(&file_path)?; + let Some(model_def) = super::file_cache::get_struct_definition(&file_path, "Model") else { + continue; + }; + let Ok(model) = super::file_cache::parse_struct_cached(&model_def) else { + continue; + }; - // Look for Model struct in the file - for item in &file_ast.items { - if let syn::Item::Struct(struct_item) = item - && struct_item.ident == "Model" - { - // Search through fields for the one with matching relation_enum - if let syn::Fields::Named(fields_named) = &struct_item.fields { - for field in &fields_named.named { - let field_relation_enum = extract_relation_enum(&field.attrs); - if field_relation_enum.as_deref() == Some(via_rel) { - // Found the matching field, extract FK column from `from` attribute - return extract_belongs_to_from_field(&field.attrs); - } - } + // Search through fields for the one with matching relation_enum + if let syn::Fields::Named(fields_named) = &model.fields { + for field in &fields_named.named { + let field_relation_enum = extract_relation_enum(&field.attrs); + if field_relation_enum.as_deref() == Some(via_rel) { + // Found the matching field, extract FK column from `from` attribute + return extract_belongs_to_from_field(&field.attrs); } } } @@ -493,19 +451,8 @@ pub fn find_model_from_schema_path(schema_path_str: &str) -> Option Some(corrupt) -> parse_struct_cached -> Err -> continue + let result = + find_fk_column_from_target_entity("crate::models::item::Schema", "SomeRelation"); + + unsafe { + if let Some(dir) = original { + std::env::set_var("CARGO_MANIFEST_DIR", dir); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + + assert!( + result.is_none(), + "Should return None when struct definition fails to parse" + ); + } } diff --git a/crates/vespera_macro/src/schema_macro/inline_types.rs b/crates/vespera_macro/src/schema_macro/inline_types.rs index 542b9e9..cbf1349 100644 --- a/crates/vespera_macro/src/schema_macro/inline_types.rs +++ b/crates/vespera_macro/src/schema_macro/inline_types.rs @@ -66,90 +66,14 @@ pub fn generate_inline_relation_type_from_def( schema_name_override: Option<&str>, model_def: &str, ) -> Option { - // Parse the model struct - let parsed_model: syn::ItemStruct = super::file_cache::parse_struct_cached(model_def).ok()?; - - // IMPORTANT: Use the TARGET model's module path for type resolution, not the parent's. - // This ensures enum types like `AuthProvider` are resolved to `crate::models::user::AuthProvider` - // instead of incorrectly using the parent module path. - let target_module_path = get_module_path_from_schema_path(&rel_info.schema_path); - let effective_module_path = if target_module_path.is_empty() { - source_module_path - } else { - &target_module_path - }; - - // Detect circular fields - let circular_fields = get_circular_analysis(source_module_path, model_def).circular_fields; - - // If no circular fields, no need for inline type - if circular_fields.is_empty() { - return None; - } - - // Get rename_all from model (or default to camelCase) - let rename_all = - extract_rename_all(&parsed_model.attrs).unwrap_or_else(|| "camelCase".to_string()); - - // Generate inline type name: {SchemaName}_{Field} - // Use custom schema name if provided, otherwise use the Rust struct name - let parent_name = schema_name_override.map_or_else( - || parent_type_name.to_string(), - std::string::ToString::to_string, - ); - let field_name_pascal = snake_to_pascal_case(&rel_info.field_name.to_string()); - let inline_type_name = syn::Ident::new( - &format!("{parent_name}_{field_name_pascal}"), - proc_macro2::Span::call_site(), - ); - - // Collect fields, excluding circular ones and relation types - let mut fields = Vec::with_capacity(8); - if let syn::Fields::Named(fields_named) = &parsed_model.fields { - for field in &fields_named.named { - let field_ident = field.ident.as_ref()?; - let field_name_str = field_ident.to_string(); - - // Skip circular fields - if circular_fields.contains(&field_name_str) { - continue; - } - - // Skip relation types (HasOne, HasMany, BelongsTo) - if is_seaorm_relation_type(&field.ty) { - continue; - } - - // Skip fields with serde(skip) - if extract_skip(&field.attrs) { - continue; - } - - // Keep serde and doc attributes - let kept_attrs: Vec = field - .attrs - .iter() - .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("doc")) - .cloned() - .collect(); - - // Convert SeaORM datetime types to chrono equivalents - // This prevents users from needing to import sea_orm::prelude::DateTimeWithTimeZone - // Use the target model's module path to correctly resolve enum types - let converted_ty = convert_type_with_chrono(&field.ty, effective_module_path); - fields.push(InlineField { - name: field_ident.clone(), - ty: converted_ty, - attrs: kept_attrs, - }); - } - } - - Some(InlineRelationType { - type_name: inline_type_name, - fields, - rename_all, - }) + generate_inline_type_core( + parent_type_name, + rel_info, + source_module_path, + schema_name_override, + model_def, + true, // check circular fields + ) } /// Generate inline relation type for `HasMany` with ALL relations stripped. @@ -187,12 +111,40 @@ pub fn generate_inline_relation_type_no_relations_from_def( source_module_path: &[String], schema_name_override: Option<&str>, model_def: &str, +) -> Option { + generate_inline_type_core( + parent_type_name, + rel_info, + source_module_path, + schema_name_override, + model_def, + false, // skip all relations without circular check + ) +} + +/// Core implementation shared by both circular-reference and no-relations variants. +/// +/// When `check_circular` is `true`: +/// - Detects circular fields via `get_circular_analysis` +/// - Returns `None` if no circular fields exist (no inline type needed) +/// - Excludes circular fields from the generated type +/// +/// When `check_circular` is `false`: +/// - Skips ALL relation types unconditionally +/// - Always proceeds (no early return) +fn generate_inline_type_core( + parent_type_name: &syn::Ident, + rel_info: &RelationFieldInfo, + source_module_path: &[String], + schema_name_override: Option<&str>, + model_def: &str, + check_circular: bool, ) -> Option { // Parse the model struct let parsed_model: syn::ItemStruct = super::file_cache::parse_struct_cached(model_def).ok()?; // IMPORTANT: Use the TARGET model's module path for type resolution, not the parent's. - // This ensures enum types like `StoryStatus` are resolved to `crate::models::story::StoryStatus` + // This ensures enum types are resolved to the correct module path // instead of incorrectly using the parent module path. let target_module_path = get_module_path_from_schema_path(&rel_info.schema_path); let effective_module_path = if target_module_path.is_empty() { @@ -201,11 +153,24 @@ pub fn generate_inline_relation_type_no_relations_from_def( &target_module_path }; + // Detect circular fields only when requested + let circular_fields: Vec = if check_circular { + let fields = get_circular_analysis(source_module_path, model_def).circular_fields; + // If no circular fields, no need for inline type + if fields.is_empty() { + return None; + } + fields + } else { + Vec::new() + }; + // Get rename_all from model (or default to camelCase) let rename_all = extract_rename_all(&parsed_model.attrs).unwrap_or_else(|| "camelCase".to_string()); // Generate inline type name: {SchemaName}_{Field} + // Use custom schema name if provided, otherwise use the Rust struct name let parent_name = schema_name_override.map_or_else( || parent_type_name.to_string(), std::string::ToString::to_string, @@ -216,13 +181,21 @@ pub fn generate_inline_relation_type_no_relations_from_def( proc_macro2::Span::call_site(), ); - // Collect fields, excluding ALL relation types + // Collect fields, excluding circular ones and/or relation types let mut fields = Vec::with_capacity(8); if let syn::Fields::Named(fields_named) = &parsed_model.fields { for field in &fields_named.named { let field_ident = field.ident.as_ref()?; - // Skip ALL relation types (HasOne, HasMany, BelongsTo) + // Skip circular fields (only when check_circular is true) + if check_circular { + let field_name_str = field_ident.to_string(); + if circular_fields.contains(&field_name_str) { + continue; + } + } + + // Skip relation types (HasOne, HasMany, BelongsTo) if is_seaorm_relation_type(&field.ty) { continue; } @@ -241,7 +214,6 @@ pub fn generate_inline_relation_type_no_relations_from_def( .collect(); // Convert SeaORM datetime types to chrono equivalents - // This prevents users from needing to import sea_orm::prelude::DateTimeWithTimeZone // Use the target model's module path to correctly resolve enum types let converted_ty = convert_type_with_chrono(&field.ty, effective_module_path); fields.push(InlineField { diff --git a/crates/vespera_macro/src/schema_macro/mod.rs b/crates/vespera_macro/src/schema_macro/mod.rs index 9cb8ddd..5f3789f 100644 --- a/crates/vespera_macro/src/schema_macro/mod.rs +++ b/crates/vespera_macro/src/schema_macro/mod.rs @@ -6,7 +6,7 @@ mod circular; mod codegen; -mod file_cache; +pub mod file_cache; mod file_lookup; mod from_model; mod inline_types; @@ -103,24 +103,24 @@ pub fn generate_schema_type_code( // This may be empty for simple names like `Model` - will be overridden below if found from file let mut source_module_path = extract_module_path(&input.source_type); - // Find struct definition - lookup order depends on whether path is qualified - // For qualified paths (crate::models::memo::Model), try file lookup FIRST - // to avoid name collisions when multiple modules have same struct name (e.g., Model) + // Find struct definition - check SCHEMA_STORAGE first (no file I/O), + // fall back to file lookup for types not registered (e.g., SeaORM Model). let struct_def_owned: StructMetadata; let schema_name_hint = input.schema_name.as_deref(); let struct_def = if is_qualified_path(&input.source_type) { - // Qualified path: try file lookup first, then storage - if let Some((found, module_path)) = + // Qualified path: try storage first (avoids parse_file for Schema-derived types), + // then file lookup for non-Schema types (e.g., SeaORM Model) + if let Some(found) = schema_storage.get(&source_type_name) { + found + } else if let Some((found, module_path)) = find_struct_from_path(&input.source_type, schema_name_hint) { struct_def_owned = found; - // Always use the module path from file lookup for qualified paths + // Use the module path from file lookup for qualified paths // The file lookup derives module path from actual file location, which is more accurate // for resolving relative paths like `super::user::Entity` source_module_path = module_path; &struct_def_owned - } else if let Some(found) = schema_storage.get(&source_type_name) { - found } else { return Err(syn::Error::new_spanned( &input.source_type, @@ -749,14 +749,7 @@ fn sql_function_default_for_type(original_ty: &syn::Type) -> Option<(TokenStream let type_name = segment.ident.to_string(); match type_name.as_str() { - "DateTimeWithTimeZone" | "DateTimeUtc" => { - let expr = quote! { - vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() - }; - Some((expr, "1970-01-01T00:00:00+00:00".to_string())) - } - "DateTime" => { - // Could be chrono::DateTime — use UTC epoch + "DateTimeWithTimeZone" | "DateTimeUtc" | "DateTime" => { let expr = quote! { vespera::chrono::DateTime::::UNIX_EPOCH.fixed_offset() }; @@ -805,25 +798,7 @@ fn is_parseable_type(ty: &syn::Type) -> bool { let Some(segment) = type_path.path.segments.last() else { return false; }; - matches!( - segment.ident.to_string().as_str(), - "i8" | "i16" - | "i32" - | "i64" - | "i128" - | "isize" - | "u8" - | "u16" - | "u32" - | "u64" - | "u128" - | "usize" - | "f32" - | "f64" - | "bool" - | "String" - | "Decimal" - ) + type_utils::PRIMITIVE_TYPE_NAMES.contains(&segment.ident.to_string().as_str()) } #[cfg(test)] diff --git a/crates/vespera_macro/src/schema_macro/seaorm.rs b/crates/vespera_macro/src/schema_macro/seaorm.rs index f41da13..2045aa0 100644 --- a/crates/vespera_macro/src/schema_macro/seaorm.rs +++ b/crates/vespera_macro/src/schema_macro/seaorm.rs @@ -133,26 +133,25 @@ pub fn convert_type_with_chrono(ty: &Type, source_module_path: &[String]) -> Tok convert_seaorm_type_to_chrono(ty, source_module_path) } -/// Extract the "from" field name from a `sea_orm` `belongs_to` attribute. -/// e.g., `#[sea_orm(belongs_to, from = "user_id", to = "id")]` -> `Some("user_id`") -/// Also handles: `#[sea_orm(belongs_to = "Entity", from = "user_id", to = "id")]` -pub fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { +/// Extract a named string value from a `sea_orm` attribute. +/// Shared helper for `extract_belongs_to_from_field`, `extract_relation_enum`, and `extract_via_rel`. +fn extract_sea_orm_attr_value(attrs: &[syn::Attribute], attr_name: &str) -> Option { attrs.iter().find_map(|attr| { if !attr.path().is_ident("sea_orm") { return None; } - let mut from_field = None; - // Ignore parse errors - we just won't find the field if parsing fails + let mut found_value = None; + // Ignore parse errors — we just won't find the field if parsing fails let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("from") { - from_field = meta + if meta.path.is_ident(attr_name) { + found_value = meta .value() .ok() .and_then(|v| v.parse::().ok()) .map(|lit| lit.value()); } else if meta.input.peek(syn::Token![=]) { - // Consume value for key=value pairs (e.g., belongs_to = "...", to = "...") + // Consume value for other key=value pairs // Required to allow parsing to continue to next item drop( meta.value() @@ -161,40 +160,24 @@ pub fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option } Ok(()) }); - from_field + found_value }) } +/// Extract the "from" field name from a `sea_orm` `belongs_to` attribute. +/// e.g., `#[sea_orm(belongs_to, from = "user_id", to = "id")]` -> `Some("user_id")` +/// Also handles: `#[sea_orm(belongs_to = "Entity", from = "user_id", to = "id")]` +pub fn extract_belongs_to_from_field(attrs: &[syn::Attribute]) -> Option { + extract_sea_orm_attr_value(attrs, "from") +} + /// Extract the "`relation_enum`" value from a `sea_orm` attribute. /// e.g., `#[sea_orm(belongs_to, relation_enum = "TargetUser", from = "target_user_id")]` -> Some("TargetUser") /// /// When `relation_enum` is present, it indicates that multiple relations to the same /// Entity type exist, and we need to use the specific Relation enum variant for queries. pub fn extract_relation_enum(attrs: &[syn::Attribute]) -> Option { - attrs.iter().find_map(|attr| { - if !attr.path().is_ident("sea_orm") { - return None; - } - - let mut relation_enum_value = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("relation_enum") { - relation_enum_value = meta - .value() - .ok() - .and_then(|v| v.parse::().ok()) - .map(|lit| lit.value()); - } else if meta.input.peek(syn::Token![=]) { - // Consume value for other key=value pairs - drop( - meta.value() - .and_then(syn::parse::ParseBuffer::parse::), - ); - } - Ok(()) - }); - relation_enum_value - }) + extract_sea_orm_attr_value(attrs, "relation_enum") } /// Extract the "`via_rel`" value from a `sea_orm` attribute. @@ -203,30 +186,7 @@ pub fn extract_relation_enum(attrs: &[syn::Attribute]) -> Option { /// For `HasMany` relations with `relation_enum`, `via_rel` specifies which Relation variant /// on the TARGET entity corresponds to this relation. This allows us to find the FK column. pub fn extract_via_rel(attrs: &[syn::Attribute]) -> Option { - attrs.iter().find_map(|attr| { - if !attr.path().is_ident("sea_orm") { - return None; - } - - let mut via_rel_value = None; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("via_rel") { - via_rel_value = meta - .value() - .ok() - .and_then(|v| v.parse::().ok()) - .map(|lit| lit.value()); - } else if meta.input.peek(syn::Token![=]) { - // Consume value for other key=value pairs - drop( - meta.value() - .and_then(syn::parse::ParseBuffer::parse::), - ); - } - Ok(()) - }); - via_rel_value - }) + extract_sea_orm_attr_value(attrs, "via_rel") } /// Extract `default_value` from a `sea_orm` attribute. diff --git a/crates/vespera_macro/src/schema_macro/tests.rs b/crates/vespera_macro/src/schema_macro/tests.rs index 0547014..ab5ed61 100644 --- a/crates/vespera_macro/src/schema_macro/tests.rs +++ b/crates/vespera_macro/src/schema_macro/tests.rs @@ -829,6 +829,7 @@ fn test_generate_schema_type_code_preserves_struct_doc() { " .to_string(), include_in_openapi: true, + field_defaults: std::collections::BTreeMap::new(), }; let storage = to_storage(vec![struct_def]); let result = generate_schema_type_code(&input, &storage); diff --git a/crates/vespera_macro/src/schema_macro/type_utils.rs b/crates/vespera_macro/src/schema_macro/type_utils.rs index fb9e045..694499c 100644 --- a/crates/vespera_macro/src/schema_macro/type_utils.rs +++ b/crates/vespera_macro/src/schema_macro/type_utils.rs @@ -7,6 +7,15 @@ use quote::quote; use serde_json; use syn::Type; +/// Primitive type names shared across the crate. +/// Used by both `is_primitive_type()` (parser) and `is_parseable_type()` (schema_macro). +/// Note: `"str"` is intentionally excluded — only `is_primitive_type()` considers `str`, +/// since it appears in parser contexts but not in schema_macro type parsing. +pub const PRIMITIVE_TYPE_NAMES: &[&str] = &[ + "i8", "i16", "i32", "i64", "i128", "isize", "u8", "u16", "u32", "u64", "u128", "usize", "f32", + "f64", "bool", "String", "Decimal", +]; + /// Normalize a `TokenStream` or `Type` to a compact string by removing spaces. /// /// This replaces the common `.to_string().replace(' ', "")` pattern used throughout diff --git a/crates/vespera_macro/src/vespera_impl.rs b/crates/vespera_macro/src/vespera_impl.rs index 136f71c..2f8f07e 100644 --- a/crates/vespera_macro/src/vespera_impl.rs +++ b/crates/vespera_macro/src/vespera_impl.rs @@ -25,27 +25,112 @@ //! - [`process_export_app`] - Main `export_app`! macro implementation //! - [`generate_and_write_openapi`] - `OpenAPI` generation and file I/O -use std::{collections::HashMap, path::Path}; +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + path::Path, +}; use proc_macro2::Span; use quote::quote; +use serde::{Deserialize, Serialize}; + use crate::{ - collector::collect_metadata, + 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}, }; /// Docs info tuple type alias for cleaner signatures pub type DocsInfo = (Option, Option, Option); +/// Cache for avoiding redundant route scanning and OpenAPI generation. +/// Persisted to `target/vespera/routes.cache` across builds. +#[derive(Serialize, Deserialize)] +struct VesperaCache { + /// File path → modification time (secs since UNIX_EPOCH) + file_fingerprints: HashMap, + /// Hash of SCHEMA_STORAGE contents + schema_hash: u64, + /// Hash of OpenAPI config (title, version, servers, docs_url, etc.) + config_hash: u64, + /// Cached route/struct metadata + metadata: CollectedMetadata, + /// Compact JSON for docs embedding (None if docs disabled) + spec_json: Option, + /// Pretty JSON for file output (None if no openapi file configured) + spec_pretty: Option, +} + +/// Compute a deterministic hash of SCHEMA_STORAGE contents. +fn compute_schema_hash(schema_storage: &HashMap) -> 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() +} + +/// Compute a deterministic hash of OpenAPI config fields. +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() +} + +/// Get the path to the routes cache file. +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") +} + +/// Try to read and deserialize a cache file. Returns None on any failure. +fn read_cache(cache_path: &Path) -> Option { + let content = std::fs::read_to_string(cache_path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Write cache to disk. Failures are silently ignored (cache is best-effort). +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); + } +} + /// Generate `OpenAPI` JSON and write to files, returning docs info pub fn generate_and_write_openapi( input: &ProcessedVesperaInput, metadata: &CollectedMetadata, file_asts: HashMap, + route_storage: &[StoredRouteInfo], ) -> MacroResult { if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none() { @@ -58,6 +143,7 @@ pub fn generate_and_write_openapi( input.servers.clone(), metadata, Some(file_asts), + route_storage, ); // Merge specs from child apps at compile time @@ -92,7 +178,12 @@ pub fn generate_and_write_openapi( 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)))?; } - 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 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.")))?; + } } } @@ -155,10 +246,132 @@ pub fn find_target_dir(manifest_path: &Path) -> std::path::PathBuf { manifest_path.join("target") } +/// Supplement collector's `RouteMetadata` with data from `ROUTE_STORAGE`. +/// +/// `#[route]` stores metadata at attribute expansion time. +/// `collector.rs` re-parses the same data from file ASTs. +/// This function merges ROUTE_STORAGE data into collector's output, +/// preferring ROUTE_STORAGE values when they provide richer info. +/// +/// Matching is by function name. If multiple routes share a function name, +/// the match is ambiguous and ROUTE_STORAGE data is skipped for safety. +fn merge_route_storage_data(metadata: &mut CollectedMetadata, route_storage: &[StoredRouteInfo]) { + if route_storage.is_empty() { + return; + } + + for route in &mut metadata.routes { + // Find matching StoredRouteInfo by function name + let mut matches = route_storage + .iter() + .filter(|s| s.fn_name == route.function_name); + + let Some(stored) = matches.next() else { + continue; + }; + + // Skip if ambiguous (multiple routes with same function name) + if matches.next().is_some() { + continue; + } + + // Supplement with ROUTE_STORAGE data + // Only override when ROUTE_STORAGE has an explicit value + 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()); + } + } +} + +/// Write cached OpenAPI spec to output files if they are stale or missing. +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(()) +} + +/// Write compact spec JSON to target dir for `include_str!` embedding. +fn write_spec_for_embedding( + spec_json: Option, +) -> syn::Result> { + 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) })) +} + /// Process vespera macro - extracted for testability pub fn process_vespera_macro( processed: &ProcessedVesperaInput, schema_storage: &HashMap, + route_storage: &[StoredRouteInfo], ) -> syn::Result { let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { Some(std::time::Instant::now()) @@ -177,49 +390,79 @@ pub fn process_vespera_macro( )); } - let (mut metadata, file_asts) = collect_metadata(&folder_path, &processed.folder_name).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)))?; - metadata.structs.extend(schema_storage.values().cloned()); + // --- Incremental cache check --- + 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 (docs_url, redoc_url, spec_json) = - generate_and_write_openapi(processed, &metadata, file_asts)?; + 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.json files exist and are up-to-date from cache + 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)))?; + + // Clone metadata before extending (cache stores file-only structs) + 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)?; + + // Read back spec_pretty from first openapi file for caching + let spec_pretty = processed + .openapi_file_names + .first() + .and_then(|f| std::fs::read_to_string(f).ok()); + + // Persist cache (best-effort, failures are silent) + write_cache( + &cache_path, + &VesperaCache { + file_fingerprints: fingerprints, + schema_hash, + config_hash, + metadata: cache_metadata, + spec_json: spec_json.clone(), + spec_pretty, + }, + ); - let spec_tokens = match spec_json { - Some(json) => { - 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"); - 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('\\', "/"); - Some(quote::quote! { include_str!(#path_str) }) - } - None => None, + (metadata, spec_json) }; + // Write compact spec for include_str! embedding + let spec_tokens = write_spec_for_embedding(spec_json)?; + let result = Ok(generate_router_code( &metadata, - docs_url.as_deref(), - redoc_url.as_deref(), + processed.docs_url.as_deref(), + processed.redoc_url.as_deref(), spec_tokens, &processed.merge, )); @@ -241,6 +484,7 @@ pub fn process_export_app( folder_name: &str, schema_storage: &HashMap, manifest_dir: &str, + route_storage: &[StoredRouteInfo], ) -> syn::Result { let profile_start = if std::env::var("VESPERA_PROFILE").is_ok() { Some(std::time::Instant::now()) @@ -258,12 +502,22 @@ pub fn process_export_app( )); } - let (mut metadata, file_asts) = collect_metadata(&folder_path, folder_name).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.")))?; + 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}")))?; // Generate OpenAPI spec JSON string - let openapi_doc = - generate_openapi_doc_with_metadata(None, None, None, &metadata, Some(file_asts)); + 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.")))?; // Write spec to temp file for compile-time merging by parent apps @@ -313,6 +567,7 @@ mod tests { 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); @@ -338,7 +593,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::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()); @@ -359,7 +614,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::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()); @@ -384,7 +639,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::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()); @@ -406,7 +661,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::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()); @@ -430,7 +685,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); assert!(result.is_ok()); // Verify file was written @@ -457,7 +712,7 @@ mod tests { merge: vec![], }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); assert!(result.is_ok()); // Verify nested directories and file were created @@ -584,7 +839,7 @@ mod tests { servers: None, merge: vec![], }; - let result = process_vespera_macro(&processed, &HashMap::new()); + 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")); @@ -609,7 +864,7 @@ mod tests { }; // This exercises the collect_metadata path (which handles parse errors gracefully) - let result = process_vespera_macro(&processed, &HashMap::new()); + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); // Result may succeed or fail depending on how collect_metadata handles invalid files let _ = result; } @@ -641,7 +896,7 @@ mod tests { }; // This exercises the schema_storage extend path - let result = process_vespera_macro(&processed, &schema_storage); + let result = process_vespera_macro(&processed, &schema_storage, &[]); // We only care about exercising the code path let _ = result; } @@ -657,6 +912,7 @@ mod tests { "nonexistent_folder_xyz", &HashMap::new(), &temp_dir.path().to_string_lossy(), + &[], ); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -679,6 +935,7 @@ mod tests { &folder_path, &HashMap::new(), &temp_dir.path().to_string_lossy(), + &[], ); // We only care about exercising the code path let _ = result; @@ -707,6 +964,7 @@ mod tests { &folder_path, &schema_storage, &temp_dir.path().to_string_lossy(), + &[], ); // Exercises the schema_storage.extend path let _ = result; @@ -729,7 +987,7 @@ mod tests { }; 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, HashMap::new()); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); assert!(result.is_ok()); } @@ -764,7 +1022,7 @@ mod tests { }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::new()); + let result = generate_and_write_openapi(&processed, &metadata, HashMap::new(), &[]); // Restore CARGO_MANIFEST_DIR if let Some(old_value) = old_manifest_dir { @@ -841,7 +1099,7 @@ mod tests { }; let metadata = CollectedMetadata::new(); - let result = generate_and_write_openapi(&processed, &metadata, HashMap::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")); @@ -863,6 +1121,7 @@ mod tests { &folder_path, &HashMap::new(), &temp_dir.path().to_string_lossy(), + &[], ); assert!(result.is_err()); @@ -891,6 +1150,7 @@ mod tests { &folder_path, &HashMap::new(), &temp_dir.path().to_string_lossy(), + &[], ); assert!(result.is_err()); @@ -921,6 +1181,7 @@ mod tests { &folder_path, &HashMap::new(), &temp_dir.path().to_string_lossy(), + &[], ); assert!(result.is_err()); @@ -943,7 +1204,7 @@ mod tests { merge: vec![], }; - let result = process_vespera_macro(&processed, &HashMap::new()); + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); assert!( result.is_ok(), "Should succeed with no openapi output configured" @@ -970,7 +1231,7 @@ mod tests { merge: vec![], }; - let result = process_vespera_macro(&processed, &HashMap::new()); + let result = process_vespera_macro(&processed, &HashMap::new(), &[]); // Restore unsafe { @@ -1001,6 +1262,7 @@ mod tests { &folder_path, &HashMap::new(), &temp_dir.path().to_string_lossy(), + &[], ); // Restore @@ -1015,4 +1277,448 @@ mod tests { // Exercise the code path let _ = result; } + + // ========== Tests for merge_route_storage_data ========== + + #[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>".to_string(), + error_status: None, + tags: None, + description: None, + }); + + merge_route_storage_data(&mut metadata, &[]); + // No changes when storage is empty + 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>".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); + // No match — fields unchanged + 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, + }); + + // Two StoredRouteInfo with same fn_name — ambiguous + 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); + // Ambiguous match — no merge + 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); + // ROUTE_STORAGE values override when they have explicit values + 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()), + }); + + // StoredRouteInfo with only error_status (tags/description are None) + 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); + // Only error_status should be set; tags and description preserved from collector + 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() { + // Exercises lines 92-96: servers loop in compute_config_hash + 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); + + // Different servers should produce different hashes + assert_ne!( + hash_no_servers, hash_with_servers, + "Servers should affect config hash" + ); + } + + #[test] + fn test_compute_config_hash_with_merge() { + // Exercises lines 97-99: merge loop in compute_config_hash + 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() { + // Exercises lines 266-267: early return when spec_pretty is None + 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() { + // Exercises lines 269-276: write new 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() { + // Exercises line 271-272: should_write is false when content matches + 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"}"#; + + // Write file first with same content + 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()); + // File should still contain same content (no unnecessary write) + assert_eq!(fs::read_to_string(&output_path).unwrap(), spec); + } + + #[test] + fn test_ensure_openapi_files_from_cache_creates_parent_dirs() { + // Exercises lines 273-274: create parent directories + 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() { + // Exercises line 276: write failure + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("api.json"); + + // Create a directory where the file should be -> write will fail + 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() { + // Exercises the loop with multiple file names (line 269) + 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() { + // Exercises lines 320-324, 327, 329: the cache_hit branch in process_vespera_macro. + // First call populates the cache, second call hits it. + 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"); + + // Set CARGO_MANIFEST_DIR so cache path resolves to temp_dir/target/vespera/ + 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![], + }; + + // First call: cache MISS — scans files, generates spec, writes cache + 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" + ); + + // Second call: cache HIT — exercises lines 320-324, 327, 329 + let result2 = process_vespera_macro(&processed, &HashMap::new(), &[]); + assert!( + result2.is_ok(), + "Second call (cache hit) should succeed: {:?}", + result2.err() + ); + + // Restore CARGO_MANIFEST_DIR + unsafe { + if let Some(val) = old_manifest { + std::env::set_var("CARGO_MANIFEST_DIR", val); + } else { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + }; + } } diff --git a/examples/axum-example/openapi.json b/examples/axum-example/openapi.json index 2d7af37..490ddea 100644 --- a/examples/axum-example/openapi.json +++ b/examples/axum-example/openapi.json @@ -1187,6 +1187,23 @@ "schema": { "type": "string" } + }, + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "uint32" + } } ], "responses": { @@ -1195,7 +1212,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/TestStruct" } } } diff --git a/examples/axum-example/tests/snapshots/integration_test__openapi.snap b/examples/axum-example/tests/snapshots/integration_test__openapi.snap index 603f36f..69ff2d1 100644 --- a/examples/axum-example/tests/snapshots/integration_test__openapi.snap +++ b/examples/axum-example/tests/snapshots/integration_test__openapi.snap @@ -1191,6 +1191,23 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "schema": { "type": "string" } + }, + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "uint32" + } } ], "responses": { @@ -1199,7 +1216,7 @@ expression: "std::fs::read_to_string(\"openapi.json\").unwrap()" "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/TestStruct" } } } diff --git a/openapi.json b/openapi.json index 2d7af37..490ddea 100644 --- a/openapi.json +++ b/openapi.json @@ -1187,6 +1187,23 @@ "schema": { "type": "string" } + }, + { + "name": "name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "age", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "uint32" + } } ], "responses": { @@ -1195,7 +1212,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/TestStruct" } } }