From d3e362f58436670c8acb0dd9114fa71aabf2ae76 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 19:38:19 +0900 Subject: [PATCH 01/12] Support datetime --- .../changepack_log_VvUmP3B0dbaO7N2OfzGr9.json | 1 + Cargo.lock | 6 ++-- crates/vespera_macro/src/parser/schema.rs | 31 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 .changepacks/changepack_log_VvUmP3B0dbaO7N2OfzGr9.json diff --git a/.changepacks/changepack_log_VvUmP3B0dbaO7N2OfzGr9.json b/.changepacks/changepack_log_VvUmP3B0dbaO7N2OfzGr9.json new file mode 100644 index 0000000..4a81ab8 --- /dev/null +++ b/.changepacks/changepack_log_VvUmP3B0dbaO7N2OfzGr9.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_macro/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch"},"note":"Support datetime, date and time","date":"2026-01-22T10:38:05.323125700Z"} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 0364173..ca50570 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1674,7 +1674,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.19" +version = "0.1.21" dependencies = [ "axum", "axum-extra", @@ -1684,7 +1684,7 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.19" +version = "0.1.21" dependencies = [ "rstest", "serde", @@ -1693,7 +1693,7 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.19" +version = "0.1.21" dependencies = [ "anyhow", "insta", diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 0604020..c64fea8 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -849,6 +849,37 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( "f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())), "bool" => SchemaRef::Inline(Box::new(Schema::boolean())), "String" | "str" => SchemaRef::Inline(Box::new(Schema::string())), + // Date-time types from chrono crate + "DateTime" | "NaiveDateTime" => SchemaRef::Inline(Box::new(Schema { + format: Some("date-time".to_string()), + ..Schema::string() + })), + "NaiveDate" => SchemaRef::Inline(Box::new(Schema { + format: Some("date".to_string()), + ..Schema::string() + })), + "NaiveTime" => SchemaRef::Inline(Box::new(Schema { + format: Some("time".to_string()), + ..Schema::string() + })), + // Date-time types from time crate + "OffsetDateTime" | "PrimitiveDateTime" => SchemaRef::Inline(Box::new(Schema { + format: Some("date-time".to_string()), + ..Schema::string() + })), + "Date" => SchemaRef::Inline(Box::new(Schema { + format: Some("date".to_string()), + ..Schema::string() + })), + "Time" => SchemaRef::Inline(Box::new(Schema { + format: Some("time".to_string()), + ..Schema::string() + })), + // Duration types + "Duration" => SchemaRef::Inline(Box::new(Schema { + format: Some("duration".to_string()), + ..Schema::string() + })), // Standard library types that should not be referenced // Note: HashMap and BTreeMap are handled above in generic types "Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => { From 9a439595d993e9b2fc3d6ec09820ef8b5a2690ff Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 20:03:05 +0900 Subject: [PATCH 02/12] Add llvm --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 081bae0..345d762 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -49,7 +49,7 @@ jobs: echo 'merge_derives = true' >> .rustfmt.toml echo 'use_small_heuristics = "Default"' >> .rustfmt.toml cargo fmt - cargo tarpaulin --out Lcov Stdout + cargo tarpaulin --out Lcov Stdout --engine llvm - name: Upload to codecov.io uses: codecov/codecov-action@v5 with: From 57ad145ca69995a551a8e1f4fe20c7be45d0d0a7 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 20:27:09 +0900 Subject: [PATCH 03/12] Add datetime --- crates/vespera_macro/src/args.rs | 600 ++++----- crates/vespera_macro/src/collector.rs | 1414 ++++++++++----------- crates/vespera_macro/src/parser/schema.rs | 403 +++++- 3 files changed, 1402 insertions(+), 1015 deletions(-) diff --git a/crates/vespera_macro/src/args.rs b/crates/vespera_macro/src/args.rs index d414808..ad9ee08 100644 --- a/crates/vespera_macro/src/args.rs +++ b/crates/vespera_macro/src/args.rs @@ -1,300 +1,300 @@ -pub struct RouteArgs { - pub method: Option, - pub path: Option, - pub error_status: Option, - pub tags: Option, - pub description: Option, -} - -impl syn::parse::Parse for RouteArgs { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let mut method: Option = None; - let mut path: Option = None; - let mut error_status: Option = None; - let mut tags: Option = None; - let mut description: Option = None; - - // Parse comma-separated list of arguments - while !input.is_empty() { - let lookahead = input.lookahead1(); - - if lookahead.peek(syn::Ident) { - // Try to parse as method identifier (get, post, etc.) - let ident: syn::Ident = input.parse()?; - let ident_str = ident.to_string().to_lowercase(); - match ident_str.as_str() { - "get" | "post" | "put" | "patch" | "delete" | "head" | "options" => { - method = Some(ident); - } - "path" => { - input.parse::()?; - let lit: syn::LitStr = input.parse()?; - path = Some(lit); - } - "error_status" => { - input.parse::()?; - let array: syn::ExprArray = input.parse()?; - error_status = Some(array); - } - "tags" => { - input.parse::()?; - let array: syn::ExprArray = input.parse()?; - tags = Some(array); - } - "description" => { - input.parse::()?; - let lit: syn::LitStr = input.parse()?; - description = Some(lit); - } - _ => { - return Err(lookahead.error()); - } - } - - // Check if there's a comma - if input.peek(syn::Token![,]) { - input.parse::()?; - } else { - break; - } - } else { - return Err(lookahead.error()); - } - } - - Ok(RouteArgs { - method, - path, - error_status, - tags, - description, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - #[rstest] - // Method only - #[case("get", true, Some("get"), None, None)] - #[case("post", true, Some("post"), None, None)] - #[case("put", true, Some("put"), None, None)] - #[case("patch", true, Some("patch"), None, None)] - #[case("delete", true, Some("delete"), None, None)] - #[case("head", true, Some("head"), None, None)] - #[case("options", true, Some("options"), None, None)] - // Path only - #[case("path = \"/api\"", true, None, Some("/api"), None)] - #[case("path = \"/users\"", true, None, Some("/users"), None)] - #[case("path = \"/api/v1\"", true, None, Some("/api/v1"), None)] - // Method and path - #[case("get, path = \"/api\"", true, Some("get"), Some("/api"), None)] - #[case("post, path = \"/users\"", true, Some("post"), Some("/users"), None)] - #[case("path = \"/api\", get", true, Some("get"), Some("/api"), None)] - // Error status only - #[case("error_status = [400]", true, None, None, Some(vec![400]))] - #[case("error_status = [400, 404]", true, None, None, Some(vec![400, 404]))] - #[case("error_status = [400, 404, 500]", true, None, None, Some(vec![400, 404, 500]))] - // Method and error_status - #[case("get, error_status = [400]", true, Some("get"), None, Some(vec![400]))] - #[case("post, error_status = [400, 404]", true, Some("post"), None, Some(vec![400, 404]))] - // Path and error_status - #[case("path = \"/api\", error_status = [400]", true, None, Some("/api"), Some(vec![400]))] - // All three - #[case("get, path = \"/api\", error_status = [400]", true, Some("get"), Some("/api"), Some(vec![400]))] - #[case("post, path = \"/users\", error_status = [400, 404]", true, Some("post"), Some("/users"), Some(vec![400, 404]))] - #[case("path = \"/api\", get, error_status = [400]", true, Some("get"), Some("/api"), Some(vec![400]))] - // Empty input - #[case("", true, None, None, None)] - // Invalid cases - #[case("invalid", false, None, None, None)] - #[case("path", false, None, None, None)] - #[case("error_status", false, None, None, None)] - #[case("get, invalid", false, None, None, None)] - #[case("path =", false, None, None, None)] - #[case("error_status =", false, None, None, None)] - // Non-Ident tokens (should trigger line 40) - #[case("123", false, None, None, None)] - #[case("\"string\"", false, None, None, None)] - #[case("=", false, None, None, None)] - #[case("[", false, None, None, None)] - #[case("]", false, None, None, None)] - #[case(",", false, None, None, None)] - #[case("get, 123", false, None, None, None)] - #[case("get, =", false, None, None, None)] - fn test_route_args_parse( - #[case] input: &str, - #[case] should_parse: bool, - #[case] expected_method: Option<&str>, - #[case] expected_path: Option<&str>, - #[case] expected_error_status: Option>, - ) { - let result = syn::parse_str::(input); - - match (should_parse, result) { - (true, Ok(route_args)) => { - // Check method - if let Some(exp_method) = expected_method { - assert!( - route_args.method.is_some(), - "Expected method {} but got None for input: {}", - exp_method, - input - ); - assert_eq!( - route_args.method.as_ref().unwrap().to_string(), - exp_method, - "Method mismatch for input: {}", - input - ); - } else { - assert!( - route_args.method.is_none(), - "Expected no method but got {:?} for input: {}", - route_args.method, - input - ); - } - - // Check path - if let Some(exp_path) = expected_path { - assert!( - route_args.path.is_some(), - "Expected path {} but got None for input: {}", - exp_path, - input - ); - assert_eq!( - route_args.path.as_ref().unwrap().value(), - exp_path, - "Path mismatch for input: {}", - input - ); - } else { - assert!( - route_args.path.is_none(), - "Expected no path but got {:?} for input: {}", - route_args.path, - input - ); - } - - // Check error_status - if let Some(exp_status) = expected_error_status { - assert!( - route_args.error_status.is_some(), - "Expected error_status {:?} but got None for input: {}", - exp_status, - input - ); - let array = route_args.error_status.as_ref().unwrap(); - let mut status_codes = Vec::new(); - for elem in &array.elems { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Int(lit_int), - .. - }) = elem - && let Ok(code) = lit_int.base10_parse::() - { - status_codes.push(code); - } - } - assert_eq!( - status_codes, exp_status, - "Error status mismatch for input: {}", - input - ); - } else { - assert!( - route_args.error_status.is_none(), - "Expected no error_status but got {:?} for input: {}", - route_args.error_status, - input - ); - } - } - (false, Err(_)) => { - // Expected error, test passes - } - (true, Err(e)) => { - panic!( - "Expected successful parse but got error: {} for input: {}", - e, input - ); - } - (false, Ok(_)) => { - panic!("Expected parse error but got success for input: {}", input); - } - } - } - - #[rstest] - // Tags only - #[case("tags = [\"users\"]", true, vec!["users"])] - #[case("tags = [\"users\", \"admin\"]", true, vec!["users", "admin"])] - #[case("tags = [\"api\", \"v1\", \"users\"]", true, vec!["api", "v1", "users"])] - // Tags with method - #[case("get, tags = [\"users\"]", true, vec!["users"])] - #[case("post, tags = [\"users\", \"create\"]", true, vec!["users", "create"])] - // Tags with path - #[case("path = \"/api\", tags = [\"api\"]", true, vec!["api"])] - // Tags with method and path - #[case("get, path = \"/users\", tags = [\"users\"]", true, vec!["users"])] - // Empty tags array - #[case("tags = []", true, vec![])] - fn test_route_args_parse_tags( - #[case] input: &str, - #[case] should_parse: bool, - #[case] expected_tags: Vec<&str>, - ) { - let result = syn::parse_str::(input); - - match (should_parse, result) { - (true, Ok(route_args)) => { - if expected_tags.is_empty() { - // Empty array should result in Some with empty vec - if let Some(tags_array) = &route_args.tags { - assert!(tags_array.elems.is_empty()); - } - } else { - assert!( - route_args.tags.is_some(), - "Expected tags but got None for input: {}", - input - ); - let tags_array = route_args.tags.as_ref().unwrap(); - let mut parsed_tags = Vec::new(); - for elem in &tags_array.elems { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = elem - { - parsed_tags.push(lit_str.value()); - } - } - assert_eq!( - parsed_tags, expected_tags, - "Tags mismatch for input: {}", - input - ); - } - } - (false, Err(_)) => { - // Expected error, test passes - } - (true, Err(e)) => { - panic!( - "Expected successful parse but got error: {} for input: {}", - e, input - ); - } - (false, Ok(_)) => { - panic!("Expected parse error but got success for input: {}", input); - } - } - } -} +pub struct RouteArgs { + pub method: Option, + pub path: Option, + pub error_status: Option, + pub tags: Option, + pub description: Option, +} + +impl syn::parse::Parse for RouteArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut method: Option = None; + let mut path: Option = None; + let mut error_status: Option = None; + let mut tags: Option = None; + let mut description: Option = None; + + // Parse comma-separated list of arguments + while !input.is_empty() { + let lookahead = input.lookahead1(); + + if lookahead.peek(syn::Ident) { + // Try to parse as method identifier (get, post, etc.) + let ident: syn::Ident = input.parse()?; + let ident_str = ident.to_string().to_lowercase(); + match ident_str.as_str() { + "get" | "post" | "put" | "patch" | "delete" | "head" | "options" => { + method = Some(ident); + } + "path" => { + input.parse::()?; + let lit: syn::LitStr = input.parse()?; + path = Some(lit); + } + "error_status" => { + input.parse::()?; + let array: syn::ExprArray = input.parse()?; + error_status = Some(array); + } + "tags" => { + input.parse::()?; + let array: syn::ExprArray = input.parse()?; + tags = Some(array); + } + "description" => { + input.parse::()?; + let lit: syn::LitStr = input.parse()?; + description = Some(lit); + } + _ => { + return Err(lookahead.error()); + } + } + + // Check if there's a comma + if input.peek(syn::Token![,]) { + input.parse::()?; + } else { + break; + } + } else { + return Err(lookahead.error()); + } + } + + Ok(RouteArgs { + method, + path, + error_status, + tags, + description, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + // Method only + #[case("get", true, Some("get"), None, None)] + #[case("post", true, Some("post"), None, None)] + #[case("put", true, Some("put"), None, None)] + #[case("patch", true, Some("patch"), None, None)] + #[case("delete", true, Some("delete"), None, None)] + #[case("head", true, Some("head"), None, None)] + #[case("options", true, Some("options"), None, None)] + // Path only + #[case("path = \"/api\"", true, None, Some("/api"), None)] + #[case("path = \"/users\"", true, None, Some("/users"), None)] + #[case("path = \"/api/v1\"", true, None, Some("/api/v1"), None)] + // Method and path + #[case("get, path = \"/api\"", true, Some("get"), Some("/api"), None)] + #[case("post, path = \"/users\"", true, Some("post"), Some("/users"), None)] + #[case("path = \"/api\", get", true, Some("get"), Some("/api"), None)] + // Error status only + #[case("error_status = [400]", true, None, None, Some(vec![400]))] + #[case("error_status = [400, 404]", true, None, None, Some(vec![400, 404]))] + #[case("error_status = [400, 404, 500]", true, None, None, Some(vec![400, 404, 500]))] + // Method and error_status + #[case("get, error_status = [400]", true, Some("get"), None, Some(vec![400]))] + #[case("post, error_status = [400, 404]", true, Some("post"), None, Some(vec![400, 404]))] + // Path and error_status + #[case("path = \"/api\", error_status = [400]", true, None, Some("/api"), Some(vec![400]))] + // All three + #[case("get, path = \"/api\", error_status = [400]", true, Some("get"), Some("/api"), Some(vec![400]))] + #[case("post, path = \"/users\", error_status = [400, 404]", true, Some("post"), Some("/users"), Some(vec![400, 404]))] + #[case("path = \"/api\", get, error_status = [400]", true, Some("get"), Some("/api"), Some(vec![400]))] + // Empty input + #[case("", true, None, None, None)] + // Invalid cases + #[case("invalid", false, None, None, None)] + #[case("path", false, None, None, None)] + #[case("error_status", false, None, None, None)] + #[case("get, invalid", false, None, None, None)] + #[case("path =", false, None, None, None)] + #[case("error_status =", false, None, None, None)] + // Non-Ident tokens (should trigger line 40) + #[case("123", false, None, None, None)] + #[case("\"string\"", false, None, None, None)] + #[case("=", false, None, None, None)] + #[case("[", false, None, None, None)] + #[case("]", false, None, None, None)] + #[case(",", false, None, None, None)] + #[case("get, 123", false, None, None, None)] + #[case("get, =", false, None, None, None)] + fn test_route_args_parse( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_method: Option<&str>, + #[case] expected_path: Option<&str>, + #[case] expected_error_status: Option>, + ) { + let result = syn::parse_str::(input); + + match (should_parse, result) { + (true, Ok(route_args)) => { + // Check method + if let Some(exp_method) = expected_method { + assert!( + route_args.method.is_some(), + "Expected method {} but got None for input: {}", + exp_method, + input + ); + assert_eq!( + route_args.method.as_ref().unwrap().to_string(), + exp_method, + "Method mismatch for input: {}", + input + ); + } else { + assert!( + route_args.method.is_none(), + "Expected no method but got {:?} for input: {}", + route_args.method, + input + ); + } + + // Check path + if let Some(exp_path) = expected_path { + assert!( + route_args.path.is_some(), + "Expected path {} but got None for input: {}", + exp_path, + input + ); + assert_eq!( + route_args.path.as_ref().unwrap().value(), + exp_path, + "Path mismatch for input: {}", + input + ); + } else { + assert!( + route_args.path.is_none(), + "Expected no path but got {:?} for input: {}", + route_args.path, + input + ); + } + + // Check error_status + if let Some(exp_status) = expected_error_status { + assert!( + route_args.error_status.is_some(), + "Expected error_status {:?} but got None for input: {}", + exp_status, + input + ); + let array = route_args.error_status.as_ref().unwrap(); + let mut status_codes = Vec::new(); + for elem in &array.elems { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(lit_int), + .. + }) = elem + && let Ok(code) = lit_int.base10_parse::() + { + status_codes.push(code); + } + } + assert_eq!( + status_codes, exp_status, + "Error status mismatch for input: {}", + input + ); + } else { + assert!( + route_args.error_status.is_none(), + "Expected no error_status but got {:?} for input: {}", + route_args.error_status, + input + ); + } + } + (false, Err(_)) => { + // Expected error, test passes + } + (true, Err(e)) => { + panic!( + "Expected successful parse but got error: {} for input: {}", + e, input + ); + } + (false, Ok(_)) => { + panic!("Expected parse error but got success for input: {}", input); + } + } + } + + #[rstest] + // Tags only + #[case("tags = [\"users\"]", true, vec!["users"])] + #[case("tags = [\"users\", \"admin\"]", true, vec!["users", "admin"])] + #[case("tags = [\"api\", \"v1\", \"users\"]", true, vec!["api", "v1", "users"])] + // Tags with method + #[case("get, tags = [\"users\"]", true, vec!["users"])] + #[case("post, tags = [\"users\", \"create\"]", true, vec!["users", "create"])] + // Tags with path + #[case("path = \"/api\", tags = [\"api\"]", true, vec!["api"])] + // Tags with method and path + #[case("get, path = \"/users\", tags = [\"users\"]", true, vec!["users"])] + // Empty tags array + #[case("tags = []", true, vec![])] + fn test_route_args_parse_tags( + #[case] input: &str, + #[case] should_parse: bool, + #[case] expected_tags: Vec<&str>, + ) { + let result = syn::parse_str::(input); + + match (should_parse, result) { + (true, Ok(route_args)) => { + if expected_tags.is_empty() { + // Empty array should result in Some with empty vec + if let Some(tags_array) = &route_args.tags { + assert!(tags_array.elems.is_empty()); + } + } else { + assert!( + route_args.tags.is_some(), + "Expected tags but got None for input: {}", + input + ); + let tags_array = route_args.tags.as_ref().unwrap(); + let mut parsed_tags = Vec::new(); + for elem in &tags_array.elems { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = elem + { + parsed_tags.push(lit_str.value()); + } + } + assert_eq!( + parsed_tags, expected_tags, + "Tags mismatch for input: {}", + input + ); + } + } + (false, Err(_)) => { + // Expected error, test passes + } + (true, Err(e)) => { + panic!( + "Expected successful parse but got error: {} for input: {}", + e, input + ); + } + (false, Ok(_)) => { + panic!("Expected parse error but got success for input: {}", input); + } + } + } +} diff --git a/crates/vespera_macro/src/collector.rs b/crates/vespera_macro/src/collector.rs index 37a6ac5..c11c9e8 100644 --- a/crates/vespera_macro/src/collector.rs +++ b/crates/vespera_macro/src/collector.rs @@ -1,707 +1,707 @@ -//! Collector for routes and structs - -use crate::file_utils::{collect_files, file_to_segments}; -use crate::metadata::{CollectedMetadata, RouteMetadata}; -use crate::route::{extract_doc_comment, extract_route_info}; -use anyhow::{Context, Result}; -use std::path::Path; -use syn::Item; - -/// Collect routes and structs from a folder -pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result { - let mut metadata = CollectedMetadata::new(); - - let files = collect_files(folder_path).with_context(|| { - format!( - "Failed to collect files from wtf: {}", - folder_path.display() - ) - })?; - - for file in files { - if !file.extension().map(|e| e == "rs").unwrap_or(false) { - continue; - } - - let content = std::fs::read_to_string(&file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; - - let file_ast = syn::parse_file(&content) - .with_context(|| format!("Failed to parse file: {}", file.display()))?; - - // Get module path - let segments = file - .strip_prefix(folder_path) - .map(|file_stem| file_to_segments(file_stem, folder_path)) - .context(format!( - "Failed to strip prefix from file: {} (base: {})", - file.display(), - folder_path.display() - ))?; - - let module_path = if folder_name.is_empty() { - segments.join("::") - } else { - format!("{}::{}", folder_name, segments.join("::")) - }; - - let file_path = file.display().to_string(); - - // 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 { - let base = format!("/{}", segments.join("/")); - let trimmed_base = base.trim_end_matches('/'); - format!("{}/{}", trimmed_base, custom_path.trim_start_matches('/')) - } else { - format!("/{}", segments.join("/")) - }; - 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) -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - use std::fs; - use tempfile::TempDir; - - fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { - let file_path = dir.path().join(filename); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).expect("Failed to create parent directory"); - } - fs::write(&file_path, content).expect("Failed to write temp file"); - file_path - } - - #[test] - fn test_collect_metadata_empty_folder() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert!(metadata.routes.is_empty()); - assert!(metadata.structs.is_empty()); - - drop(temp_dir); - } - - #[rstest] - #[case::single_get_route( - "routes", - vec![( - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users", - "get_users", - "routes::users", - )] - #[case::single_post_route( - "routes", - vec![( - "create_user.rs", - r#" -#[route(post)] -pub fn create_user() -> String { - "created".to_string() -} -"#, - )], - "post", - "/create-user", - "create_user", - "routes::create_user", - )] - #[case::route_with_custom_path( - "routes", - vec![( - "users.rs", - r#" -#[route(get, path = "/api/users")] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users/api/users", - "get_users", - "routes::users", - )] - #[case::route_with_error_status( - "routes", - vec![( - "users.rs", - r#" -#[route(get, error_status = [400, 404])] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/users", - "get_users", - "routes::users", - )] - #[case::nested_module( - "routes", - vec![( - "api/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/users", - "get_users", - "routes::api::users", - )] - #[case::deeply_nested_module( - "routes", - vec![( - "api/v1/users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - )], - "get", - "/api/v1/users", - "get_users", - "routes::api::v1::users", - )] - fn test_collect_metadata_routes( - #[case] folder_name: &str, - #[case] files: Vec<(&str, &str)>, - #[case] expected_method: &str, - #[case] expected_path: &str, - #[case] expected_function_name: &str, - #[case] expected_module_path: &str, - ) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - - for (filename, content) in &files { - create_temp_file(&temp_dir, filename, content); - } - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - let route = &metadata.routes[0]; - assert_eq!(route.method, expected_method); - assert_eq!(route.path, expected_path); - assert_eq!(route.function_name, expected_function_name); - assert_eq!(route.module_path, expected_module_path); - if let Some((first_filename, _)) = files.first() { - assert!( - route - .file_path - .contains(first_filename.split('/').next().unwrap()) - ); - } - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_single_struct() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_struct_without_schema() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r#" -pub struct User { - pub id: i32, - pub name: String, -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 0); - assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_route_and_struct() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r#" -use vespera::Schema; - -#[derive(Schema)] -pub struct User { - pub id: i32, - pub name: String, -} - -#[route(get)] -pub fn get_user() -> User { - User { id: 1, name: "Alice".to_string() } -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - - let route = &metadata.routes[0]; - assert_eq!(route.function_name, "get_user"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_multiple_routes() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} - -#[route(post)] -pub fn create_users() -> String { - "created".to_string() -} -"#, - ); - - create_temp_file( - &temp_dir, - "posts.rs", - r#" -#[route(get)] -pub fn get_posts() -> String { - "posts".to_string() -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 3); - assert_eq!(metadata.structs.len(), 0); - - // Check all routes are present - let function_names: Vec<&str> = metadata - .routes - .iter() - .map(|r| r.function_name.as_str()) - .collect(); - assert!(function_names.contains(&"get_users")); - assert!(function_names.contains(&"create_users")); - assert!(function_names.contains(&"get_posts")); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_multiple_structs() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r#" -use vespera::Schema; - -#[derive(Schema)] -pub struct User { - pub id: i32, - pub name: String, -} -"#, - ); - - create_temp_file( - &temp_dir, - "post.rs", - r#" -use vespera::Schema; - -#[derive(Schema)] -pub struct Post { - pub id: i32, - pub title: String, -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_with_mod_rs() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "mod.rs", - r#" -#[route(get)] -pub fn index() -> String { - "index".to_string() -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.function_name, "index"); - assert_eq!(route.path, "/"); - assert_eq!(route.module_path, "routes::"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_empty_folder_name() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = ""; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.module_path, "users"); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_ignores_non_rs_files() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - create_temp_file(&temp_dir, "config.txt", "some config content"); - - create_temp_file(&temp_dir, "readme.md", "# Readme"); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - // Only .rs file should be processed - assert_eq!(metadata.routes.len(), 1); - assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_ignores_invalid_syntax() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "valid.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {"); - - let metadata = collect_metadata(temp_dir.path(), folder_name); - - // Only valid file should be processed - assert!(metadata.is_err()); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_error_status() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "users.rs", - r#" -#[route(get, error_status = [400, 404, 500])] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 1); - let route = &metadata.routes[0]; - assert_eq!(route.method, "get"); - assert!(route.error_status.is_some()); - let error_status = route.error_status.as_ref().unwrap(); - assert_eq!(error_status.len(), 3); - assert!(error_status.contains(&400)); - assert!(error_status.contains(&404)); - assert!(error_status.contains(&500)); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_all_http_methods() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "routes.rs", - r#" -#[route(get)] -pub fn get_handler() -> String { "get".to_string() } - -#[route(post)] -pub fn post_handler() -> String { "post".to_string() } - -#[route(put)] -pub fn put_handler() -> String { "put".to_string() } - -#[route(patch)] -pub fn patch_handler() -> String { "patch".to_string() } - -#[route(delete)] -pub fn delete_handler() -> String { "delete".to_string() } - -#[route(head)] -pub fn head_handler() -> String { "head".to_string() } - -#[route(options)] -pub fn options_handler() -> String { "options".to_string() } -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - assert_eq!(metadata.routes.len(), 7); - - let methods: Vec<&str> = metadata.routes.iter().map(|r| r.method.as_str()).collect(); - assert!(methods.contains(&"get")); - assert!(methods.contains(&"post")); - assert!(methods.contains(&"put")); - assert!(methods.contains(&"patch")); - assert!(methods.contains(&"delete")); - assert!(methods.contains(&"head")); - assert!(methods.contains(&"options")); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_collect_files_error() { - // Test: collect_files returns error (non-existent directory) - 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); - - // Should return error when collect_files fails - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Failed to collect files")); - } - - #[test] - fn test_collect_metadata_file_read_error() { - // Test line 25: file read error - // This is difficult to test directly, but we can test with a file that becomes - // inaccessible. However, in practice, if the file exists, read_to_string usually succeeds. - // For coverage purposes, we'll create a scenario where the file might fail to read. - // Actually, this is hard to simulate reliably, so we'll skip this for now. - // The continue path is already covered by invalid syntax tests. - } - - #[test] - fn test_collect_metadata_strip_prefix_error() { - // Test line 37: strip_prefix fails - // Note: This is a defensive programming path that is unlikely to be executed - // in practice because collect_files always returns files under folder_path. - // However, path normalization differences could theoretically cause this. - // For coverage purposes, we test the normal case where strip_prefix succeeds. - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - // Create a subdirectory - let sub_dir = temp_dir.path().join("routes"); - std::fs::create_dir_all(&sub_dir).expect("Failed to create subdirectory"); - - // Create a file in the subdirectory - create_temp_file( - &temp_dir, - "routes/valid.rs", - r#" -#[route(get)] -pub fn get_users() -> String { - "users".to_string() -} -"#, - ); - - // Collect metadata from the subdirectory - let metadata = collect_metadata(&sub_dir, folder_name).unwrap(); - - // Should collect the route (strip_prefix succeeds in normal cases) - assert_eq!(metadata.routes.len(), 1); - - // The continue path on line 37 is defensive code that handles edge cases - // where path normalization might cause strip_prefix to fail, but this is - // extremely rare in practice. - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_struct_without_derive() { - // Test line 81: attr.path().is_ident("derive") returns false - // Struct with non-derive attributes should not be collected - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r#" -#[allow(dead_code)] -pub struct User { - pub id: i32, - pub name: String, -} -"#, - ); - - let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); - - // Struct without Schema derive should not be collected - assert_eq!(metadata.structs.len(), 0); - - drop(temp_dir); - } - - #[test] - fn test_collect_metadata_struct_with_other_derive() { - // Test line 81: struct with other derive attributes (not Schema) - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let folder_name = "routes"; - - create_temp_file( - &temp_dir, - "user.rs", - r#" -#[derive(Debug, Clone)] -pub struct User { - pub id: i32, - pub name: String, -} -"#, - ); - - let metadata = 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); - } -} +//! Collector for routes and structs + +use crate::file_utils::{collect_files, file_to_segments}; +use crate::metadata::{CollectedMetadata, RouteMetadata}; +use crate::route::{extract_doc_comment, extract_route_info}; +use anyhow::{Context, Result}; +use std::path::Path; +use syn::Item; + +/// Collect routes and structs from a folder +pub fn collect_metadata(folder_path: &Path, folder_name: &str) -> Result { + let mut metadata = CollectedMetadata::new(); + + let files = collect_files(folder_path).with_context(|| { + format!( + "Failed to collect files from wtf: {}", + folder_path.display() + ) + })?; + + for file in files { + if !file.extension().map(|e| e == "rs").unwrap_or(false) { + continue; + } + + let content = std::fs::read_to_string(&file) + .with_context(|| format!("Failed to read file: {}", file.display()))?; + + let file_ast = syn::parse_file(&content) + .with_context(|| format!("Failed to parse file: {}", file.display()))?; + + // Get module path + let segments = file + .strip_prefix(folder_path) + .map(|file_stem| file_to_segments(file_stem, folder_path)) + .context(format!( + "Failed to strip prefix from file: {} (base: {})", + file.display(), + folder_path.display() + ))?; + + let module_path = if folder_name.is_empty() { + segments.join("::") + } else { + format!("{}::{}", folder_name, segments.join("::")) + }; + + let file_path = file.display().to_string(); + + // 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 { + let base = format!("/{}", segments.join("/")); + let trimmed_base = base.trim_end_matches('/'); + format!("{}/{}", trimmed_base, custom_path.trim_start_matches('/')) + } else { + format!("/{}", segments.join("/")) + }; + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use std::fs; + use tempfile::TempDir; + + fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf { + let file_path = dir.path().join(filename); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent directory"); + } + fs::write(&file_path, content).expect("Failed to write temp file"); + file_path + } + + #[test] + fn test_collect_metadata_empty_folder() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert!(metadata.routes.is_empty()); + assert!(metadata.structs.is_empty()); + + drop(temp_dir); + } + + #[rstest] + #[case::single_get_route( + "routes", + vec![( + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/users", + "get_users", + "routes::users", + )] + #[case::single_post_route( + "routes", + vec![( + "create_user.rs", + r#" +#[route(post)] +pub fn create_user() -> String { + "created".to_string() +} +"#, + )], + "post", + "/create-user", + "create_user", + "routes::create_user", + )] + #[case::route_with_custom_path( + "routes", + vec![( + "users.rs", + r#" +#[route(get, path = "/api/users")] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/users/api/users", + "get_users", + "routes::users", + )] + #[case::route_with_error_status( + "routes", + vec![( + "users.rs", + r#" +#[route(get, error_status = [400, 404])] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/users", + "get_users", + "routes::users", + )] + #[case::nested_module( + "routes", + vec![( + "api/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/api/users", + "get_users", + "routes::api::users", + )] + #[case::deeply_nested_module( + "routes", + vec![( + "api/v1/users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + )], + "get", + "/api/v1/users", + "get_users", + "routes::api::v1::users", + )] + fn test_collect_metadata_routes( + #[case] folder_name: &str, + #[case] files: Vec<(&str, &str)>, + #[case] expected_method: &str, + #[case] expected_path: &str, + #[case] expected_function_name: &str, + #[case] expected_module_path: &str, + ) { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + for (filename, content) in &files { + create_temp_file(&temp_dir, filename, content); + } + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + let route = &metadata.routes[0]; + assert_eq!(route.method, expected_method); + assert_eq!(route.path, expected_path); + assert_eq!(route.function_name, expected_function_name); + assert_eq!(route.module_path, expected_module_path); + if let Some((first_filename, _)) = files.first() { + assert!( + route + .file_path + .contains(first_filename.split('/').next().unwrap()) + ); + } + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_single_struct() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 0); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_struct_without_schema() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r#" +pub struct User { + pub id: i32, + pub name: String, +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 0); + assert_eq!(metadata.structs.len(), 0); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_route_and_struct() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r#" +use vespera::Schema; + +#[derive(Schema)] +pub struct User { + pub id: i32, + pub name: String, +} + +#[route(get)] +pub fn get_user() -> User { + User { id: 1, name: "Alice".to_string() } +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "get_user"); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_multiple_routes() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} + +#[route(post)] +pub fn create_users() -> String { + "created".to_string() +} +"#, + ); + + create_temp_file( + &temp_dir, + "posts.rs", + r#" +#[route(get)] +pub fn get_posts() -> String { + "posts".to_string() +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 3); + assert_eq!(metadata.structs.len(), 0); + + // Check all routes are present + let function_names: Vec<&str> = metadata + .routes + .iter() + .map(|r| r.function_name.as_str()) + .collect(); + assert!(function_names.contains(&"get_users")); + assert!(function_names.contains(&"create_users")); + assert!(function_names.contains(&"get_posts")); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_multiple_structs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r#" +use vespera::Schema; + +#[derive(Schema)] +pub struct User { + pub id: i32, + pub name: String, +} +"#, + ); + + create_temp_file( + &temp_dir, + "post.rs", + r#" +use vespera::Schema; + +#[derive(Schema)] +pub struct Post { + pub id: i32, + pub title: String, +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 0); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_with_mod_rs() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "mod.rs", + r#" +#[route(get)] +pub fn index() -> String { + "index".to_string() +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.function_name, "index"); + assert_eq!(route.path, "/"); + assert_eq!(route.module_path, "routes::"); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_empty_folder_name() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = ""; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.module_path, "users"); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_ignores_non_rs_files() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + ); + + create_temp_file(&temp_dir, "config.txt", "some config content"); + + create_temp_file(&temp_dir, "readme.md", "# Readme"); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + // Only .rs file should be processed + assert_eq!(metadata.routes.len(), 1); + assert_eq!(metadata.structs.len(), 0); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_ignores_invalid_syntax() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "valid.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + ); + + create_temp_file(&temp_dir, "invalid.rs", "invalid rust syntax {"); + + let metadata = collect_metadata(temp_dir.path(), folder_name); + + // Only valid file should be processed + assert!(metadata.is_err()); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_error_status() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "users.rs", + r#" +#[route(get, error_status = [400, 404, 500])] +pub fn get_users() -> String { + "users".to_string() +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 1); + let route = &metadata.routes[0]; + assert_eq!(route.method, "get"); + assert!(route.error_status.is_some()); + let error_status = route.error_status.as_ref().unwrap(); + assert_eq!(error_status.len(), 3); + assert!(error_status.contains(&400)); + assert!(error_status.contains(&404)); + assert!(error_status.contains(&500)); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_all_http_methods() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "routes.rs", + r#" +#[route(get)] +pub fn get_handler() -> String { "get".to_string() } + +#[route(post)] +pub fn post_handler() -> String { "post".to_string() } + +#[route(put)] +pub fn put_handler() -> String { "put".to_string() } + +#[route(patch)] +pub fn patch_handler() -> String { "patch".to_string() } + +#[route(delete)] +pub fn delete_handler() -> String { "delete".to_string() } + +#[route(head)] +pub fn head_handler() -> String { "head".to_string() } + +#[route(options)] +pub fn options_handler() -> String { "options".to_string() } +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + assert_eq!(metadata.routes.len(), 7); + + let methods: Vec<&str> = metadata.routes.iter().map(|r| r.method.as_str()).collect(); + assert!(methods.contains(&"get")); + assert!(methods.contains(&"post")); + assert!(methods.contains(&"put")); + assert!(methods.contains(&"patch")); + assert!(methods.contains(&"delete")); + assert!(methods.contains(&"head")); + assert!(methods.contains(&"options")); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_collect_files_error() { + // Test: collect_files returns error (non-existent directory) + 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); + + // Should return error when collect_files fails + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Failed to collect files")); + } + + #[test] + fn test_collect_metadata_file_read_error() { + // Test line 25: file read error + // This is difficult to test directly, but we can test with a file that becomes + // inaccessible. However, in practice, if the file exists, read_to_string usually succeeds. + // For coverage purposes, we'll create a scenario where the file might fail to read. + // Actually, this is hard to simulate reliably, so we'll skip this for now. + // The continue path is already covered by invalid syntax tests. + } + + #[test] + fn test_collect_metadata_strip_prefix_error() { + // Test line 37: strip_prefix fails + // Note: This is a defensive programming path that is unlikely to be executed + // in practice because collect_files always returns files under folder_path. + // However, path normalization differences could theoretically cause this. + // For coverage purposes, we test the normal case where strip_prefix succeeds. + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + // Create a subdirectory + let sub_dir = temp_dir.path().join("routes"); + std::fs::create_dir_all(&sub_dir).expect("Failed to create subdirectory"); + + // Create a file in the subdirectory + create_temp_file( + &temp_dir, + "routes/valid.rs", + r#" +#[route(get)] +pub fn get_users() -> String { + "users".to_string() +} +"#, + ); + + // Collect metadata from the subdirectory + let metadata = collect_metadata(&sub_dir, folder_name).unwrap(); + + // Should collect the route (strip_prefix succeeds in normal cases) + assert_eq!(metadata.routes.len(), 1); + + // The continue path on line 37 is defensive code that handles edge cases + // where path normalization might cause strip_prefix to fail, but this is + // extremely rare in practice. + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_struct_without_derive() { + // Test line 81: attr.path().is_ident("derive") returns false + // Struct with non-derive attributes should not be collected + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r#" +#[allow(dead_code)] +pub struct User { + pub id: i32, + pub name: String, +} +"#, + ); + + let metadata = collect_metadata(temp_dir.path(), folder_name).unwrap(); + + // Struct without Schema derive should not be collected + assert_eq!(metadata.structs.len(), 0); + + drop(temp_dir); + } + + #[test] + fn test_collect_metadata_struct_with_other_derive() { + // Test line 81: struct with other derive attributes (not Schema) + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let folder_name = "routes"; + + create_temp_file( + &temp_dir, + "user.rs", + r#" +#[derive(Debug, Clone)] +pub struct User { + pub id: i32, + pub name: String, +} +"#, + ); + + let metadata = 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); + } +} diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index c64fea8..69291e1 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -30,7 +30,7 @@ pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { return found_rename_all; } - // Fallback: manual token parsing + // Fallback: manual token parsing for complex attribute combinations let tokens = match attr.meta.require_list() { Ok(t) => t, Err(_) => continue, @@ -80,7 +80,7 @@ pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option { return Some(rename_value); } - // Fallback: manual token parsing with regex-like approach + // Fallback: manual token parsing for complex attribute combinations let tokens = meta_list.tokens.to_string(); // Look for pattern: rename = "value" (with proper word boundaries) if let Some(start) = tokens.find("rename") { @@ -175,7 +175,7 @@ pub fn extract_skip_serializing_if(attrs: &[syn::Attribute]) -> bool { return true; } - // Fallback: check tokens string + // Fallback: check tokens string for complex attribute combinations let tokens = meta_list.tokens.to_string(); if tokens.contains("skip_serializing_if") { return true; @@ -218,16 +218,24 @@ pub fn extract_default(attrs: &[syn::Attribute]) -> Option> { return Some(default_value); } - // Fallback: manual token parsing + // Fallback: manual token parsing for complex attribute combinations let tokens = meta_list.tokens.to_string(); if let Some(start) = tokens.find("default") { let remaining = &tokens[start + "default".len()..]; if remaining.trim_start().starts_with('=') { // default = "function_name" - let value_part = remaining.trim_start()[1..].trim(); - if value_part.starts_with('"') && value_part.ends_with('"') { - let function_name = &value_part[1..value_part.len() - 1]; - return Some(Some(function_name.to_string())); + let after_equals = remaining + .trim_start() + .strip_prefix('=') + .unwrap_or("") + .trim_start(); + // Extract string value - find opening and closing quotes + if let Some(quote_start) = after_equals.find('"') { + let after_quote = &after_equals[quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + let function_name = &after_quote[..quote_end]; + return Some(Some(function_name.to_string())); + } } } else { // Just "default" without = (standalone) @@ -1909,6 +1917,385 @@ mod tests { } } + // Tests for fallback parsing paths using synthetic attributes + mod fallback_parsing_tests { + use super::*; + + /// Helper to create attributes by parsing a struct with the given serde attributes + fn get_struct_attrs(serde_content: &str) -> Vec { + let src = format!(r#"#[serde({})] struct Foo;"#, serde_content); + let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); + item.attrs + } + + /// Helper to create field attributes by parsing a struct with the field + fn get_field_attrs(serde_content: &str) -> Vec { + let src = format!(r#"struct Foo {{ #[serde({})] field: i32 }}"#, serde_content); + let item: syn::ItemStruct = syn::parse_str(&src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + fields.named.first().unwrap().attrs.clone() + } else { + vec![] + } + } + + /// Test extract_rename_all fallback by creating an attribute where + /// parse_nested_meta succeeds but doesn't find rename_all in the expected format + #[test] + fn test_extract_rename_all_fallback_path() { + // Standard path - parse_nested_meta should work + let attrs = get_struct_attrs(r#"rename_all = "camelCase""#); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_field_rename fallback + #[test] + fn test_extract_field_rename_fallback_path() { + // Standard path + let attrs = get_field_attrs(r#"rename = "myField""#); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("myField")); + } + + /// Test extract_skip_serializing_if with fallback token check + #[test] + fn test_extract_skip_serializing_if_fallback_path() { + let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); + let result = extract_skip_serializing_if(&attrs); + assert!(result); + } + + /// Test extract_default standalone fallback + #[test] + fn test_extract_default_standalone_fallback_path() { + // Simple default without function + let attrs = get_field_attrs(r#"default"#); + let result = extract_default(&attrs); + assert_eq!(result, Some(None)); + } + + /// Test extract_default with function fallback + #[test] + fn test_extract_default_with_function_fallback_path() { + let attrs = get_field_attrs(r#"default = "my_default_fn""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(Some("my_default_fn".to_string()))); + } + + /// Test that rename_all is NOT confused with rename + #[test] + fn test_extract_field_rename_avoids_rename_all() { + let attrs = get_field_attrs(r#"rename_all = "camelCase""#); + let result = extract_field_rename(&attrs); + assert_eq!(result, None); // Should NOT extract rename_all as rename + } + + /// Test empty serde attribute + #[test] + fn test_extract_functions_with_empty_serde() { + let item: syn::ItemStruct = syn::parse_str(r#"#[serde()] struct Foo;"#).unwrap(); + assert_eq!(extract_rename_all(&item.attrs), None); + } + + /// Test non-serde attribute is ignored + #[test] + fn test_extract_functions_ignore_non_serde() { + let item: syn::ItemStruct = syn::parse_str(r#"#[derive(Debug)] struct Foo;"#).unwrap(); + assert_eq!(extract_rename_all(&item.attrs), None); + assert_eq!(extract_field_rename(&item.attrs), None); + } + + /// Test serde attribute that is not a list (e.g., #[serde]) + #[test] + fn test_extract_rename_all_non_list_serde() { + // #[serde] without parentheses - this should just be ignored + let item: syn::ItemStruct = syn::parse_str(r#"#[serde] struct Foo;"#).unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result, None); + } + + /// Test extract_field_rename with complex attribute + #[test] + fn test_extract_field_rename_complex_attr() { + let attrs = get_field_attrs( + r#"default, rename = "field_name", skip_serializing_if = "Option::is_none""#, + ); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("field_name")); + } + + /// Test extract_rename_all with multiple serde attributes on same item + #[test] + fn test_extract_rename_all_multiple_serde_attrs() { + let item: syn::ItemStruct = syn::parse_str( + r#" + #[serde(default)] + #[serde(rename_all = "snake_case")] + struct Foo; + "#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test edge case: rename_all with extra whitespace (manual parsing should handle) + #[test] + fn test_extract_rename_all_with_whitespace() { + // Note: syn normalizes whitespace in parsed tokens, so this tests the robust parsing + let attrs = get_struct_attrs(r#"rename_all = "PascalCase""#); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some("PascalCase")); + } + + /// Test edge case: rename at various positions + #[test] + fn test_extract_field_rename_at_end() { + let attrs = get_field_attrs(r#"skip_serializing_if = "is_none", rename = "lastField""#); + let result = extract_field_rename(&attrs); + assert_eq!(result.as_deref(), Some("lastField")); + } + + /// Test extract_default when it appears with other attrs + #[test] + fn test_extract_default_among_other_attrs() { + let attrs = + get_field_attrs(r#"skip_serializing_if = "is_none", default, rename = "field""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(None)); + } + + /// Test extract_skip - basic functionality + #[test] + fn test_extract_skip_basic() { + let attrs = get_field_attrs(r#"skip"#); + let result = extract_skip(&attrs); + assert!(result); + } + + /// Test extract_skip does not trigger for skip_serializing_if + #[test] + fn test_extract_skip_not_skip_serializing_if() { + let attrs = get_field_attrs(r#"skip_serializing_if = "Option::is_none""#); + let result = extract_skip(&attrs); + assert!(!result); + } + + /// Test extract_skip does not trigger for skip_deserializing + #[test] + fn test_extract_skip_not_skip_deserializing() { + let attrs = get_field_attrs(r#"skip_deserializing"#); + let result = extract_skip(&attrs); + assert!(!result); + } + + /// Test extract_skip with combined attrs + #[test] + fn test_extract_skip_with_other_attrs() { + let attrs = get_field_attrs(r#"skip, default"#); + let result = extract_skip(&attrs); + assert!(result); + } + + /// Test extract_default function with path containing colons + #[test] + fn test_extract_default_with_path() { + let attrs = get_field_attrs(r#"default = "Default::default""#); + let result = extract_default(&attrs); + assert_eq!(result, Some(Some("Default::default".to_string()))); + } + + /// Test extract_skip_serializing_if with complex path + #[test] + fn test_extract_skip_serializing_if_complex_path() { + let attrs = get_field_attrs(r#"skip_serializing_if = "Vec::is_empty""#); + let result = extract_skip_serializing_if(&attrs); + assert!(result); + } + + /// Test extract_rename_all with all supported formats + #[rstest] + #[case("camelCase")] + #[case("snake_case")] + #[case("kebab-case")] + #[case("PascalCase")] + #[case("lowercase")] + #[case("UPPERCASE")] + #[case("SCREAMING_SNAKE_CASE")] + #[case("SCREAMING-KEBAB-CASE")] + fn test_extract_rename_all_all_formats(#[case] format: &str) { + let attrs = get_struct_attrs(&format!(r#"rename_all = "{}""#, format)); + let result = extract_rename_all(&attrs); + assert_eq!(result.as_deref(), Some(format)); + } + + /// Test non-serde attribute doesn't affect extraction + #[test] + fn test_mixed_attributes() { + let item: syn::ItemStruct = syn::parse_str( + r#" + #[derive(Debug, Clone)] + #[serde(rename_all = "camelCase")] + #[doc = "Some documentation"] + struct Foo; + "#, + ) + .unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test field with multiple serde attributes + #[test] + fn test_field_multiple_serde_attrs() { + let item: syn::ItemStruct = syn::parse_str( + r#" + struct Foo { + #[serde(default)] + #[serde(rename = "customName")] + field: i32 + } + "#, + ) + .unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let attrs = &fields.named.first().unwrap().attrs; + let rename = extract_field_rename(attrs); + let default = extract_default(attrs); + assert_eq!(rename.as_deref(), Some("customName")); + assert_eq!(default, Some(None)); + } + } + + /// Test strip_raw_prefix function + #[test] + fn test_strip_raw_prefix() { + assert_eq!(strip_raw_prefix("r#type"), "type"); + assert_eq!(strip_raw_prefix("r#match"), "match"); + assert_eq!(strip_raw_prefix("normal"), "normal"); + assert_eq!(strip_raw_prefix("r#"), ""); + } + + // Tests using programmatically created attributes + mod programmatic_attr_tests { + use super::*; + use proc_macro2::{Span, TokenStream}; + use quote::quote; + + /// Create a serde attribute with programmatic tokens + fn create_attr_with_raw_tokens(tokens: TokenStream) -> syn::Attribute { + syn::Attribute { + pound_token: syn::token::Pound::default(), + style: syn::AttrStyle::Outer, + bracket_token: syn::token::Bracket::default(), + meta: syn::Meta::List(syn::MetaList { + path: syn::Path::from(syn::Ident::new("serde", Span::call_site())), + delimiter: syn::MacroDelimiter::Paren(syn::token::Paren::default()), + tokens, + }), + } + } + + /// Test extract_rename_all with programmatic tokens + #[test] + fn test_extract_rename_all_programmatic() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with invalid value (not a string) + #[test] + fn test_extract_rename_all_invalid_value() { + let tokens = quote!(rename_all = camelCase); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // parse_nested_meta won't find a string literal + assert!(result.is_none()); + } + + /// Test extract_rename_all with missing equals sign + #[test] + fn test_extract_rename_all_no_equals() { + let tokens = quote!(rename_all "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert!(result.is_none()); + } + + /// Test extract_field_rename with programmatic tokens + #[test] + fn test_extract_field_rename_programmatic() { + let tokens = quote!(rename = "customField"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result.as_deref(), Some("customField")); + } + + /// Test extract_default standalone with programmatic tokens + #[test] + fn test_extract_default_programmatic() { + let tokens = quote!(default); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!(result, Some(None)); + } + + /// Test extract_default with function via programmatic tokens + #[test] + fn test_extract_default_with_fn_programmatic() { + let tokens = quote!(default = "my_fn"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_default(&[attr]); + assert_eq!(result, Some(Some("my_fn".to_string()))); + } + + /// Test extract_skip_serializing_if with programmatic tokens + #[test] + fn test_extract_skip_serializing_if_programmatic() { + let tokens = quote!(skip_serializing_if = "is_none"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_skip_serializing_if(&[attr]); + assert!(result); + } + + /// Test extract_skip via programmatic tokens + #[test] + fn test_extract_skip_programmatic() { + let tokens = quote!(skip); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_skip(&[attr]); + assert!(result); + } + + /// Test that rename_all is not confused with rename + #[test] + fn test_rename_all_not_rename() { + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_field_rename(&[attr]); + assert_eq!(result, None); + } + + /// Test multiple items in serde attribute + #[test] + fn test_multiple_items_programmatic() { + let tokens = quote!(default, rename = "myField", skip_serializing_if = "is_none"); + let attr = create_attr_with_raw_tokens(tokens); + + let rename_result = extract_field_rename(std::slice::from_ref(&attr)); + let default_result = extract_default(std::slice::from_ref(&attr)); + let skip_if_result = extract_skip_serializing_if(std::slice::from_ref(&attr)); + + assert_eq!(rename_result.as_deref(), Some("myField")); + assert_eq!(default_result, Some(None)); + assert!(skip_if_result); + } + } + } + // Test Vec without inner type (edge case) #[test] fn test_parse_type_to_schema_ref_vec_without_args() { From d25fafa467d48a65bdad4252d5e121de17139d6a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 20:49:50 +0900 Subject: [PATCH 04/12] Add test --- .gitignore | 1 + crates/vespera_macro/src/openapi_generator.rs | 177 +++++++++++++++++ crates/vespera_macro/src/parser/operation.rs | 122 ++++++++++++ crates/vespera_macro/src/parser/parameters.rs | 182 ++++++++++++++++++ crates/vespera_macro/src/parser/response.rs | 97 ++++++++++ crates/vespera_macro/src/parser/schema.rs | 60 ++++++ 6 files changed, 639 insertions(+) diff --git a/.gitignore b/.gitignore index 873277d..19c0bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ target lcov.info coverage build_rs_cov.profraw +.sisyphus/ diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index c45a5a2..7bf2bc6 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -1154,4 +1154,181 @@ pub fn get_config() -> Config { let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); assert!(schemas.contains_key("Config")); } + + // ======== Tests for uncovered lines ======== + + #[test] + fn test_fallback_struct_finding_in_route_files() { + // Test line 65: fallback loop that finds struct in any route file when direct search fails + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create TWO route files - struct is in second file, route references it from first + let route1_content = r#" +pub fn get_users() -> Vec { + vec![] +} +"#; + let route1_file = create_temp_file(&temp_dir, "users.rs", route1_content); + + let route2_content = r#" +fn default_name() -> String { + "Guest".to_string() +} + +struct User { + #[serde(default = "default_name")] + name: String, +} + +pub fn get_user() -> User { + User { name: "Alice".to_string() } +} +"#; + let route2_file = create_temp_file(&temp_dir, "user.rs", route2_content); + + let mut metadata = CollectedMetadata::new(); + // Add struct but point to route1 (which doesn't contain the struct) + // This forces the fallback loop to search other route files + metadata.structs.push(StructMetadata { + name: "User".to_string(), + definition: r#"struct User { #[serde(default = "default_name")] name: String }"# + .to_string(), + }); + // Add BOTH routes - the first doesn't contain User struct, so fallback searches the second + 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: route1_file.to_string_lossy().to_string(), + signature: "fn get_users() -> Vec".to_string(), + error_status: None, + tags: None, + description: None, + }); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/user".to_string(), + function_name: "get_user".to_string(), + module_path: "test::user".to_string(), + file_path: route2_file.to_string_lossy().to_string(), + signature: "fn get_user() -> User".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + + // Struct should be found via fallback and processed + assert!(doc.components.as_ref().unwrap().schemas.is_some()); + let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("User")); + } + + #[test] + fn test_process_default_functions_with_no_properties() { + // Test line 152: early return when schema.properties is None + // This happens when a struct has no named fields (unit struct or tuple struct) + use vespera_core::schema::Schema; + + let struct_item: syn::ItemStruct = syn::parse_str("struct Empty;").unwrap(); + let file_ast: syn::File = syn::parse_str("fn foo() {}").unwrap(); + let mut schema = Schema::object(); + schema.properties = None; // Explicitly set to None + + // This should return early without panic + process_default_functions(&struct_item, &file_ast, &mut schema); + + // Schema should remain unchanged + assert!(schema.properties.is_none()); + } + + #[test] + fn test_extract_value_from_expr_int_parse_failure() { + // Test line 253: int parse failure (overflow) + // Create an integer literal that's too large to parse as i64 + // Use a literal that syn will parse but i64::parse will fail on + let expr: syn::Expr = syn::parse_str("999999999999999999999999999999").unwrap(); + let value = extract_value_from_expr(&expr); + assert!(value.is_none()); + } + + #[test] + fn test_extract_value_from_expr_float_parse_failure() { + // Test line 260: float parse failure + // Create a float literal that's too large/invalid + let expr: syn::Expr = syn::parse_str("1e999999").unwrap(); + let value = extract_value_from_expr(&expr); + // This may parse successfully to infinity or fail - either way should handle it + // The important thing is no panic + let _ = value; + } + + #[test] + fn test_extract_value_from_expr_method_call_with_nested_receiver() { + // Test lines 275-276: recursive extraction from method call receiver + // When receiver is not a direct string literal, it tries to extract recursively + // But the recursive call also won't find a Lit, so it returns None + // This test verifies the recursive path is exercised (line 275-276) + let expr: syn::Expr = syn::parse_str(r#"("hello").to_string()"#).unwrap(); + let value = extract_value_from_expr(&expr); + // The receiver is a Paren expression - recursive call is made but returns None + // because Paren is not handled in the match + assert!(value.is_none()); + } + + #[test] + fn test_extract_value_from_expr_method_call_with_non_literal_receiver() { + // Test lines 275-276: recursive extraction fails for non-literal + let expr: syn::Expr = syn::parse_str(r#"some_var.to_string()"#).unwrap(); + let value = extract_value_from_expr(&expr); + // Cannot extract value from a variable + assert!(value.is_none()); + } + + #[test] + fn test_extract_value_from_expr_method_call_chained_to_string() { + // Test lines 275-276: another case where recursive extraction is attempted + // Chained method calls: 42.to_string() has int literal as receiver + let expr: syn::Expr = syn::parse_str(r#"42.to_string()"#).unwrap(); + let value = extract_value_from_expr(&expr); + // Line 275 recursive call extracts 42 as Number, then line 276 returns it + assert_eq!(value, Some(serde_json::Value::Number(42.into()))); + } + + #[test] + fn test_get_type_default_empty_path_segments() { + // Test line 307: empty path segments returns None + // Create a type with empty path segments + + // Use parse to create a valid type, then we verify the normal path works + let ty: syn::Type = syn::parse_str("::String").unwrap(); + // This has segments, so it should work + let value = get_type_default(&ty); + // Global path ::String still has "String" as last segment + assert!(value.is_some()); + + // Test reference type (non-path type) + let ref_ty: syn::Type = syn::parse_str("&str").unwrap(); + let ref_value = get_type_default(&ref_ty); + // Reference is not a Path type, so returns None via line 310 + assert!(ref_value.is_none()); + } + + #[test] + fn test_get_type_default_tuple_type() { + // Test line 310: non-Path type returns None + let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); + let value = get_type_default(&ty); + assert!(value.is_none()); + } + + #[test] + fn test_get_type_default_array_type() { + // Test line 310: array type returns None + let ty: syn::Type = syn::parse_str("[i32; 3]").unwrap(); + let value = get_type_default(&ty); + assert!(value.is_none()); + } } diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 2b535be..697b8c4 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -526,4 +526,126 @@ mod tests { assert_body(&op, &expected_body); assert_responses(&op, &expected_resps); } + + // ======== Tests for uncovered lines ======== + + #[test] + fn test_single_path_param_with_single_type() { + // Test line 55: Path with single type (not tuple) and exactly ONE path param + // This exercises the branch: path_params.len() == 1 with non-tuple type + let op = build("fn get(Path(id): Path) -> String", "/users/{id}", None); + + // Should have exactly 1 path parameter with Integer type + let params = op.parameters.as_ref().expect("parameters expected"); + assert_eq!(params.len(), 1); + assert_eq!(params[0].name, "id"); + assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::Integer)); + } + + #[test] + fn test_single_path_param_with_string_type() { + // Another test for line 55: Path with single path param + let op = build( + "fn get(Path(id): Path) -> String", + "/users/{user_id}", + None, + ); + + let params = op.parameters.as_ref().expect("parameters expected"); + assert_eq!(params.len(), 1); + assert_eq!(params[0].name, "user_id"); + assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::String)); + } + + #[test] + fn test_non_path_extractor_with_query() { + // Test lines 85, 89: non-Path extractor handling + // When input is Query, it should NOT be treated as Path + let op = build( + "fn search(Query(params): Query) -> String", + "/search", + None, + ); + + // Query params should be extended to parameters (line 89) + // But QueryParams is not in known_schemas/struct_definitions so it won't appear + // The key is that it doesn't treat Query as a Path extractor (line 85 returns false) + assert!(op.request_body.is_none()); // Query is not a body + } + + #[test] + fn test_non_path_extractor_with_state() { + // Test lines 85, 89: State should be ignored (not Path) + let op = build( + "fn handler(State(state): State) -> String", + "/handler", + None, + ); + + // State is not a path extractor, and State params are typically ignored + // line 85 returns false, so line 89 extends parameters (but State is usually filtered out) + assert!(op.parameters.is_none() || op.parameters.as_ref().unwrap().is_empty()); + } + + #[test] + fn test_string_body_fallback() { + // Test lines 100-107: String as last arg becomes text/plain body + let op = build("fn upload(content: String) -> String", "/upload", None); + + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); + let media = body.content.get("text/plain").unwrap(); + match media.schema.as_ref().unwrap() { + SchemaRef::Inline(schema) => { + assert_eq!(schema.schema_type, Some(SchemaType::String)); + } + _ => panic!("expected inline schema"), + } + } + + #[test] + fn test_str_ref_body_fallback() { + // Test lines 100-106: &str as last arg becomes text/plain body + let op = build("fn upload(content: &str) -> String", "/upload", None); + + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); + } + + #[test] + fn test_type_reference_with_string() { + // Test lines 100-102, 104: Type::Reference branch - &String + let op = build("fn upload(content: &String) -> String", "/upload", None); + + // &String reference should be detected as string type + // Line 101-102 checks if Type::Reference elem is a Path with String/str + let body = op.request_body.as_ref().expect("request body expected"); + assert!(body.content.contains_key("text/plain")); + } + + #[test] + fn test_non_string_last_arg_not_body() { + // Test line 107: last arg that's NOT String/&str should NOT become body + let op = build("fn process(count: i32) -> String", "/process", None); + + // i32 is not String/&str, so line 107 returns false, no body created + // However, bare i32 without extractor is also ignored + assert!(op.request_body.is_none()); + } + + #[test] + fn test_multiple_path_params_with_single_type() { + // Test line 57-60: multiple path params but single type - uses type for all + let op = build( + "fn get(Path(id): Path) -> String", + "/shops/{shop_id}/items/{item_id}", + None, + ); + + // Both params should use String type + let params = op.parameters.as_ref().expect("parameters expected"); + assert_eq!(params.len(), 2); + assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::String)); + assert_eq!(param_schema_type(¶ms[1]), Some(SchemaType::String)); + } } diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 9e43919..a25bbe7 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -842,4 +842,186 @@ mod tests { assert_eq!(params[0].required, Some(true)); assert_eq!(params[1].required, Some(false)); } + + // ======== Tests for uncovered lines ======== + + #[test] + fn test_query_single_non_struct_known_type() { + // Test line 128: Return single Query parameter where T is a known non-primitive type + // This should return a single parameter when Query wraps a known type that's not primitive-like + let mut known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + // Add a known type that's not a struct + known_schemas.insert( + "CustomId".to_string(), + "#/components/schemas/CustomId".to_string(), + ); + + let func: syn::ItemFn = syn::parse_str("fn test(id: Query) {}").unwrap(); + let path_params: Vec = vec![]; + + for arg in func.sig.inputs.iter() { + let result = + parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); + // Line 128 returns Some(vec![Parameter...]) for single Query parameter + assert!(result.is_some(), "Expected single Query parameter"); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0].r#in, ParameterLocation::Query); + } + } + + #[test] + fn test_path_param_by_name_match() { + // Test line 159: path param matched by name (non-extractor case) + // When a parameter name matches a path param name directly without Path extractor + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + let func: syn::ItemFn = syn::parse_str("fn test(user_id: i32) {}").unwrap(); + let path_params = vec!["user_id".to_string()]; + + for arg in func.sig.inputs.iter() { + let result = + parse_function_parameter(arg, &path_params, &known_schemas, &struct_definitions); + // Line 159: path_params.contains(¶m_name) returns true, so it creates a Path parameter + assert!(result.is_some(), "Expected path parameter by name match"); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + assert_eq!(params[0].r#in, ParameterLocation::Path); + assert_eq!(params[0].name, "user_id"); + } + } + + #[test] + fn test_is_map_type_false_for_non_path() { + // Test line 177: is_map_type returns false for non-Path type + let ty: Type = syn::parse_str("&str").unwrap(); + assert!(!is_map_type(&ty)); // Line 177: returns false for non-Path type + + let ty: Type = syn::parse_str("(i32, String)").unwrap(); + assert!(!is_map_type(&ty)); // Tuple is also not a Path type + } + + #[test] + fn test_is_known_type_empty_segments() { + // Test line 209: empty path segments returns false + // Note: It's hard to create a Type::Path with empty segments via parse, + // but we can test the normal path behavior and document the edge case + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + // Non-path type returns false (line 209 is for empty segments which is rare) + let ty: Type = syn::parse_str("&str").unwrap(); + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + } + + #[test] + fn test_is_known_type_non_vec_option_generic() { + // Test line 230: non-Vec/Option generic type (like Result or Box) + // The match at line 224-229 only handles Vec and Option + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + // Box has angle brackets but is not Vec or Option + let ty: Type = syn::parse_str("Box").unwrap(); + // Line 230: the default case `_ => {}` is hit, returns false + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + + // Result also not handled + let ty: Type = syn::parse_str("Result").unwrap(); + assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); + } + + #[test] + fn test_parse_query_struct_empty_path_segments() { + // Test line 245: empty path segments in parse_query_struct_to_parameters + // This is the early return when path.segments.is_empty() + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + + // A reference type (not a path type) should return None + let ty: Type = syn::parse_str("&QueryParams").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + // Line 242 matches Type::Path, but &QueryParams is Type::Reference + assert!(result.is_none()); + } + + #[test] + fn test_schema_ref_to_inline_conversion_optional() { + // Test line 313: SchemaRef::Ref converted to inline for Optional fields + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashMap::new(); + + // Struct with Option - the field references a known schema + struct_definitions.insert( + "QueryWithOptionalRef".to_string(), + r#" + pub struct QueryWithOptionalRef { + pub item: Option, + } + "# + .to_string(), + ); + + // RefType is a known schema (will be SchemaRef::Ref) but NOT in struct_definitions + // So it stays as Ref, triggering line 313 (if still Ref after Option handling) + known_schemas.insert( + "RefType".to_string(), + "#/components/schemas/RefType".to_string(), + ); + + let ty: Type = syn::parse_str("QueryWithOptionalRef").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + + assert!(result.is_some()); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + // The schema should be inline (converted from Ref via line 313) + match ¶ms[0].schema { + Some(SchemaRef::Inline(schema)) => { + assert_eq!(schema.nullable, Some(true)); + } + _ => panic!("Expected inline schema with nullable"), + } + } + + #[test] + fn test_schema_ref_to_inline_conversion_required() { + // Test line 318: SchemaRef::Ref converted to inline for required fields + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashMap::new(); + + // Struct with required RefType field + struct_definitions.insert( + "QueryWithRef".to_string(), + r#" + pub struct QueryWithRef { + pub item: RefType, + } + "# + .to_string(), + ); + + // RefType is a known schema (will be SchemaRef::Ref) + known_schemas.insert( + "RefType".to_string(), + "#/components/schemas/RefType".to_string(), + ); + + let ty: Type = syn::parse_str("QueryWithRef").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + + assert!(result.is_some()); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + // Line 318: Ref is converted to inline object + match ¶ms[0].schema { + Some(SchemaRef::Inline(schema)) => { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + } + _ => panic!("Expected inline schema"), + } + } } diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 92ed3b1..7ccc9b2 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -438,4 +438,101 @@ mod tests { } } } + + // ======== Tests for uncovered lines ======== + + #[test] + fn test_extract_result_types_non_path_non_ref() { + // Test line 43: type that's neither Path nor Reference returns None + // Tuple type is neither Path nor Reference + let ty: syn::Type = syn::parse_str("(i32, String)").unwrap(); + let result = extract_result_types(&ty); + assert!(result.is_none()); + + // Array type + let ty: syn::Type = syn::parse_str("[i32; 3]").unwrap(); + let result = extract_result_types(&ty); + assert!(result.is_none()); + + // Slice type + let ty: syn::Type = syn::parse_str("[i32]").unwrap(); + let result = extract_result_types(&ty); + assert!(result.is_none()); + } + + #[test] + fn test_extract_result_types_ref_to_non_path() { + // Test line 43: &(Tuple) - Reference to non-Path type + // This hits the else branch at line 42-43 + let ty: syn::Type = syn::parse_str("&(i32, String)").unwrap(); + let result = extract_result_types(&ty); + // The Reference's elem is a Tuple, not a Path, so line 39 condition fails + // Falls through to line 43 + assert!(result.is_none()); + } + + #[test] + fn test_extract_result_types_empty_path_segments() { + // Test line 48: path.segments.is_empty() returns None + // Note: It's very hard to create a valid Type::Path with empty segments via parse + // because syn won't parse it. But we can test the behavior indirectly. + + // A global path like "::Result" still has segments + // Test with a simple non-Result path to exercise the code path + let ty: syn::Type = syn::parse_str("String").unwrap(); + let result = extract_result_types(&ty); + // String is not Result, so is_keyword_type_by_type_path fails at line 51 + assert!(result.is_none()); + } + + #[test] + fn test_extract_result_types_with_reference() { + // Test the Reference path (line 38-41) that succeeds + // &Result should still extract types + let ty: syn::Type = syn::parse_str("&Result").unwrap(); + let _result = extract_result_types(&ty); + // Note: This doesn't actually work because is_keyword_type_by_type_path + // checks for Result type, but ref to Result is different + // The important thing is the code doesn't panic + // This exercises lines 38-41 even if result is None + } + + #[test] + fn test_unwrap_json_non_json() { + // Test unwrap_json with non-Json type returns original + let ty: syn::Type = syn::parse_str("String").unwrap(); + let unwrapped = unwrap_json(&ty); + // Should return the same type + assert!(matches!(unwrapped, syn::Type::Path(_))); + } + + #[test] + fn test_unwrap_json_with_json() { + // Test unwrap_json with Json + let ty: syn::Type = syn::parse_str("Json").unwrap(); + let unwrapped = unwrap_json(&ty); + // Should unwrap to String + if let syn::Type::Path(type_path) = unwrapped { + assert_eq!( + type_path.path.segments.last().unwrap().ident.to_string(), + "String" + ); + } else { + panic!("Expected Path type"); + } + } + + #[test] + fn test_parse_return_type_tuple() { + // Test parse_return_type with tuple type (exercises line 43 via extract_result_types) + let known_schemas = HashMap::new(); + let struct_definitions = HashMap::new(); + let return_type = parse_return_type_str("-> (i32, String)"); + + let responses = parse_return_type(&return_type, &known_schemas, &struct_definitions); + + // Tuple is not a Result, so it should be treated as regular response + assert!(responses.contains_key("200")); + assert_eq!(responses.len(), 1); + } } diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 69291e1..3983bde 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -2293,6 +2293,66 @@ mod tests { assert_eq!(default_result, Some(None)); assert!(skip_if_result); } + + /// Test extract_rename_all fallback parsing (lines 44-47) + /// This tests the manual token parsing when parse_nested_meta doesn't find rename_all + /// in its expected format but the token string contains it + #[test] + fn test_extract_rename_all_fallback_manual_parsing() { + // Create tokens that parse_nested_meta won't recognize properly + // but the manual token parsing at lines 38-47 should catch + let tokens = quote!(rename_all = "kebab-case"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // Lines 44-47: extract the value from quoted string + assert_eq!(result.as_deref(), Some("kebab-case")); + } + + /// Test extract_rename_all with complex attribute that forces fallback + #[test] + fn test_extract_rename_all_complex_attribute_fallback() { + // When combined with other attrs, this might trigger fallback path + let tokens = quote!(default, rename_all = "SCREAMING_SNAKE_CASE", skip); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // Should still find rename_all via either path + assert_eq!(result.as_deref(), Some("SCREAMING_SNAKE_CASE")); + } + + /// Test extract_rename_all when value is not a string literal (line 43 check fails) + #[test] + fn test_extract_rename_all_no_quote_start() { + // If there's no opening quote, line 43 returns false + let tokens = quote!(rename_all = snake_case); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // No quote found at line 43, so None + assert!(result.is_none()); + } + + /// Test extract_rename_all with unclosed quote (line 45 check fails) + #[test] + fn test_extract_rename_all_unclosed_quote() { + // This tests when quote_start is found but quote_end is not + // This is hard to create via quote! macro, so we test the edge case differently + // The important thing is the code doesn't panic + let tokens = quote!(rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + // Should work with proper quotes + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with empty string value + #[test] + fn test_extract_rename_all_empty_string() { + // Tests when there's a valid quote pair but empty content (line 46-47) + let tokens = quote!(rename_all = ""); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // Empty string between quotes + assert_eq!(result.as_deref(), Some("")); + } } } From 226dddb50bf40230a370ed20d0a982f1f07e1d2d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 20:58:07 +0900 Subject: [PATCH 05/12] Add test --- crates/vespera_macro/src/parser/operation.rs | 68 +++++++++++++++ crates/vespera_macro/src/parser/parameters.rs | 87 +++++++++++++++---- 2 files changed, 138 insertions(+), 17 deletions(-) diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index 697b8c4..bc473ba 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -648,4 +648,72 @@ mod tests { assert_eq!(param_schema_type(¶ms[0]), Some(SchemaType::String)); assert_eq!(param_schema_type(¶ms[1]), Some(SchemaType::String)); } + + #[test] + fn test_reference_to_non_path_type_not_body() { + // Test line 104: &(tuple) reference where elem is NOT a Path type + // This hits the else branch at line 104 returning false + let op = build("fn process(data: &(i32, i32)) -> String", "/process", None); + // Reference to tuple is not String/&str, so no body created + assert!(op.request_body.is_none()); + } + + #[test] + fn test_reference_to_slice_not_body() { + // Test line 104: &[T] reference where elem is NOT a simple Path type + let op = build("fn process(data: &[u8]) -> String", "/process", None); + // Reference to slice is not String/&str + assert!(op.request_body.is_none()); + } + + #[test] + fn test_tuple_type_not_body() { + // Test line 107: tuple type (not Path, not Reference) returns false + let op = build( + "fn process(data: (i32, String)) -> String", + "/process", + None, + ); + // Tuple is neither Path nor Reference, hits line 107 + assert!(op.request_body.is_none()); + } + + #[test] + fn test_array_type_not_body() { + // Test line 107: array type (not Path, not Reference) returns false + let op = build("fn process(data: [u8; 4]) -> String", "/process", None); + // Array is neither Path nor Reference + assert!(op.request_body.is_none()); + } + + #[test] + fn test_non_path_extractor_generates_params_and_extends() { + // Test lines 85, 89: non-Path extractor that DOES generate params + // Query where T is a known struct generates query parameters + let sig: syn::Signature = syn::parse_str("fn search(Query(params): Query, TypedHeader(auth): TypedHeader) -> String").unwrap(); + + let mut struct_definitions = HashMap::new(); + struct_definitions.insert( + "SearchParams".to_string(), + "pub struct SearchParams { pub q: String }".to_string(), + ); + + let op = build_operation_from_function( + &sig, + "/search", + &HashMap::new(), + &struct_definitions, + None, + None, + ); + + // Query is not Path (line 85 returns false) + // parse_function_parameter returns Some for Query + // Line 89: parameters.extend(params) + // TypedHeader also generates a header parameter + assert!(op.parameters.is_some()); + let params = op.parameters.unwrap(); + // Should have query param(s) and header param + assert!(!params.is_empty()); + } } diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index a25bbe7..5ad640c 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -951,34 +951,35 @@ mod tests { #[test] fn test_schema_ref_to_inline_conversion_optional() { // Test line 313: SchemaRef::Ref converted to inline for Optional fields + // This requires a field that: + // 1. Is Option where T is a known schema + // 2. T is NOT in struct_definitions (so ref stays as Ref) + // 3. field_schema is still Ref after the conversion attempt + // + // Note: parse_type_to_schema_ref_with_schemas for Option may create + // an inline schema wrapping the inner ref, not a direct Ref. + // Line 313 is a defensive case that may be hard to hit in practice. let mut struct_definitions = HashMap::new(); - let mut known_schemas = HashMap::new(); + let known_schemas = HashMap::new(); - // Struct with Option - the field references a known schema + // Use a simple struct with Option to verify the optional handling works struct_definitions.insert( - "QueryWithOptionalRef".to_string(), + "QueryWithOptional".to_string(), r#" - pub struct QueryWithOptionalRef { - pub item: Option, + pub struct QueryWithOptional { + pub count: Option, } "# .to_string(), ); - // RefType is a known schema (will be SchemaRef::Ref) but NOT in struct_definitions - // So it stays as Ref, triggering line 313 (if still Ref after Option handling) - known_schemas.insert( - "RefType".to_string(), - "#/components/schemas/RefType".to_string(), - ); - - let ty: Type = syn::parse_str("QueryWithOptionalRef").unwrap(); + let ty: Type = syn::parse_str("QueryWithOptional").unwrap(); let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); assert!(result.is_some()); let params = result.unwrap(); assert_eq!(params.len(), 1); - // The schema should be inline (converted from Ref via line 313) + assert_eq!(params[0].required, Some(false)); match ¶ms[0].schema { Some(SchemaRef::Inline(schema)) => { assert_eq!(schema.nullable, Some(true)); @@ -990,6 +991,10 @@ mod tests { #[test] fn test_schema_ref_to_inline_conversion_required() { // Test line 318: SchemaRef::Ref converted to inline for required fields + // This requires a field where: + // 1. field_schema is SchemaRef::Ref + // 2. is_optional is false + // 3. The ref conversion at lines 294-304 fails (no struct_def) let mut struct_definitions = HashMap::new(); let mut known_schemas = HashMap::new(); @@ -1004,7 +1009,8 @@ mod tests { .to_string(), ); - // RefType is a known schema (will be SchemaRef::Ref) + // RefType is a known schema (will generate SchemaRef::Ref) + // BUT we don't have its struct definition, so the conversion at 296-303 fails known_schemas.insert( "RefType".to_string(), "#/components/schemas/RefType".to_string(), @@ -1016,12 +1022,59 @@ mod tests { assert!(result.is_some()); let params = result.unwrap(); assert_eq!(params.len(), 1); - // Line 318: Ref is converted to inline object + // Line 318: Ref that couldn't be converted is turned into inline object match ¶ms[0].schema { Some(SchemaRef::Inline(schema)) => { assert_eq!(schema.schema_type, Some(SchemaType::Object)); } - _ => panic!("Expected inline schema"), + _ => panic!("Expected inline schema (converted from Ref)"), + } + } + + #[test] + fn test_schema_ref_converted_to_inline_with_struct_def() { + // Test lines 294-304: Ref IS converted when struct_def exists + let mut struct_definitions = HashMap::new(); + let mut known_schemas = HashMap::new(); + + // Main struct with a field of type NestedType + struct_definitions.insert( + "QueryWithNested".to_string(), + r#" + pub struct QueryWithNested { + pub nested: NestedType, + } + "# + .to_string(), + ); + + // NestedType is both in known_schemas AND has a struct definition + known_schemas.insert( + "NestedType".to_string(), + "#/components/schemas/NestedType".to_string(), + ); + struct_definitions.insert( + "NestedType".to_string(), + r#" + pub struct NestedType { + pub value: i32, + } + "# + .to_string(), + ); + + let ty: Type = syn::parse_str("QueryWithNested").unwrap(); + let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); + + assert!(result.is_some()); + let params = result.unwrap(); + assert_eq!(params.len(), 1); + // Lines 294-304: Ref is converted to inline by parsing the nested struct + match ¶ms[0].schema { + Some(SchemaRef::Inline(_)) => { + // Successfully converted + } + _ => panic!("Expected inline schema (converted from Ref via struct_def)"), } } } From 9d50df99a41fa8d814b04e9c7c9259e462baf1a1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 21:28:38 +0900 Subject: [PATCH 06/12] Add test and upgrade --- Cargo.lock | 98 +++++++++---------- crates/vespera_macro/Cargo.toml | 2 +- crates/vespera_macro/src/parser/parameters.rs | 58 +++++++++-- crates/vespera_macro/src/parser/response.rs | 54 ++++++++-- crates/vespera_macro/src/parser/schema.rs | 89 +++++++++++++++++ examples/axum-example/Cargo.toml | 4 +- 6 files changed, 237 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca50570..b74e392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,9 +133,9 @@ dependencies = [ [[package]] name = "axum-test" -version = "18.5.0" +version = "18.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf48df8b4be768081e11b7bb6d50e7dd96a3616b0b728f9e8d49bfbd8116f3c6" +checksum = "0ce2a8627e8d8851f894696b39f2b67807d6375c177361d376173ace306a21e2" dependencies = [ "anyhow", "axum", @@ -201,9 +201,9 @@ checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" [[package]] name = "cc" -version = "1.2.51" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "shlex", @@ -217,9 +217,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -365,9 +365,9 @@ dependencies = [ [[package]] name = "expect-json" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf3355a7ef83e52c9383ab0c7719acd1da54be5fed7c6572d87ddc4d8589753" +checksum = "5325e3924286c2263a3f01ddd09ddae9ded098fffffe4182dad3b140243119f3" dependencies = [ "chrono", "email_address", @@ -383,9 +383,9 @@ dependencies = [ [[package]] name = "expect-json-macros" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ff9262e5b5f9760f60c57ada4fffd25201ae9fefd426f29f097dcc573d86e6" +checksum = "f464e1e518bc97a6749590758411784df7dda4f36384e1fb11a58f040c1d0459" dependencies = [ "proc-macro2", "quote", @@ -400,9 +400,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "form_urlencoded" @@ -757,9 +757,9 @@ dependencies = [ [[package]] name = "insta" -version = "1.45.1" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "983e3b24350c84ab8a65151f537d67afbbf7153bb9f1110e03e9fa9b07f67a5c" +checksum = "248b42847813a1550dafd15296fd9748c651d0c32194559dbc05d804d54b21e8" dependencies = [ "console", "once_cell", @@ -794,9 +794,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.178" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "linux-raw-sys" @@ -1042,18 +1042,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1086,9 +1086,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom", ] @@ -1281,9 +1281,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -1384,9 +1384,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "syn" -version = "2.0.112" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -1425,18 +1425,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1445,30 +1445,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -1486,9 +1486,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -1544,9 +1544,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -1640,9 +1640,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2043,18 +2043,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", @@ -2117,6 +2117,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.3" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9747e91771f56fd7893e1164abd78febd14a670ceec257caad15e051de35f06" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/crates/vespera_macro/Cargo.toml b/crates/vespera_macro/Cargo.toml index 21a80e8..fd64437 100644 --- a/crates/vespera_macro/Cargo.toml +++ b/crates/vespera_macro/Cargo.toml @@ -21,5 +21,5 @@ anyhow = "1.0" [dev-dependencies] rstest = "0.26" -insta = "1.45" +insta = "1.46" tempfile = "3" diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index 5ad640c..bb12e6e 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -907,13 +907,23 @@ mod tests { #[test] fn test_is_known_type_empty_segments() { // Test line 209: empty path segments returns false - // Note: It's hard to create a Type::Path with empty segments via parse, - // but we can test the normal path behavior and document the edge case + // Create a Type::Path programmatically with empty segments + use syn::punctuated::Punctuated; + let known_schemas = HashMap::new(); let struct_definitions = HashMap::new(); - // Non-path type returns false (line 209 is for empty segments which is rare) - let ty: Type = syn::parse_str("&str").unwrap(); + // Create Type::Path with empty segments + let type_path = syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), // Empty segments! + }, + }; + let ty = Type::Path(type_path); + + // This MUST hit line 209 because path.segments.is_empty() is true assert!(!is_known_type(&ty, &known_schemas, &struct_definitions)); } @@ -937,15 +947,28 @@ mod tests { #[test] fn test_parse_query_struct_empty_path_segments() { // Test line 245: empty path segments in parse_query_struct_to_parameters - // This is the early return when path.segments.is_empty() + // Create a Type::Path programmatically with empty segments + use syn::punctuated::Punctuated; + let known_schemas = HashMap::new(); let struct_definitions = HashMap::new(); - // A reference type (not a path type) should return None - let ty: Type = syn::parse_str("&QueryParams").unwrap(); + // Create Type::Path with empty segments + let type_path = syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), // Empty segments! + }, + }; + let ty = Type::Path(type_path); + + // This MUST hit line 245 because path.segments.is_empty() is true let result = parse_query_struct_to_parameters(&ty, &known_schemas, &struct_definitions); - // Line 242 matches Type::Path, but &QueryParams is Type::Reference - assert!(result.is_none()); + assert!( + result.is_none(), + "Empty path segments should return None (line 245)" + ); } #[test] @@ -988,6 +1011,23 @@ mod tests { } } + // NOTE: Line 313 is defensive/unreachable code. + // + // Analysis: Line 313 requires: + // 1. is_optional == true (field type starts with "Option") + // 2. field_schema is still SchemaRef::Ref after the conversion at lines 294-304 + // + // However, parse_type_to_schema_ref_with_schemas("Option") ALWAYS returns + // SchemaRef::Inline because: + // - schema.rs lines 650-668 handle Option specially + // - If inner type is Inline → returns Inline (line 660) + // - If inner type is Ref → wraps in Inline (line 664) + // + // Therefore, for any field whose type starts with "Option", field_schema at line 290 + // will always be SchemaRef::Inline, making the else branch at line 312-313 unreachable. + // + // This is defensive code guarding against future changes to Option handling in schema.rs. + #[test] fn test_schema_ref_to_inline_conversion_required() { // Test line 318: SchemaRef::Ref converted to inline for required fields diff --git a/crates/vespera_macro/src/parser/response.rs b/crates/vespera_macro/src/parser/response.rs index 7ccc9b2..848138c 100644 --- a/crates/vespera_macro/src/parser/response.rs +++ b/crates/vespera_macro/src/parser/response.rs @@ -474,15 +474,55 @@ mod tests { #[test] fn test_extract_result_types_empty_path_segments() { // Test line 48: path.segments.is_empty() returns None - // Note: It's very hard to create a valid Type::Path with empty segments via parse - // because syn won't parse it. But we can test the behavior indirectly. + // Create a Type::Path programmatically with empty segments + use syn::punctuated::Punctuated; + + let type_path = syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), // Empty segments! + }, + }; + let ty = syn::Type::Path(type_path); + + // This MUST hit line 48 because path.segments.is_empty() is true + let result = extract_result_types(&ty); + assert!( + result.is_none(), + "Empty path segments should return None (line 48)" + ); + } - // A global path like "::Result" still has segments - // Test with a simple non-Result path to exercise the code path - let ty: syn::Type = syn::parse_str("String").unwrap(); + #[test] + fn test_extract_result_types_empty_path_via_reference() { + // Test line 48 via reference path: &Type::Path with empty segments + use syn::punctuated::Punctuated; + + // Create inner Type::Path with empty segments + let inner_type_path = syn::TypePath { + qself: None, + path: syn::Path { + leading_colon: None, + segments: Punctuated::new(), + }, + }; + let inner_ty = syn::Type::Path(inner_type_path); + + // Wrap in a reference + let ty = syn::Type::Reference(syn::TypeReference { + and_token: syn::token::And::default(), + lifetime: None, + mutability: None, + elem: Box::new(inner_ty), + }); + + // This goes through line 38-41 (reference to path), then hits line 48 let result = extract_result_types(&ty); - // String is not Result, so is_keyword_type_by_type_path fails at line 51 - assert!(result.is_none()); + assert!( + result.is_none(), + "Empty path segments via reference should return None (line 48)" + ); } #[test] diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 3983bde..536dd61 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -2353,6 +2353,95 @@ mod tests { // Empty string between quotes assert_eq!(result.as_deref(), Some("")); } + + /// Test extract_rename_all with QUALIFIED PATH to force fallback (CRITICAL for lines 44-47) + /// parse_nested_meta checks meta.path.is_ident("rename_all") which returns false for qualified paths + /// But manual token parsing finds "rename_all" in the string + #[test] + fn test_extract_rename_all_qualified_path_forces_fallback() { + // Create tokens with a qualified path: serde_with::rename_all = "camelCase" + // parse_nested_meta sees path with segments ["serde_with", "rename_all"] + // is_ident("rename_all") returns false because path has multiple segments + // Manual token parsing finds "rename_all" in the string and extracts value + let tokens = quote!(serde_with::rename_all = "camelCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // This MUST hit lines 44-47 (fallback path) + assert_eq!(result.as_deref(), Some("camelCase")); + } + + /// Test extract_rename_all with another qualified path variation + #[test] + fn test_extract_rename_all_module_qualified_forces_fallback() { + // Another variation with qualified path + let tokens = quote!(my_module::rename_all = "snake_case"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + // Fallback path extracts the value + assert_eq!(result.as_deref(), Some("snake_case")); + } + + /// Test extract_rename_all with deeply qualified path + #[test] + fn test_extract_rename_all_deeply_qualified_forces_fallback() { + // Deeply qualified path: a::b::rename_all = "PascalCase" + let tokens = quote!(a::b::rename_all = "PascalCase"); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("PascalCase")); + } + + /// CRITICAL TEST: This test MUST hit lines 44-47 by using raw token manipulation + /// We create a TokenStream where parse_nested_meta cannot find rename_all + /// but the manual token string search DOES find it + #[test] + fn test_extract_rename_all_raw_tokens_force_fallback() { + // Create raw tokens that look like: __rename_all_prefix::rename_all = "lowercase" + // parse_nested_meta will see path "__rename_all_prefix::rename_all" + // is_ident("rename_all") returns false (qualified path) + // Manual parsing finds "rename_all" and extracts "lowercase" + let tokens: TokenStream = "__rename_all_prefix::rename_all = \"lowercase\"" + .parse() + .unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + + // Verify the token string contains what we expect + if let syn::Meta::List(list) = &attr.meta { + let token_str = list.tokens.to_string(); + assert!( + token_str.contains("rename_all"), + "Token string should contain rename_all: {}", + token_str + ); + } + + let result = extract_rename_all(&[attr]); + // This MUST succeed via fallback path (lines 44-47) + assert_eq!( + result.as_deref(), + Some("lowercase"), + "Fallback parsing must extract the value" + ); + } + + /// Another critical test with different qualified path format + #[test] + fn test_extract_rename_all_crate_qualified_forces_fallback() { + // Use crate:: prefix which is definitely a qualified path + let tokens: TokenStream = "crate::rename_all = \"UPPERCASE\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("UPPERCASE")); + } + + /// Test with self:: prefix + #[test] + fn test_extract_rename_all_self_qualified_forces_fallback() { + let tokens: TokenStream = "self::rename_all = \"kebab-case\"".parse().unwrap(); + let attr = create_attr_with_raw_tokens(tokens); + let result = extract_rename_all(&[attr]); + assert_eq!(result.as_deref(), Some("kebab-case")); + } } } diff --git a/examples/axum-example/Cargo.toml b/examples/axum-example/Cargo.toml index f5144e8..bee54bc 100644 --- a/examples/axum-example/Cargo.toml +++ b/examples/axum-example/Cargo.toml @@ -14,6 +14,6 @@ serde_json = "1" vespera = { path = "../../crates/vespera" } [dev-dependencies] -axum-test = "18.5" -insta = "1.45" +axum-test = "18.7" +insta = "1.46" From 0129a72614210595c90b27fa205af351c81f8e3c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 21:43:26 +0900 Subject: [PATCH 07/12] Add testcase --- crates/vespera_macro/src/parser/parameters.rs | 144 +++++++++++------- 1 file changed, 90 insertions(+), 54 deletions(-) diff --git a/crates/vespera_macro/src/parser/parameters.rs b/crates/vespera_macro/src/parser/parameters.rs index bb12e6e..1dd1702 100644 --- a/crates/vespera_macro/src/parser/parameters.rs +++ b/crates/vespera_macro/src/parser/parameters.rs @@ -11,6 +11,27 @@ use super::schema::{ parse_type_to_schema_ref_with_schemas, rename_field, }; +/// Convert SchemaRef to inline schema for query parameters +/// Query parameters should always use inline schemas, not refs +/// Adds nullable flag if the field is optional +fn convert_to_inline_schema(field_schema: SchemaRef, is_optional: bool) -> SchemaRef { + match field_schema { + SchemaRef::Inline(mut schema) => { + if is_optional { + schema.nullable = Some(true); + } + SchemaRef::Inline(schema) + } + SchemaRef::Ref(_) => { + let mut schema = Schema::new(SchemaType::Object); + if is_optional { + schema.nullable = Some(true); + } + SchemaRef::Inline(Box::new(schema)) + } + } +} + /// Analyze function parameter and convert to OpenAPI Parameter(s) /// Returns None if parameter should be ignored (e.g., Query>) /// Returns Some(Vec) with one or more parameters @@ -393,46 +414,23 @@ fn parse_query_struct_to_parameters( // Convert ref to inline if needed (Query parameters should not use refs) // If it's a ref to a known struct, get the struct definition and inline it - if let SchemaRef::Ref(ref_ref) = &field_schema { - // Try to extract type name from ref path (e.g., "#/components/schemas/User" -> "User") - if let Some(type_name) = + if let SchemaRef::Ref(ref_ref) = &field_schema + && let Some(type_name) = ref_ref.ref_path.strip_prefix("#/components/schemas/") - && let Some(struct_def) = struct_definitions.get(type_name) - && let Ok(nested_struct_item) = - syn::parse_str::(struct_def) - { - // Parse the nested struct to schema (inline) - let nested_schema = parse_struct_to_schema( - &nested_struct_item, - known_schemas, - struct_definitions, - ); - field_schema = SchemaRef::Inline(Box::new(nested_schema)); - } + && let Some(struct_def) = struct_definitions.get(type_name) + && let Ok(nested_struct_item) = + syn::parse_str::(struct_def) + { + // Parse the nested struct to schema (inline) + let nested_schema = parse_struct_to_schema( + &nested_struct_item, + known_schemas, + struct_definitions, + ); + field_schema = SchemaRef::Inline(Box::new(nested_schema)); } - // If it's Option, make it nullable - let final_schema = if is_optional { - if let SchemaRef::Inline(mut schema) = field_schema { - schema.nullable = Some(true); - SchemaRef::Inline(schema) - } else { - // If still a ref, convert to inline object with nullable - SchemaRef::Inline(Box::new(Schema { - schema_type: Some(SchemaType::Object), - nullable: Some(true), - ..Schema::object() - })) - } - } else { - // If it's still a ref, convert to inline object - match field_schema { - SchemaRef::Ref(_) => { - SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))) - } - SchemaRef::Inline(schema) => SchemaRef::Inline(schema), - } - }; + let final_schema = convert_to_inline_schema(field_schema, is_optional); let required = !is_optional; @@ -1011,23 +1009,6 @@ mod tests { } } - // NOTE: Line 313 is defensive/unreachable code. - // - // Analysis: Line 313 requires: - // 1. is_optional == true (field type starts with "Option") - // 2. field_schema is still SchemaRef::Ref after the conversion at lines 294-304 - // - // However, parse_type_to_schema_ref_with_schemas("Option") ALWAYS returns - // SchemaRef::Inline because: - // - schema.rs lines 650-668 handle Option specially - // - If inner type is Inline → returns Inline (line 660) - // - If inner type is Ref → wraps in Inline (line 664) - // - // Therefore, for any field whose type starts with "Option", field_schema at line 290 - // will always be SchemaRef::Inline, making the else branch at line 312-313 unreachable. - // - // This is defensive code guarding against future changes to Option handling in schema.rs. - #[test] fn test_schema_ref_to_inline_conversion_required() { // Test line 318: SchemaRef::Ref converted to inline for required fields @@ -1117,4 +1098,59 @@ mod tests { _ => panic!("Expected inline schema (converted from Ref via struct_def)"), } } + + // Tests for convert_to_inline_schema helper function + #[test] + fn test_convert_to_inline_schema_inline_required() { + let schema = SchemaRef::Inline(Box::new(Schema::string())); + let result = convert_to_inline_schema(schema, false); + match result { + SchemaRef::Inline(s) => { + assert_eq!(s.schema_type, Some(SchemaType::String)); + assert!(s.nullable.is_none()); + } + _ => panic!("Expected Inline"), + } + } + + #[test] + fn test_convert_to_inline_schema_inline_optional() { + let schema = SchemaRef::Inline(Box::new(Schema::string())); + let result = convert_to_inline_schema(schema, true); + match result { + SchemaRef::Inline(s) => { + assert_eq!(s.schema_type, Some(SchemaType::String)); + assert_eq!(s.nullable, Some(true)); + } + _ => panic!("Expected Inline"), + } + } + + #[test] + fn test_convert_to_inline_schema_ref_required() { + use vespera_core::schema::Reference; + let schema = SchemaRef::Ref(Reference::schema("SomeType")); + let result = convert_to_inline_schema(schema, false); + match result { + SchemaRef::Inline(s) => { + assert_eq!(s.schema_type, Some(SchemaType::Object)); + assert!(s.nullable.is_none()); + } + _ => panic!("Expected Inline"), + } + } + + #[test] + fn test_convert_to_inline_schema_ref_optional() { + use vespera_core::schema::Reference; + let schema = SchemaRef::Ref(Reference::schema("SomeType")); + let result = convert_to_inline_schema(schema, true); + match result { + SchemaRef::Inline(s) => { + assert_eq!(s.schema_type, Some(SchemaType::Object)); + assert_eq!(s.nullable, Some(true)); + } + _ => panic!("Expected Inline"), + } + } } From 44ad6f096007ed9acec0d8e559986c6d4931802a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 21:56:13 +0900 Subject: [PATCH 08/12] Add test case --- crates/vespera_macro/src/parser/operation.rs | 82 ++++---------------- 1 file changed, 13 insertions(+), 69 deletions(-) diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index bc473ba..f8bcc74 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -1,10 +1,7 @@ use std::collections::BTreeMap; use syn::{FnArg, PatType, Type}; -use vespera_core::{ - route::{MediaType, Operation, Parameter, ParameterLocation, RequestBody, Response}, - schema::{Schema, SchemaRef}, -}; +use vespera_core::route::{MediaType, Operation, Parameter, ParameterLocation, Response}; use super::{ parameters::parse_function_parameter, path::extract_path_parameters, @@ -160,49 +157,6 @@ pub fn build_operation_from_function( } } - // Fallback: if last arg is String/&str and no body yet, treat as text/plain body - if request_body.is_none() - && let Some(FnArg::Typed(PatType { ty, .. })) = sig.inputs.last() - { - let is_string = match ty.as_ref() { - Type::Path(type_path) => type_path - .path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false), - Type::Reference(type_ref) => { - if let Type::Path(p) = type_ref.elem.as_ref() { - p.path - .segments - .last() - .map(|s| s.ident == "String" || s.ident == "str") - .unwrap_or(false) - } else { - false - } - } - _ => false, - }; - - if is_string { - let mut content = BTreeMap::new(); - content.insert( - "text/plain".to_string(), - MediaType { - schema: Some(SchemaRef::Inline(Box::new(Schema::string()))), - example: None, - examples: None, - }, - ); - request_body = Some(RequestBody { - description: None, - content, - required: Some(true), - }); - } - } - // Parse return type - may return multiple responses (for Result types) let mut responses = parse_return_type(&sig.output, known_schemas, struct_definitions); @@ -588,8 +542,8 @@ mod tests { } #[test] - fn test_string_body_fallback() { - // Test lines 100-107: String as last arg becomes text/plain body + fn test_string_body() { + // String arg is handled by parse_request_body via is_string_like() let op = build("fn upload(content: String) -> String", "/upload", None); let body = op.request_body.as_ref().expect("request body expected"); @@ -604,8 +558,8 @@ mod tests { } #[test] - fn test_str_ref_body_fallback() { - // Test lines 100-106: &str as last arg becomes text/plain body + fn test_str_ref_body() { + // &str arg is handled by parse_request_body via is_string_like() let op = build("fn upload(content: &str) -> String", "/upload", None); let body = op.request_body.as_ref().expect("request body expected"); @@ -613,23 +567,18 @@ mod tests { } #[test] - fn test_type_reference_with_string() { - // Test lines 100-102, 104: Type::Reference branch - &String + fn test_string_ref_body() { + // &String arg is handled by parse_request_body via is_string_like() let op = build("fn upload(content: &String) -> String", "/upload", None); - // &String reference should be detected as string type - // Line 101-102 checks if Type::Reference elem is a Path with String/str let body = op.request_body.as_ref().expect("request body expected"); assert!(body.content.contains_key("text/plain")); } #[test] - fn test_non_string_last_arg_not_body() { - // Test line 107: last arg that's NOT String/&str should NOT become body + fn test_non_string_arg_not_body() { + // Non-string args don't become request body let op = build("fn process(count: i32) -> String", "/process", None); - - // i32 is not String/&str, so line 107 returns false, no body created - // However, bare i32 without extractor is also ignored assert!(op.request_body.is_none()); } @@ -651,38 +600,33 @@ mod tests { #[test] fn test_reference_to_non_path_type_not_body() { - // Test line 104: &(tuple) reference where elem is NOT a Path type - // This hits the else branch at line 104 returning false + // &(tuple) is not string-like, no body created let op = build("fn process(data: &(i32, i32)) -> String", "/process", None); - // Reference to tuple is not String/&str, so no body created assert!(op.request_body.is_none()); } #[test] fn test_reference_to_slice_not_body() { - // Test line 104: &[T] reference where elem is NOT a simple Path type + // &[T] is not string-like, no body created let op = build("fn process(data: &[u8]) -> String", "/process", None); - // Reference to slice is not String/&str assert!(op.request_body.is_none()); } #[test] fn test_tuple_type_not_body() { - // Test line 107: tuple type (not Path, not Reference) returns false + // Tuple type is not string-like, no body created let op = build( "fn process(data: (i32, String)) -> String", "/process", None, ); - // Tuple is neither Path nor Reference, hits line 107 assert!(op.request_body.is_none()); } #[test] fn test_array_type_not_body() { - // Test line 107: array type (not Path, not Reference) returns false + // Array type is not string-like, no body created let op = build("fn process(data: [u8; 4]) -> String", "/process", None); - // Array is neither Path nor Reference assert!(op.request_body.is_none()); } From 4d88289dfcaeda5720cf9d439ac81e4fcff7749d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 22:29:16 +0900 Subject: [PATCH 09/12] Refactor --- crates/vespera_macro/src/openapi_generator.rs | 80 ++++++------------- 1 file changed, 26 insertions(+), 54 deletions(-) diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 7bf2bc6..7de4ee6 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -59,34 +59,17 @@ pub fn generate_openapi_doc_with_metadata( if let syn::Item::Struct(struct_item) = &parsed { // Find the file where this struct is defined // Try to find a route file that contains this struct + // Find the route file that contains this struct definition let struct_file = metadata .routes .iter() .find_map(|route| { - // Check if the file contains the struct definition - if let Ok(file_content) = std::fs::read_to_string(&route.file_path) { - // Check if the struct name appears in the file (more specific check) - // Look for "struct StructName" pattern - let struct_pattern = format!("struct {}", struct_meta.name); - if file_content.contains(&struct_pattern) { - return Some(route.file_path.clone()); - } - } - None + std::fs::read_to_string(&route.file_path) + .ok() + .filter(|content| content.contains(&format!("struct {}", struct_meta.name))) + .map(|_| route.file_path.clone()) }) - .or_else(|| { - // Fallback: try all route files to find the struct - for route in &metadata.routes { - if let Ok(file_content) = std::fs::read_to_string(&route.file_path) { - let struct_pattern = format!("struct {}", struct_meta.name); - if file_content.contains(&struct_pattern) { - return Some(route.file_path.clone()); - } - } - } - // Last resort: use first route file if available - metadata.routes.first().map(|r| r.file_path.clone()) - }); + .or_else(|| metadata.routes.first().map(|r| r.file_path.clone())); if let Some(file_path) = struct_file && let Ok(file_content) = std::fs::read_to_string(&file_path) @@ -362,22 +345,15 @@ fn extract_value_from_expr(expr: &syn::Expr) -> Option { // Literal values Expr::Lit(ExprLit { lit, .. }) => match lit { Lit::Str(s) => Some(serde_json::Value::String(s.value())), - Lit::Int(i) => { - if let Ok(val) = i.base10_parse::() { - Some(serde_json::Value::Number(val.into())) - } else { - None - } - } - Lit::Float(f) => { - if let Ok(val) = f.base10_parse::() { - Some(serde_json::Value::Number( - serde_json::Number::from_f64(val).unwrap_or(serde_json::Number::from(0)), - )) - } else { - None - } - } + Lit::Int(i) => i + .base10_parse::() + .ok() + .map(|v| serde_json::Value::Number(v.into())), + Lit::Float(f) => f + .base10_parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(serde_json::Value::Number), Lit::Bool(b) => Some(serde_json::Value::Bool(b.value)), _ => None, }, @@ -415,23 +391,19 @@ fn extract_value_from_expr(expr: &syn::Expr) -> Option { fn get_type_default(ty: &syn::Type) -> Option { use syn::Type; match ty { - Type::Path(type_path) => { - if let Some(segment) = type_path.path.segments.last() { - match segment.ident.to_string().as_str() { - "String" => Some(serde_json::Value::String(String::new())), - "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => { - Some(serde_json::Value::Number(serde_json::Number::from(0))) - } - "f32" | "f64" => Some(serde_json::Value::Number( - serde_json::Number::from_f64(0.0).unwrap_or(serde_json::Number::from(0)), - )), - "bool" => Some(serde_json::Value::Bool(false)), - _ => None, + Type::Path(type_path) => type_path.path.segments.last().and_then(|segment| { + match segment.ident.to_string().as_str() { + "String" => Some(serde_json::Value::String(String::new())), + "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => { + Some(serde_json::Value::Number(serde_json::Number::from(0))) } - } else { - None + "f32" | "f64" => Some(serde_json::Value::Number( + serde_json::Number::from_f64(0.0).unwrap_or(serde_json::Number::from(0)), + )), + "bool" => Some(serde_json::Value::Bool(false)), + _ => None, } - } + }), _ => None, } } From f463d6edbd50eed8742842993fe69c50625e40d2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 22:56:09 +0900 Subject: [PATCH 10/12] Fix description issue --- crates/vespera_macro/src/openapi_generator.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index 7de4ee6..7a9181e 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -131,11 +131,7 @@ pub fn generate_openapi_doc_with_metadata( route_meta.error_status.as_deref(), route_meta.tags.as_deref(), ); - - // Set description from metadata - if let Some(desc) = &route_meta.description { - operation.description = Some(desc.clone()); - } + operation.description = route_meta.description.clone(); // Get or create PathItem let path_item = paths From 8cc5b0557db120c007535d5318c62c72a5d5a98e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 23:10:30 +0900 Subject: [PATCH 11/12] Add testcase --- crates/vespera_macro/src/lib.rs | 297 ++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 396da69..a0cef50 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -1041,4 +1041,301 @@ pub fn get_users() -> String { drop(temp_dir); } + + // ========== Tests for parsing functions ========== + + #[test] + fn test_parse_openapi_values_single() { + // Test that single string openapi value parses correctly via AutoRouterInput + let tokens = quote::quote!(openapi = "openapi.json"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 1); + assert_eq!(openapi[0].value(), "openapi.json"); + } + + #[test] + fn test_parse_openapi_values_array() { + // Test that array openapi value parses correctly via AutoRouterInput + let tokens = quote::quote!(openapi = ["openapi.json", "api.json"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 2); + assert_eq!(openapi[0].value(), "openapi.json"); + assert_eq!(openapi[1].value(), "api.json"); + } + + #[test] + fn test_validate_server_url_valid_http() { + let lit = LitStr::new("http://localhost:3000", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "http://localhost:3000"); + } + + #[test] + fn test_validate_server_url_valid_https() { + let lit = LitStr::new("https://api.example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "https://api.example.com"); + } + + #[test] + fn test_validate_server_url_invalid() { + let lit = LitStr::new("ftp://example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_err()); + } + + #[test] + fn test_validate_server_url_no_scheme() { + let lit = LitStr::new("example.com", Span::call_site()); + let result = validate_server_url(&lit); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_parse_dir_only() { + let tokens = quote::quote!(dir = "api"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.dir.unwrap().value(), "api"); + assert!(input.openapi.is_none()); + } + + #[test] + fn test_auto_router_input_parse_string_as_dir() { + let tokens = quote::quote!("routes"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.dir.unwrap().value(), "routes"); + } + + #[test] + fn test_auto_router_input_parse_openapi_single() { + let tokens = quote::quote!(openapi = "openapi.json"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 1); + assert_eq!(openapi[0].value(), "openapi.json"); + } + + #[test] + fn test_auto_router_input_parse_openapi_array() { + let tokens = quote::quote!(openapi = ["a.json", "b.json"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let openapi = input.openapi.unwrap(); + assert_eq!(openapi.len(), 2); + } + + #[test] + fn test_auto_router_input_parse_title_version() { + let tokens = quote::quote!(title = "My API", version = "2.0.0"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.title.unwrap().value(), "My API"); + assert_eq!(input.version.unwrap().value(), "2.0.0"); + } + + #[test] + fn test_auto_router_input_parse_docs_redoc() { + let tokens = quote::quote!(docs_url = "/docs", redoc_url = "/redoc"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert_eq!(input.docs_url.unwrap().value(), "/docs"); + assert_eq!(input.redoc_url.unwrap().value(), "/redoc"); + } + + #[test] + fn test_auto_router_input_parse_servers_single() { + let tokens = quote::quote!(servers = "http://localhost:3000"); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert!(servers[0].description.is_none()); + } + + #[test] + fn test_auto_router_input_parse_servers_array_strings() { + let tokens = quote::quote!(servers = ["http://localhost:3000", "https://api.example.com"]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 2); + } + + #[test] + fn test_auto_router_input_parse_servers_tuple() { + let tokens = quote::quote!(servers = [("http://localhost:3000", "Development")]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert_eq!(servers[0].description, Some("Development".to_string())); + } + + #[test] + fn test_auto_router_input_parse_servers_struct() { + let tokens = + quote::quote!(servers = [{ url = "http://localhost:3000", description = "Dev" }]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert_eq!(servers[0].description, Some("Dev".to_string())); + } + + #[test] + fn test_auto_router_input_parse_servers_single_struct() { + let tokens = quote::quote!(servers = { url = "https://api.example.com" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "https://api.example.com"); + } + + #[test] + fn test_auto_router_input_parse_unknown_field() { + let tokens = quote::quote!(unknown_field = "value"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_parse_all_fields() { + let tokens = quote::quote!( + dir = "api", + openapi = "openapi.json", + title = "Test API", + version = "1.0.0", + docs_url = "/docs", + redoc_url = "/redoc", + servers = "http://localhost:3000" + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + assert!(input.dir.is_some()); + assert!(input.openapi.is_some()); + assert!(input.title.is_some()); + assert!(input.version.is_some()); + assert!(input.docs_url.is_some()); + assert!(input.redoc_url.is_some()); + assert!(input.servers.is_some()); + } + + #[test] + fn test_generate_router_code_with_docs() { + let metadata = CollectedMetadata::new(); + let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); + + let result = generate_router_code(&metadata, docs_info, None); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("swagger-ui")); + } + + #[test] + fn test_generate_router_code_with_redoc() { + let metadata = CollectedMetadata::new(); + let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); + + let result = generate_router_code(&metadata, None, redoc_info); + let code = result.to_string(); + + assert!(code.contains("/redoc")); + assert!(code.contains("redoc")); + } + + #[test] + fn test_generate_router_code_with_both_docs() { + let metadata = CollectedMetadata::new(); + let docs_info = Some(("/docs".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); + let redoc_info = Some(("/redoc".to_string(), r#"{"openapi":"3.1.0"}"#.to_string())); + + let result = generate_router_code(&metadata, docs_info, redoc_info); + let code = result.to_string(); + + assert!(code.contains("/docs")); + assert!(code.contains("/redoc")); + } + + #[test] + fn test_parse_server_struct_url_only() { + // Test server struct parsing via AutoRouterInput + let tokens = quote::quote!(servers = { url = "http://localhost:3000" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + assert!(servers[0].description.is_none()); + } + + #[test] + fn test_parse_server_struct_with_description() { + let tokens = + quote::quote!(servers = { url = "http://localhost:3000", description = "Local" }); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers[0].description, Some("Local".to_string())); + } + + #[test] + fn test_parse_server_struct_unknown_field() { + let tokens = quote::quote!(servers = { url = "http://localhost:3000", unknown = "test" }); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_parse_server_struct_missing_url() { + let tokens = quote::quote!(servers = { description = "test" }); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_parse_servers_tuple_url_only() { + let tokens = quote::quote!(servers = [("http://localhost:3000")]); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let servers = input.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert!(servers[0].description.is_none()); + } + + #[test] + fn test_parse_servers_invalid_url() { + let tokens = quote::quote!(servers = "invalid-url"); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_parse_invalid_token() { + // Test line 149: neither ident nor string literal triggers lookahead error + let tokens = quote::quote!(123); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_err()); + } + + #[test] + fn test_auto_router_input_empty() { + // Test empty input - should use defaults/env vars + let tokens = quote::quote!(); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_ok()); + } + + #[test] + fn test_auto_router_input_multiple_commas() { + // Test input with trailing comma + let tokens = quote::quote!(dir = "api",); + let result: syn::Result = syn::parse2(tokens); + assert!(result.is_ok()); + } + + #[test] + fn test_auto_router_input_no_comma() { + // Test input without comma between fields (should stop at second field) + let tokens = quote::quote!(dir = "api" title = "Test"); + let result: syn::Result = syn::parse2(tokens); + // This should fail or only parse first field + assert!(result.is_err()); + } } From 3a726966f507c2379de61e1e30058c85ce439941 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Jan 2026 23:33:28 +0900 Subject: [PATCH 12/12] Add testcase --- crates/vespera_macro/src/lib.rs | 514 +++++++++++++++++----- crates/vespera_macro/src/parser/schema.rs | 60 ++- 2 files changed, 461 insertions(+), 113 deletions(-) diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index a0cef50..6d43984 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -24,15 +24,29 @@ use crate::openapi_generator::generate_openapi_doc_with_metadata; use vespera_core::openapi::Server; use vespera_core::route::HttpMethod; +/// Validate route function - must be pub and async +fn validate_route_fn(item_fn: &syn::ItemFn) -> Result<(), syn::Error> { + if !matches!(item_fn.vis, syn::Visibility::Public(_)) { + return Err(syn::Error::new_spanned( + item_fn.sig.fn_token, + "route function must be public", + )); + } + if item_fn.sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + item_fn.sig.fn_token, + "route function must be async", + )); + } + Ok(()) +} + /// route attribute macro #[proc_macro_attribute] pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { - // Validate attribute arguments if let Err(e) = syn::parse::(attr) { return e.to_compile_error().into(); } - - // Validate that item is a function let item_fn = match syn::parse::(item.clone()) { Ok(f) => f, Err(e) => { @@ -41,21 +55,9 @@ pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { .into(); } }; - - // Validate function is pub - if !matches!(item_fn.vis, syn::Visibility::Public(_)) { - return syn::Error::new_spanned(item_fn.sig.fn_token, "route function must be public") - .to_compile_error() - .into(); - } - - // Validate function is async - if item_fn.sig.asyncness.is_none() { - return syn::Error::new_spanned(item_fn.sig.fn_token, "route function must be async") - .to_compile_error() - .into(); + if let Err(e) = validate_route_fn(&item_fn) { + return e.to_compile_error().into(); } - item } @@ -63,27 +65,27 @@ pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream { static SCHEMA_STORAGE: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); -/// Derive macro for Schema -#[proc_macro_derive(Schema)] -pub fn derive_schema(input: TokenStream) -> TokenStream { - let input = syn::parse_macro_input!(input as syn::DeriveInput); +/// Process derive input and return metadata + expanded code +fn process_derive_schema(input: &syn::DeriveInput) -> (StructMetadata, proc_macro2::TokenStream) { let name = &input.ident; let generics = &input.generics; - - let mut schema_storage = SCHEMA_STORAGE.lock().unwrap(); - schema_storage.push(StructMetadata { + let metadata = StructMetadata { name: name.to_string(), definition: quote::quote!(#input).to_string(), - }); - - // Mark both struct and enum as having SchemaBuilder - // For generic types, include the generic parameters in the impl - // The actual schema generation will be done at runtime + }; let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); let expanded = quote! { impl #impl_generics vespera::schema::SchemaBuilder for #name #ty_generics #where_clause {} }; + (metadata, expanded) +} +/// Derive macro for Schema +#[proc_macro_derive(Schema)] +pub fn derive_schema(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + let (metadata, expanded) = process_derive_schema(&input); + SCHEMA_STORAGE.lock().unwrap().push(metadata); TokenStream::from(expanded) } @@ -376,49 +378,107 @@ fn parse_server_struct(input: ParseStream) -> syn::Result { Ok(ServerConfig { url, description }) } +/// Docs info tuple type alias for cleaner signatures +type DocsInfo = (Option<(String, String)>, Option<(String, String)>); + +/// Processed vespera input with extracted values +struct ProcessedVesperaInput { + folder_name: String, + openapi_file_names: Vec, + title: Option, + version: Option, + docs_url: Option, + redoc_url: Option, + servers: Option>, +} + +/// Process AutoRouterInput into extracted values +fn process_vespera_input(input: AutoRouterInput) -> ProcessedVesperaInput { + ProcessedVesperaInput { + folder_name: input + .dir + .map(|f| f.value()) + .unwrap_or_else(|| "routes".to_string()), + openapi_file_names: input + .openapi + .unwrap_or_default() + .into_iter() + .map(|f| f.value()) + .collect(), + title: input.title.map(|t| t.value()), + version: input.version.map(|v| v.value()), + docs_url: input.docs_url.map(|u| u.value()), + redoc_url: input.redoc_url.map(|u| u.value()), + servers: input.servers.map(|svrs| { + svrs.into_iter() + .map(|s| Server { + url: s.url, + description: s.description, + variables: None, + }) + .collect() + }), + } +} + +/// Generate OpenAPI JSON and write to files, returning docs info +fn generate_and_write_openapi( + input: &ProcessedVesperaInput, + metadata: &CollectedMetadata, +) -> Result { + if input.openapi_file_names.is_empty() && input.docs_url.is_none() && input.redoc_url.is_none() + { + return Ok((None, None)); + } + + let json_str = serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( + input.title.clone(), + input.version.clone(), + input.servers.clone(), + metadata, + )) + .map_err(|e| format!("Failed to serialize OpenAPI document: {}", e))?; + + for openapi_file_name in &input.openapi_file_names { + let file_path = Path::new(openapi_file_name); + if let Some(parent) = file_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create directory: {}", e))?; + } + std::fs::write(file_path, &json_str).map_err(|e| { + format!( + "Failed to write OpenAPI document to {}: {}", + openapi_file_name, e + ) + })?; + } + + let docs_info = input + .docs_url + .as_ref() + .map(|url| (url.clone(), json_str.clone())); + let redoc_info = input.redoc_url.as_ref().map(|url| (url.clone(), json_str)); + + Ok((docs_info, redoc_info)) +} + #[proc_macro] pub fn vespera(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as AutoRouterInput); + let processed = process_vespera_input(input); - let folder_name = input - .dir - .map(|f| f.value()) - .unwrap_or_else(|| "routes".to_string()); - - let openapi_file_names = input - .openapi - .unwrap_or_default() - .into_iter() - .map(|f| f.value()) - .collect::>(); - - let title = input.title.map(|t| t.value()); - let version = input.version.map(|v| v.value()); - let docs_url = input.docs_url.map(|u| u.value()); - let redoc_url = input.redoc_url.map(|u| u.value()); - let servers = input.servers.map(|svrs| { - svrs.into_iter() - .map(|s| Server { - url: s.url, - description: s.description, - variables: None, - }) - .collect::>() - }); - - let folder_path = find_folder_path(&folder_name); - + let folder_path = find_folder_path(&processed.folder_name); if !folder_path.exists() { return syn::Error::new( Span::call_site(), - format!("Folder not found: {}", folder_name), + format!("Folder not found: {}", processed.folder_name), ) .to_compile_error() .into(); } - let mut metadata = match collect_metadata(&folder_path, &folder_name) { - Ok(metadata) => metadata, + let mut metadata = match collect_metadata(&folder_path, &processed.folder_name) { + Ok(m) => m, Err(e) => { return syn::Error::new( Span::call_site(), @@ -428,56 +488,18 @@ pub fn vespera(input: TokenStream) -> TokenStream { .into(); } }; - let schemas = SCHEMA_STORAGE.lock().unwrap().clone(); - - metadata.structs.extend(schemas); - - let mut docs_info = None; - let mut redoc_info = None; - - if !openapi_file_names.is_empty() || docs_url.is_some() || redoc_url.is_some() { - // Generate OpenAPI document using collected metadata - - // Serialize to JSON - let json_str = match serde_json::to_string_pretty(&generate_openapi_doc_with_metadata( - title, version, servers, &metadata, - )) { - Ok(json) => json, - Err(e) => { - return syn::Error::new( - Span::call_site(), - format!("Failed to serialize OpenAPI document: {}", e), - ) - .to_compile_error() - .into(); - } - }; - for openapi_file_name in &openapi_file_names { - // create directory if not exists - let file_path = Path::new(openapi_file_name); - if let Some(parent) = file_path.parent() { - std::fs::create_dir_all(parent).expect("Failed to create parent directory"); - } + metadata + .structs + .extend(SCHEMA_STORAGE.lock().unwrap().clone()); - if let Err(e) = std::fs::write(file_path, &json_str) { - return syn::Error::new( - Span::call_site(), - format!( - "Failed to write OpenAPI document to {}: {}", - openapi_file_name, e - ), - ) + let (docs_info, redoc_info) = match generate_and_write_openapi(&processed, &metadata) { + Ok(info) => info, + Err(e) => { + return syn::Error::new(Span::call_site(), e) .to_compile_error() .into(); - } } - if let Some(docs_url) = docs_url { - docs_info = Some((docs_url, json_str.clone())); - } - if let Some(redoc_url) = redoc_url { - redoc_info = Some((redoc_url, json_str)); - } - } + }; generate_router_code(&metadata, docs_info, redoc_info).into() } @@ -1338,4 +1360,284 @@ pub fn get_users() -> String { // This should fail or only parse first field assert!(result.is_err()); } + + // ========== Tests for validate_route_fn ========== + + #[test] + fn test_validate_route_fn_not_public() { + let item: syn::ItemFn = syn::parse_quote! { + async fn private_handler() -> String { + "test".to_string() + } + }; + let result = validate_route_fn(&item); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("must be public")); + } + + #[test] + fn test_validate_route_fn_not_async() { + let item: syn::ItemFn = syn::parse_quote! { + pub fn sync_handler() -> String { + "test".to_string() + } + }; + let result = validate_route_fn(&item); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("must be async")); + } + + #[test] + fn test_validate_route_fn_valid() { + let item: syn::ItemFn = syn::parse_quote! { + pub async fn valid_handler() -> String { + "test".to_string() + } + }; + let result = validate_route_fn(&item); + assert!(result.is_ok()); + } + + // ========== Tests for process_derive_schema ========== + + #[test] + fn test_process_derive_schema_struct() { + let input: syn::DeriveInput = syn::parse_quote! { + struct User { + name: String, + age: u32, + } + }; + let (metadata, expanded) = process_derive_schema(&input); + assert_eq!(metadata.name, "User"); + assert!(metadata.definition.contains("struct User")); + let code = expanded.to_string(); + assert!(code.contains("SchemaBuilder")); + assert!(code.contains("User")); + } + + #[test] + fn test_process_derive_schema_enum() { + let input: syn::DeriveInput = syn::parse_quote! { + enum Status { + Active, + Inactive, + } + }; + let (metadata, expanded) = process_derive_schema(&input); + assert_eq!(metadata.name, "Status"); + assert!(metadata.definition.contains("enum Status")); + let code = expanded.to_string(); + assert!(code.contains("SchemaBuilder")); + } + + #[test] + fn test_process_derive_schema_generic() { + let input: syn::DeriveInput = syn::parse_quote! { + struct Container { + value: T, + } + }; + let (metadata, expanded) = process_derive_schema(&input); + assert_eq!(metadata.name, "Container"); + let code = expanded.to_string(); + assert!(code.contains("SchemaBuilder")); + // Should have generic impl + assert!(code.contains("impl")); + } + + // ========== Tests for process_vespera_input ========== + + #[test] + fn test_process_vespera_input_defaults() { + let tokens = quote::quote!(); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "routes"); + assert!(processed.openapi_file_names.is_empty()); + assert!(processed.title.is_none()); + assert!(processed.docs_url.is_none()); + } + + #[test] + fn test_process_vespera_input_all_fields() { + let tokens = quote::quote!( + dir = "api", + openapi = ["openapi.json", "api.json"], + title = "My API", + version = "1.0.0", + docs_url = "/docs", + redoc_url = "/redoc", + servers = "http://localhost:3000" + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + assert_eq!(processed.folder_name, "api"); + assert_eq!( + processed.openapi_file_names, + vec!["openapi.json", "api.json"] + ); + assert_eq!(processed.title, Some("My API".to_string())); + assert_eq!(processed.version, Some("1.0.0".to_string())); + assert_eq!(processed.docs_url, Some("/docs".to_string())); + assert_eq!(processed.redoc_url, Some("/redoc".to_string())); + let servers = processed.servers.unwrap(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].url, "http://localhost:3000"); + } + + #[test] + fn test_process_vespera_input_servers_with_description() { + let tokens = quote::quote!( + servers = [{ url = "https://api.example.com", description = "Production" }] + ); + let input: AutoRouterInput = syn::parse2(tokens).unwrap(); + let processed = process_vespera_input(input); + let servers = processed.servers.unwrap(); + assert_eq!(servers[0].url, "https://api.example.com"); + assert_eq!(servers[0].description, Some("Production".to_string())); + } + + // ========== Tests for generate_and_write_openapi ========== + + #[test] + fn test_generate_and_write_openapi_no_output() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata); + assert!(result.is_ok()); + let (docs_info, redoc_info) = result.unwrap(); + assert!(docs_info.is_none()); + assert!(redoc_info.is_none()); + } + + #[test] + fn test_generate_and_write_openapi_docs_only() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: Some("Test API".to_string()), + version: Some("1.0.0".to_string()), + docs_url: Some("/docs".to_string()), + redoc_url: None, + servers: None, + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata); + assert!(result.is_ok()); + let (docs_info, redoc_info) = result.unwrap(); + assert!(docs_info.is_some()); + let (url, json) = docs_info.unwrap(); + assert_eq!(url, "/docs"); + assert!(json.contains("\"openapi\"")); + assert!(json.contains("Test API")); + assert!(redoc_info.is_none()); + } + + #[test] + fn test_generate_and_write_openapi_redoc_only() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: None, + redoc_url: Some("/redoc".to_string()), + servers: None, + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata); + assert!(result.is_ok()); + let (docs_info, redoc_info) = result.unwrap(); + assert!(docs_info.is_none()); + assert!(redoc_info.is_some()); + let (url, _) = redoc_info.unwrap(); + assert_eq!(url, "/redoc"); + } + + #[test] + fn test_generate_and_write_openapi_both_docs() { + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![], + title: None, + version: None, + docs_url: Some("/docs".to_string()), + redoc_url: Some("/redoc".to_string()), + servers: None, + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata); + assert!(result.is_ok()); + let (docs_info, redoc_info) = result.unwrap(); + assert!(docs_info.is_some()); + assert!(redoc_info.is_some()); + } + + #[test] + fn test_generate_and_write_openapi_file_output() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("test-openapi.json"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: Some("File Test".to_string()), + version: Some("2.0.0".to_string()), + docs_url: None, + redoc_url: None, + servers: None, + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata); + assert!(result.is_ok()); + + // Verify file was written + assert!(output_path.exists()); + let content = fs::read_to_string(&output_path).unwrap(); + assert!(content.contains("\"openapi\"")); + assert!(content.contains("File Test")); + assert!(content.contains("2.0.0")); + } + + #[test] + fn test_generate_and_write_openapi_creates_directories() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let output_path = temp_dir.path().join("nested/dir/openapi.json"); + + let processed = ProcessedVesperaInput { + folder_name: "routes".to_string(), + openapi_file_names: vec![output_path.to_string_lossy().to_string()], + title: None, + version: None, + docs_url: None, + redoc_url: None, + servers: None, + }; + let metadata = CollectedMetadata::new(); + let result = generate_and_write_openapi(&processed, &metadata); + assert!(result.is_ok()); + + // Verify nested directories and file were created + assert!(output_path.exists()); + } + + // ========== Tests for find_folder_path ========== + // Note: find_folder_path uses CARGO_MANIFEST_DIR which is set during cargo test + + #[test] + fn test_find_folder_path_nonexistent_returns_path() { + // When the constructed path doesn't exist, it falls back to using folder_name directly + let result = find_folder_path("nonexistent_folder_xyz"); + // It should return a PathBuf (either from src/nonexistent... or just the folder name) + assert!(result.to_string_lossy().contains("nonexistent_folder_xyz")); + } } diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index 536dd61..a1688ab 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -896,13 +896,8 @@ pub(super) fn parse_type_to_schema_ref_with_schemas( } _ => { // Check if this is a known schema (struct with Schema derive) - // Try both the full path and just the type name - let type_name = if path.segments.len() > 1 { - // For paths like crate::TestStruct, use just the type name - ident_str.clone() - } else { - ident_str.clone() - }; + // Use just the type name (handles both crate::TestStruct and TestStruct) + let type_name = ident_str.clone(); if known_schemas.contains_key(&type_name) { // Check if this is a generic type with type parameters @@ -2483,4 +2478,55 @@ mod tests { let one_of = schema.one_of.expect("one_of missing"); assert_eq!(one_of.len(), 1); } + + // Test rename_field with unknown/invalid rename_all format - should return original field name + #[test] + fn test_rename_field_unknown_format() { + // Unknown format should return the original field name unchanged + let result = rename_field("my_field", Some("unknown_format")); + assert_eq!(result, "my_field"); + + let result = rename_field("myField", Some("invalid")); + assert_eq!(result, "myField"); + + let result = rename_field("test_name", Some("not_a_real_format")); + assert_eq!(result, "test_name"); + } + + // Test parse_type_to_schema_ref with unknown custom type (not in known_schemas) + #[test] + fn test_parse_type_to_schema_ref_unknown_custom_type() { + // MyUnknownType is not in known_schemas, should return inline object schema + let ty: Type = syn::parse_str("MyUnknownType").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + } else { + panic!("Expected inline schema for unknown type"); + } + } + + // Test parse_type_to_schema_ref with qualified path to unknown type + #[test] + fn test_parse_type_to_schema_ref_qualified_unknown_type() { + // crate::models::UnknownStruct is not in known_schemas + let ty: Type = syn::parse_str("crate::models::UnknownStruct").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); + if let SchemaRef::Inline(schema) = schema_ref { + assert_eq!(schema.schema_type, Some(SchemaType::Object)); + } else { + panic!("Expected inline schema for unknown qualified type"); + } + } + + // Test camelCase transformation with mixed characters (covers line 263) + #[test] + fn test_rename_field_camelcase_with_digits() { + // Tests the regular character branch in camelCase + let result = rename_field("user_id_123", Some("camelCase")); + assert_eq!(result, "userId123"); + + let result = rename_field("get_user_by_id", Some("camelCase")); + assert_eq!(result, "getUserById"); + } }