diff --git a/.changepacks/changepack_log_YqFBr3DFcYz7WgZ1blg__.json b/.changepacks/changepack_log_YqFBr3DFcYz7WgZ1blg__.json new file mode 100644 index 0000000..1fd53bb --- /dev/null +++ b/.changepacks/changepack_log_YqFBr3DFcYz7WgZ1blg__.json @@ -0,0 +1 @@ +{"changes":{"crates/vespera_macro/Cargo.toml":"Patch","crates/vespera_core/Cargo.toml":"Patch","crates/vespera/Cargo.toml":"Patch"},"note":"Fix rename_all","date":"2026-01-12T04:26:20.440174200Z"} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0e2fe11..873277d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ target lcov.info +coverage +build_rs_cov.profraw diff --git a/crates/vespera_macro/src/lib.rs b/crates/vespera_macro/src/lib.rs index 3779a4b..396da69 100644 --- a/crates/vespera_macro/src/lib.rs +++ b/crates/vespera_macro/src/lib.rs @@ -266,7 +266,7 @@ fn parse_servers_values(input: ParseStream) -> syn::Result> { input.parse::()?; if input.peek(syn::token::Bracket) { - // Array format: [...] + // Array format: [...] let content; let _ = bracketed!(content in input); diff --git a/crates/vespera_macro/src/openapi_generator.rs b/crates/vespera_macro/src/openapi_generator.rs index b3a91ca..1f3ed5c 100644 --- a/crates/vespera_macro/src/openapi_generator.rs +++ b/crates/vespera_macro/src/openapi_generator.rs @@ -805,4 +805,353 @@ pub fn create_user() -> String { // Ensure TempDir is properly closed drop(temp_dir); } + + #[test] + fn test_generate_openapi_with_tags_and_description() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let route_content = r#" +pub fn get_users() -> String { + "users".to_string() +} +"#; + let route_file = create_temp_file(&temp_dir, "users.rs", route_content); + + let mut metadata = CollectedMetadata::new(); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/users".to_string(), + function_name: "get_users".to_string(), + module_path: "test::users".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_users() -> String".to_string(), + error_status: Some(vec![404]), + tags: Some(vec!["users".to_string(), "admin".to_string()]), + description: Some("Get all users".to_string()), + }); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + + // Check route has description + let path_item = doc.paths.get("/users").unwrap(); + let operation = path_item.get.as_ref().unwrap(); + assert_eq!(operation.description, Some("Get all users".to_string())); + + // Check tags are collected + assert!(doc.tags.is_some()); + let tags = doc.tags.as_ref().unwrap(); + assert!(tags.iter().any(|t| t.name == "users")); + assert!(tags.iter().any(|t| t.name == "admin")); + } + + #[test] + fn test_generate_openapi_with_servers() { + let metadata = CollectedMetadata::new(); + let servers = vec![ + Server { + url: "https://api.example.com".to_string(), + description: Some("Production".to_string()), + variables: None, + }, + Server { + url: "http://localhost:3000".to_string(), + description: Some("Development".to_string()), + variables: None, + }, + ]; + + let doc = generate_openapi_doc_with_metadata(None, None, Some(servers), &metadata); + + assert!(doc.servers.is_some()); + let doc_servers = doc.servers.unwrap(); + assert_eq!(doc_servers.len(), 2); + assert_eq!(doc_servers[0].url, "https://api.example.com"); + assert_eq!(doc_servers[1].url, "http://localhost:3000"); + } + + #[test] + fn test_extract_value_from_expr_int() { + let expr: syn::Expr = syn::parse_str("42").unwrap(); + let value = extract_value_from_expr(&expr); + assert_eq!(value, Some(serde_json::Value::Number(42.into()))); + } + + #[test] + fn test_extract_value_from_expr_float() { + let expr: syn::Expr = syn::parse_str("12.34").unwrap(); + let value = extract_value_from_expr(&expr); + assert!(value.is_some()); + if let Some(serde_json::Value::Number(n)) = value { + assert!((n.as_f64().unwrap() - 12.34).abs() < 0.001); + } + } + + #[test] + fn test_extract_value_from_expr_bool() { + let expr_true: syn::Expr = syn::parse_str("true").unwrap(); + let expr_false: syn::Expr = syn::parse_str("false").unwrap(); + assert_eq!( + extract_value_from_expr(&expr_true), + Some(serde_json::Value::Bool(true)) + ); + assert_eq!( + extract_value_from_expr(&expr_false), + Some(serde_json::Value::Bool(false)) + ); + } + + #[test] + fn test_extract_value_from_expr_string() { + let expr: syn::Expr = syn::parse_str(r#""hello""#).unwrap(); + let value = extract_value_from_expr(&expr); + assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); + } + + #[test] + fn test_extract_value_from_expr_to_string() { + let expr: syn::Expr = syn::parse_str(r#""hello".to_string()"#).unwrap(); + let value = extract_value_from_expr(&expr); + assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); + } + + #[test] + fn test_extract_value_from_expr_vec_macro() { + let expr: syn::Expr = syn::parse_str("vec![]").unwrap(); + let value = extract_value_from_expr(&expr); + assert_eq!(value, Some(serde_json::Value::Array(vec![]))); + } + + #[test] + fn test_extract_value_from_expr_unsupported() { + // Binary expression is not supported + let expr: syn::Expr = syn::parse_str("1 + 2").unwrap(); + let value = extract_value_from_expr(&expr); + assert!(value.is_none()); + } + + #[test] + fn test_extract_value_from_expr_method_call_non_to_string() { + // Method call that's not to_string() + let expr: syn::Expr = syn::parse_str(r#""hello".len()"#).unwrap(); + let value = extract_value_from_expr(&expr); + assert!(value.is_none()); + } + + #[test] + fn test_extract_value_from_expr_unsupported_literal() { + // Byte literal is not directly supported + let expr: syn::Expr = syn::parse_str("b'a'").unwrap(); + let value = extract_value_from_expr(&expr); + assert!(value.is_none()); + } + + #[test] + fn test_extract_value_from_expr_non_vec_macro() { + // Other macros like println! are not supported + let expr: syn::Expr = syn::parse_str(r#"println!("test")"#).unwrap(); + let value = extract_value_from_expr(&expr); + assert!(value.is_none()); + } + + #[test] + fn test_get_type_default_string() { + let ty: syn::Type = syn::parse_str("String").unwrap(); + let value = get_type_default(&ty); + assert_eq!(value, Some(serde_json::Value::String(String::new()))); + } + + #[test] + fn test_get_type_default_integers() { + for type_name in &["i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64"] { + let ty: syn::Type = syn::parse_str(type_name).unwrap(); + let value = get_type_default(&ty); + assert_eq!( + value, + Some(serde_json::Value::Number(0.into())), + "Failed for type {}", + type_name + ); + } + } + + #[test] + fn test_get_type_default_floats() { + for type_name in &["f32", "f64"] { + let ty: syn::Type = syn::parse_str(type_name).unwrap(); + let value = get_type_default(&ty); + assert!(value.is_some(), "Failed for type {}", type_name); + } + } + + #[test] + fn test_get_type_default_bool() { + let ty: syn::Type = syn::parse_str("bool").unwrap(); + let value = get_type_default(&ty); + assert_eq!(value, Some(serde_json::Value::Bool(false))); + } + + #[test] + fn test_get_type_default_unknown() { + let ty: syn::Type = syn::parse_str("CustomType").unwrap(); + let value = get_type_default(&ty); + assert!(value.is_none()); + } + + #[test] + fn test_get_type_default_non_path() { + // Reference type is not a path type + let ty: syn::Type = syn::parse_str("&str").unwrap(); + let value = get_type_default(&ty); + assert!(value.is_none()); + } + + #[test] + fn test_find_function_in_file() { + let file_content = r#" +fn foo() {} +fn bar() -> i32 { 42 } +fn baz(x: i32) -> i32 { x } +"#; + let file_ast: syn::File = syn::parse_str(file_content).unwrap(); + + assert!(find_function_in_file(&file_ast, "foo").is_some()); + assert!(find_function_in_file(&file_ast, "bar").is_some()); + assert!(find_function_in_file(&file_ast, "baz").is_some()); + assert!(find_function_in_file(&file_ast, "nonexistent").is_none()); + } + + #[test] + fn test_extract_default_value_from_function() { + // Test direct expression return + let func: syn::ItemFn = syn::parse_str( + r#" + fn default_value() -> i32 { + 42 + } + "#, + ) + .unwrap(); + let value = extract_default_value_from_function(&func); + assert_eq!(value, Some(serde_json::Value::Number(42.into()))); + } + + #[test] + fn test_extract_default_value_from_function_with_return() { + // Test explicit return statement + let func: syn::ItemFn = syn::parse_str( + r#" + fn default_value() -> String { + return "hello".to_string() + } + "#, + ) + .unwrap(); + let value = extract_default_value_from_function(&func); + assert_eq!(value, Some(serde_json::Value::String("hello".to_string()))); + } + + #[test] + fn test_extract_default_value_from_function_empty() { + // Test function with no extractable value + let func: syn::ItemFn = syn::parse_str( + r#" + fn default_value() { + let x = 1; + } + "#, + ) + .unwrap(); + let value = extract_default_value_from_function(&func); + assert!(value.is_none()); + } + + #[test] + fn test_generate_openapi_with_default_functions() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + // Create a file with struct that has default function + let route_content = r#" +fn default_name() -> String { + "John".to_string() +} + +struct User { + #[serde(default = "default_name")] + name: String, +} + +pub fn get_user() -> User { + User { name: "Alice".to_string() } +} +"#; + let route_file = create_temp_file(&temp_dir, "user.rs", route_content); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "User".to_string(), + definition: r#"struct User { #[serde(default = "default_name")] name: String }"# + .to_string(), + }); + 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: route_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 present + 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_generate_openapi_with_simple_default() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + + let route_content = r#" +struct Config { + #[serde(default)] + enabled: bool, + #[serde(default)] + count: i32, +} + +pub fn get_config() -> Config { + Config { enabled: true, count: 0 } +} +"#; + let route_file = create_temp_file(&temp_dir, "config.rs", route_content); + + let mut metadata = CollectedMetadata::new(); + metadata.structs.push(StructMetadata { + name: "Config".to_string(), + definition: + r#"struct Config { #[serde(default)] enabled: bool, #[serde(default)] count: i32 }"# + .to_string(), + }); + metadata.routes.push(RouteMetadata { + method: "GET".to_string(), + path: "/config".to_string(), + function_name: "get_config".to_string(), + module_path: "test::config".to_string(), + file_path: route_file.to_string_lossy().to_string(), + signature: "fn get_config() -> Config".to_string(), + error_status: None, + tags: None, + description: None, + }); + + let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata); + + assert!(doc.components.as_ref().unwrap().schemas.is_some()); + let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap(); + assert!(schemas.contains_key("Config")); + } } diff --git a/crates/vespera_macro/src/parser/operation.rs b/crates/vespera_macro/src/parser/operation.rs index d60720a..2b535be 100644 --- a/crates/vespera_macro/src/parser/operation.rs +++ b/crates/vespera_macro/src/parser/operation.rs @@ -367,6 +367,30 @@ mod tests { } } + fn build_with_tags(sig_src: &str, path: &str, tags: Option<&[String]>) -> Operation { + let sig: syn::Signature = syn::parse_str(sig_src).expect("signature parse failed"); + build_operation_from_function(&sig, path, &HashMap::new(), &HashMap::new(), None, tags) + } + + #[test] + fn test_build_operation_with_tags() { + let tags = vec!["users".to_string(), "admin".to_string()]; + let op = build_with_tags("fn test() -> String", "/test", Some(&tags)); + assert_eq!(op.tags, Some(tags)); + } + + #[test] + fn test_build_operation_without_tags() { + let op = build_with_tags("fn test() -> String", "/test", None); + assert_eq!(op.tags, None); + } + + #[test] + fn test_build_operation_operation_id() { + let op = build("fn my_handler() -> String", "/test", None); + assert_eq!(op.operation_id, Some("my_handler".to_string())); + } + #[rstest] #[case( "fn upload(data: String) -> String", diff --git a/crates/vespera_macro/src/parser/schema.rs b/crates/vespera_macro/src/parser/schema.rs index cf339fe..79a34bc 100644 --- a/crates/vespera_macro/src/parser/schema.rs +++ b/crates/vespera_macro/src/parser/schema.rs @@ -6,20 +6,43 @@ use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType}; pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option { for attr in attrs { if attr.path().is_ident("serde") { - // Parse the attribute tokens manually - // Format: #[serde(rename_all = "camelCase")] - let tokens = attr.meta.require_list().ok()?; + // Try using parse_nested_meta for robust parsing + let mut found_rename_all = None; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename_all") + && let Ok(value) = meta.value() + && let Ok(syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + })) = value.parse::() + { + found_rename_all = Some(s.value()); + } + Ok(()) + }); + if found_rename_all.is_some() { + return found_rename_all; + } + + // Fallback: manual token parsing + let tokens = match attr.meta.require_list() { + Ok(t) => t, + Err(_) => continue, + }; let token_str = tokens.tokens.to_string(); // Look for rename_all = "..." pattern if let Some(start) = token_str.find("rename_all") { let remaining = &token_str[start + "rename_all".len()..]; if let Some(equals_pos) = remaining.find('=') { - let value_part = &remaining[equals_pos + 1..].trim(); - // Extract string value (remove quotes) - if value_part.starts_with('"') && value_part.ends_with('"') { - let value = &value_part[1..value_part.len() - 1]; - return Some(value.to_string()); + let value_part = remaining[equals_pos + 1..].trim(); + // Extract string value - find the closing quote + if let Some(quote_start) = value_part.find('"') { + let after_quote = &value_part[quote_start + 1..]; + if let Some(quote_end) = after_quote.find('"') { + let value = &after_quote[..quote_end]; + return Some(value.to_string()); + } } } } @@ -222,12 +245,28 @@ pub fn rename_field(field_name: &str, rename_all: Option<&str>) -> String { // "lowercase", "UPPERCASE", "PascalCase", "camelCase", "snake_case", "SCREAMING_SNAKE_CASE", "kebab-case", "SCREAMING-KEBAB-CASE" match rename_all { Some("camelCase") => { - // Convert snake_case to camelCase + // Convert snake_case or PascalCase to camelCase let mut result = String::new(); let mut capitalize_next = false; - for ch in field_name.chars() { + let mut in_first_word = true; + let chars: Vec = field_name.chars().collect(); + + for (i, &ch) in chars.iter().enumerate() { if ch == '_' { capitalize_next = true; + in_first_word = false; + } else if in_first_word { + // In first word: lowercase until we hit a word boundary + // Word boundary: uppercase char followed by lowercase (e.g., "XMLParser" -> "P" starts new word) + let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); + if ch.is_uppercase() && next_is_lower && i > 0 { + // This uppercase starts a new word (e.g., 'P' in "XMLParser") + in_first_word = false; + result.push(ch); + } else { + // Still in first word, lowercase it + result.push(ch.to_lowercase().next().unwrap_or(ch)); + } } else if capitalize_next { result.push(ch.to_uppercase().next().unwrap_or(ch)); capitalize_next = false; @@ -1256,6 +1295,88 @@ mod tests { assert!(!inner_props.contains_key("user-id")); // variant rename_all ignored for this field } + #[test] + fn test_parse_enum_to_schema_rename_all_with_other_attrs_unit() { + // Test rename_all combined with other serde attributes for unit variants + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "kebab-case", default)] + enum Status { + ActiveUser, + InactiveUser, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let enum_values = schema.r#enum.expect("enum values missing"); + assert_eq!(enum_values[0].as_str().unwrap(), "active-user"); + assert_eq!(enum_values[1].as_str().unwrap(), "inactive-user"); + } + + #[test] + fn test_parse_enum_to_schema_rename_all_with_other_attrs_data() { + // Test rename_all combined with other serde attributes for data variants + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "camelCase", deny_unknown_fields)] + enum Event { + UserCreated { user_name: String, created_at: i64 }, + UserDeleted(i32), + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.one_of.expect("one_of missing"); + + // Check UserCreated variant key is camelCase + let variant_obj = match &one_of[0] { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline schema"), + }; + let props = variant_obj + .properties + .as_ref() + .expect("variant props missing"); + assert!(props.contains_key("userCreated")); + assert!(!props.contains_key("UserCreated")); + assert!(!props.contains_key("user_created")); + + // Check UserDeleted variant key is camelCase + let variant_obj2 = match &one_of[1] { + SchemaRef::Inline(s) => s, + _ => panic!("Expected inline schema"), + }; + let props2 = variant_obj2 + .properties + .as_ref() + .expect("variant props missing"); + assert!(props2.contains_key("userDeleted")); + } + + #[test] + fn test_parse_enum_to_schema_rename_all_not_first_attr() { + // Test rename_all when it's not the first attribute + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(default, rename_all = "SCREAMING_SNAKE_CASE")] + enum Priority { + HighPriority, + LowPriority, + } + "#, + ) + .unwrap(); + + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let enum_values = schema.r#enum.expect("enum values missing"); + assert_eq!(enum_values[0].as_str().unwrap(), "HIGH_PRIORITY"); + assert_eq!(enum_values[1].as_str().unwrap(), "LOW_PRIORITY"); + } + #[test] fn test_parse_struct_to_schema_required_optional() { let struct_item: syn::ItemStruct = syn::parse_str( @@ -1456,13 +1577,20 @@ mod tests { } #[rstest] - // camelCase tests + // camelCase tests (snake_case input) #[case("user_name", Some("camelCase"), "userName")] #[case("first_name", Some("camelCase"), "firstName")] #[case("last_name", Some("camelCase"), "lastName")] #[case("user_id", Some("camelCase"), "userId")] #[case("api_key", Some("camelCase"), "apiKey")] #[case("already_camel", Some("camelCase"), "alreadyCamel")] + // camelCase tests (PascalCase input) + #[case("UserName", Some("camelCase"), "userName")] + #[case("UserCreated", Some("camelCase"), "userCreated")] + #[case("FirstName", Some("camelCase"), "firstName")] + #[case("ID", Some("camelCase"), "id")] + #[case("XMLParser", Some("camelCase"), "xmlParser")] + #[case("HTTPSConnection", Some("camelCase"), "httpsConnection")] // snake_case tests #[case("userName", Some("snake_case"), "user_name")] #[case("firstName", Some("snake_case"), "first_name")] @@ -1524,4 +1652,262 @@ mod tests { ) { assert_eq!(rename_field(field_name, rename_all), expected); } + + #[rstest] + #[case(r#"#[serde(rename_all = "camelCase")] struct Foo;"#, Some("camelCase"))] + #[case( + r#"#[serde(rename_all = "snake_case")] struct Foo;"#, + Some("snake_case") + )] + #[case( + r#"#[serde(rename_all = "kebab-case")] struct Foo;"#, + Some("kebab-case") + )] + #[case( + r#"#[serde(rename_all = "PascalCase")] struct Foo;"#, + Some("PascalCase") + )] + // Multiple attributes - this is the bug case + #[case( + r#"#[serde(rename_all = "camelCase", default)] struct Foo;"#, + Some("camelCase") + )] + #[case( + r#"#[serde(default, rename_all = "snake_case")] struct Foo;"#, + Some("snake_case") + )] + #[case(r#"#[serde(rename_all = "kebab-case", skip_serializing_if = "Option::is_none")] struct Foo;"#, Some("kebab-case"))] + // No rename_all + #[case(r#"#[serde(default)] struct Foo;"#, None)] + #[case(r#"#[derive(Debug)] struct Foo;"#, None)] + fn test_extract_rename_all(#[case] item_src: &str, #[case] expected: Option<&str>) { + let item: syn::ItemStruct = syn::parse_str(item_src).unwrap(); + let result = extract_rename_all(&item.attrs); + assert_eq!(result.as_deref(), expected); + } + + #[test] + fn test_extract_rename_all_enum_with_deny_unknown_fields() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + #[serde(rename_all = "camelCase", deny_unknown_fields)] + enum Foo { A, B } + "#, + ) + .unwrap(); + let result = extract_rename_all(&enum_item.attrs); + assert_eq!(result.as_deref(), Some("camelCase")); + } + + // Tests for extract_field_rename function + #[rstest] + #[case(r#"#[serde(rename = "custom_name")] field: i32"#, Some("custom_name"))] + #[case(r#"#[serde(rename = "userId")] field: i32"#, Some("userId"))] + #[case(r#"#[serde(rename = "ID")] field: i32"#, Some("ID"))] + #[case(r#"#[serde(default)] field: i32"#, None)] + #[case(r#"#[serde(skip)] field: i32"#, None)] + #[case(r#"field: i32"#, None)] + // rename_all should NOT be extracted as rename + #[case(r#"#[serde(rename_all = "camelCase")] field: i32"#, None)] + // Multiple attributes + #[case(r#"#[serde(rename = "custom", default)] field: i32"#, Some("custom"))] + #[case( + r#"#[serde(default, rename = "my_field")] field: i32"#, + Some("my_field") + )] + fn test_extract_field_rename(#[case] field_src: &str, #[case] expected: Option<&str>) { + // Parse field from struct context + let struct_src = format!("struct Foo {{ {} }}", field_src); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_field_rename(&field.attrs); + assert_eq!(result.as_deref(), expected, "Failed for: {}", field_src); + } + } + + // Tests for extract_skip function + #[rstest] + #[case(r#"#[serde(skip)] field: i32"#, true)] + #[case(r#"#[serde(default)] field: i32"#, false)] + #[case(r#"#[serde(rename = "x")] field: i32"#, false)] + #[case(r#"field: i32"#, false)] + // skip_serializing_if should NOT be treated as skip + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, + false + )] + // skip_deserializing should NOT be treated as skip + #[case(r#"#[serde(skip_deserializing)] field: i32"#, false)] + // Combined attributes + #[case(r#"#[serde(skip, default)] field: i32"#, true)] + #[case(r#"#[serde(default, skip)] field: i32"#, true)] + fn test_extract_skip(#[case] field_src: &str, #[case] expected: bool) { + let struct_src = format!("struct Foo {{ {} }}", field_src); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_skip(&field.attrs); + assert_eq!(result, expected, "Failed for: {}", field_src); + } + } + + // Tests for extract_skip_serializing_if function + #[rstest] + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#, + true + )] + #[case(r#"#[serde(skip_serializing_if = "is_zero")] field: i32"#, true)] + #[case(r#"#[serde(default)] field: i32"#, false)] + #[case(r#"#[serde(skip)] field: i32"#, false)] + #[case(r#"field: i32"#, false)] + fn test_extract_skip_serializing_if(#[case] field_src: &str, #[case] expected: bool) { + let struct_src = format!("struct Foo {{ {} }}", field_src); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_skip_serializing_if(&field.attrs); + assert_eq!(result, expected, "Failed for: {}", field_src); + } + } + + // Tests for extract_default function + #[rstest] + // Simple default (no function) + #[case(r#"#[serde(default)] field: i32"#, Some(None))] + // Default with function name + #[case( + r#"#[serde(default = "default_value")] field: i32"#, + Some(Some("default_value")) + )] + #[case( + r#"#[serde(default = "Default::default")] field: i32"#, + Some(Some("Default::default")) + )] + // No default + #[case(r#"#[serde(skip)] field: i32"#, None)] + #[case(r#"#[serde(rename = "x")] field: i32"#, None)] + #[case(r#"field: i32"#, None)] + // Combined attributes + #[case( + r#"#[serde(default, skip_serializing_if = "Option::is_none")] field: i32"#, + Some(None) + )] + #[case( + r#"#[serde(skip_serializing_if = "Option::is_none", default = "my_default")] field: i32"#, + Some(Some("my_default")) + )] + fn test_extract_default(#[case] field_src: &str, #[case] expected: Option>) { + let struct_src = format!("struct Foo {{ {} }}", field_src); + let item: syn::ItemStruct = syn::parse_str(&struct_src).unwrap(); + if let syn::Fields::Named(fields) = &item.fields { + let field = fields.named.first().unwrap(); + let result = extract_default(&field.attrs); + let expected_owned = expected.map(|o| o.map(|s| s.to_string())); + assert_eq!(result, expected_owned, "Failed for: {}", field_src); + } + } + + // Test struct with skip field + #[test] + fn test_parse_struct_to_schema_with_skip_field() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + struct User { + id: i32, + #[serde(skip)] + internal_data: String, + name: String, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new()); + let props = schema.properties.as_ref().unwrap(); + assert!(props.contains_key("id")); + assert!(props.contains_key("name")); + assert!(!props.contains_key("internal_data")); // Should be skipped + } + + // Test struct with default and skip_serializing_if + #[test] + fn test_parse_struct_to_schema_with_default_fields() { + let struct_item: syn::ItemStruct = syn::parse_str( + r#" + struct Config { + required_field: i32, + #[serde(default)] + optional_with_default: String, + #[serde(skip_serializing_if = "Option::is_none")] + maybe_skip: Option, + } + "#, + ) + .unwrap(); + let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new()); + let props = schema.properties.as_ref().unwrap(); + assert!(props.contains_key("required_field")); + assert!(props.contains_key("optional_with_default")); + assert!(props.contains_key("maybe_skip")); + + let required = schema.required.as_ref().unwrap(); + assert!(required.contains(&"required_field".to_string())); + // Fields with default should NOT be required + assert!(!required.contains(&"optional_with_default".to_string())); + // Fields with skip_serializing_if should NOT be required + assert!(!required.contains(&"maybe_skip".to_string())); + } + + // Test BTreeMap type + #[test] + fn test_parse_type_to_schema_ref_btreemap() { + let ty: Type = syn::parse_str("BTreeMap").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)); + assert!(schema.additional_properties.is_some()); + } else { + panic!("Expected inline schema for BTreeMap"); + } + } + + // Test Vec without inner type (edge case) + #[test] + fn test_parse_type_to_schema_ref_vec_without_args() { + let ty: Type = syn::parse_str("Vec").unwrap(); + let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new()); + // Vec without angle brackets should return object schema + assert!(matches!(schema_ref, SchemaRef::Inline(_))); + } + + // Test enum with empty variants (edge case) + #[test] + fn test_parse_enum_to_schema_empty_enum() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + enum Empty {} + "#, + ) + .unwrap(); + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + // Empty enum should have no enum values + assert!(schema.r#enum.is_none() || schema.r#enum.as_ref().unwrap().is_empty()); + } + + // Test enum with all struct variants having empty properties + #[test] + fn test_parse_enum_to_schema_struct_variant_no_fields() { + let enum_item: syn::ItemEnum = syn::parse_str( + r#" + enum Event { + Empty {}, + } + "#, + ) + .unwrap(); + let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new()); + let one_of = schema.one_of.expect("one_of missing"); + assert_eq!(one_of.len(), 1); + } } diff --git a/crates/vespera_macro/src/route/utils.rs b/crates/vespera_macro/src/route/utils.rs index 4ad55a2..c6b4e6c 100644 --- a/crates/vespera_macro/src/route/utils.rs +++ b/crates/vespera_macro/src/route/utils.rs @@ -1,332 +1,474 @@ -use crate::args::RouteArgs; - -/// Extract doc comments from attributes -/// Returns concatenated doc comment string or None if no doc comments -pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { - let mut doc_lines = Vec::new(); - - for attr in attrs { - if attr.path().is_ident("doc") - && let syn::Meta::NameValue(meta_nv) = &attr.meta - && let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = &meta_nv.value - { - let line = lit_str.value(); - // Trim leading space that rustdoc adds - let trimmed = line.strip_prefix(' ').unwrap_or(&line); - doc_lines.push(trimmed.to_string()); - } - } - - if doc_lines.is_empty() { - None - } else { - Some(doc_lines.join("\n")) - } -} - -#[derive(Debug)] -pub struct RouteInfo { - pub method: String, - pub path: Option, - pub error_status: Option>, - pub tags: Option>, - pub description: Option, -} - -pub fn check_route_by_meta(meta: &syn::Meta) -> bool { - match meta { - syn::Meta::List(meta_list) => { - (meta_list.path.segments.len() == 2 - && meta_list.path.segments[0].ident == "vespera" - && meta_list.path.segments[1].ident == "route") - || (meta_list.path.segments.len() == 1 - && meta_list.path.segments[0].ident == "route") - } - syn::Meta::Path(path) => { - (path.segments.len() == 2 - && path.segments[0].ident == "vespera" - && path.segments[1].ident == "route") - || (path.segments.len() == 1 && path.segments[0].ident == "route") - } - syn::Meta::NameValue(meta_nv) => { - (meta_nv.path.segments.len() == 2 - && meta_nv.path.segments[0].ident == "vespera" - && meta_nv.path.segments[1].ident == "route") - || (meta_nv.path.segments.len() == 1 && meta_nv.path.segments[0].ident == "route") - } - } -} - -pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { - for attr in attrs { - // Check if attribute path is "vespera" or "route" - if check_route_by_meta(&attr.meta) { - match &attr.meta { - syn::Meta::List(meta_list) => { - // Try to parse as RouteArgs - if let Ok(route_args) = meta_list.parse_args::() { - let method = route_args - .method - .as_ref() - .map(syn::Ident::to_string) - .unwrap_or_else(|| "get".to_string()); - let path = route_args.path.as_ref().map(syn::LitStr::value); - - // Parse error_status array if present - let error_status = route_args.error_status.as_ref().and_then(|array| { - 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); - } - } - if status_codes.is_empty() { - None - } else { - Some(status_codes) - } - }); - - // Parse tags array if present - let tags = route_args.tags.as_ref().and_then(|array| { - let mut tag_list = Vec::new(); - for elem in &array.elems { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = elem - { - tag_list.push(lit_str.value()); - } - } - if tag_list.is_empty() { - None - } else { - Some(tag_list) - } - }); - - // Parse description if present - let description = route_args.description.as_ref().map(|s| s.value()); - - return Some(RouteInfo { - method, - path, - error_status, - tags, - description, - }); - } - } - // Try to parse as Meta::NameValue (e.g., #[route = "patch"]) - syn::Meta::NameValue(meta_nv) => { - if let syn::Expr::Lit(syn::ExprLit { - lit: syn::Lit::Str(lit_str), - .. - }) = &meta_nv.value - { - let method_str = lit_str.value().to_lowercase(); - if method_str == "get" - || method_str == "post" - || method_str == "put" - || method_str == "patch" - || method_str == "delete" - || method_str == "head" - || method_str == "options" - { - return Some(RouteInfo { - method: method_str, - path: None, - error_status: None, - tags: None, - description: None, - }); - } - } - } - // Try to parse as Meta::Path (e.g., #[route]) - syn::Meta::Path(_) => { - return Some(RouteInfo { - method: "get".to_string(), - path: None, - error_status: None, - tags: None, - description: None, - }); - } - } - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use rstest::rstest; - - fn parse_meta_from_attr(attr_str: &str) -> syn::Meta { - // Parse attribute from string like "#[route()]" or "#[vespera::route(get)]" - let full_code = format!("{} fn test() {{}}", attr_str); - let file: syn::File = syn::parse_str(&full_code).expect("Failed to parse with attribute"); - - // Extract the first attribute from the function - if let Some(syn::Item::Fn(fn_item)) = file.items.first() - && let Some(attr) = fn_item.attrs.first() - { - return attr.meta.clone(); - } - - panic!("Failed to extract meta from attribute: {}", attr_str); - } - - #[rstest] - // Valid route attributes (List meta) - #[case("#[route()]", true)] - #[case("#[vespera::route()]", true)] - #[case("#[route(get)]", true)] - #[case("#[vespera::route(get)]", true)] - #[case("#[route(post)]", true)] - #[case("#[vespera::route(post)]", true)] - #[case("#[route(get, path = \"/api\")]", true)] - #[case("#[vespera::route(get, path = \"/api\")]", true)] - // Path meta (without parentheses) should return true - #[case("#[route]", true)] - #[case("#[vespera::route]", true)] - // NameValue meta should return true - #[case("#[route = \"get\"]", true)] - #[case("#[vespera::route = \"get\"]", true)] - // Invalid route attributes - #[case("#[other()]", false)] - #[case("#[vespera::other()]", false)] - #[case("#[other(get)]", false)] - #[case("#[vespera::other(get)]", false)] - #[case("#[derive(Schema)]", false)] - #[case("#[serde(rename_all = \"camelCase\")]", false)] - #[case("#[test]", false)] - // Nested paths with more than 2 segments should return false - #[case("#[vespera::route::something]", false)] - #[case("#[vespera::route::something()]", false)] - fn test_check_route_by_meta(#[case] attr_str: &str, #[case] expected: bool) { - let meta = parse_meta_from_attr(attr_str); - let result = check_route_by_meta(&meta); - assert_eq!( - result, expected, - "Failed for attribute: {}, expected: {}", - attr_str, expected - ); - } - - fn parse_attrs_from_code(code: &str) -> Vec { - let file: syn::File = syn::parse_str(code).expect("Failed to parse code"); - if let Some(syn::Item::Fn(fn_item)) = file.items.first() { - return fn_item.attrs.clone(); - } - vec![] - } - - #[rstest] - // Route with method only - #[case("#[route(get)] fn test() {}", Some(("get".to_string(), None, None)))] - #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] - #[case("#[route(put)] fn test() {}", Some(("put".to_string(), None, None)))] - #[case("#[route(patch)] fn test() {}", Some(("patch".to_string(), None, None)))] - #[case("#[route(delete)] fn test() {}", Some(("delete".to_string(), None, None)))] - #[case("#[route(head)] fn test() {}", Some(("head".to_string(), None, None)))] - #[case("#[route(options)] fn test() {}", Some(("options".to_string(), None, None)))] - #[case("#[vespera::route(get)] fn test() {}", Some(("get".to_string(), None, None)))] - // Route with method and path - #[case("#[route(get, path = \"/api\")] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))] - #[case("#[route(post, path = \"/users\")] fn test() {}", Some(("post".to_string(), Some("/users".to_string()), None)))] - #[case("#[route(get, path = \"/api/v1\")] fn test() {}", Some(("get".to_string(), Some("/api/v1".to_string()), None)))] - // Route with method and error_status - #[case("#[route(get, error_status = [400])] fn test() {}", Some(("get".to_string(), None, Some(vec![400]))))] - #[case("#[route(get, error_status = [400, 404])] fn test() {}", Some(("get".to_string(), None, Some(vec![400, 404]))))] - #[case("#[route(get, error_status = [400, 404, 500])] fn test() {}", Some(("get".to_string(), None, Some(vec![400, 404, 500]))))] - // Route with method, path, and error_status - #[case("#[route(get, path = \"/api\", error_status = [400])] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), Some(vec![400]))))] - #[case("#[route(post, path = \"/users\", error_status = [400, 404])] fn test() {}", Some(("post".to_string(), Some("/users".to_string()), Some(vec![400, 404]))))] - // Route without method (defaults to "get") - #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] - #[case("#[route(path = \"/api\")] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))] - // Route with Path meta (e.g., #[route]) - #[case("#[route] fn test() {}", Some(("get".to_string(), None, None)))] - #[case("#[vespera::route] fn test() {}", Some(("get".to_string(), None, None)))] - // Route with empty error_status array (should return None for error_status) - #[case("#[route(get, error_status = [])] fn test() {}", Some(("get".to_string(), None, None)))] - // NameValue format (should work now) - #[case("#[route = \"get\"] fn test() {}", Some(("get".to_string(), None, None)))] - #[case("#[route = \"post\"] fn test() {}", Some(("post".to_string(), None, None)))] - #[case("#[route = \"put\"] fn test() {}", Some(("put".to_string(), None, None)))] - #[case("#[route = \"patch\"] fn test() {}", Some(("patch".to_string(), None, None)))] - #[case("#[route = \"delete\"] fn test() {}", Some(("delete".to_string(), None, None)))] - #[case("#[route = \"head\"] fn test() {}", Some(("head".to_string(), None, None)))] - #[case("#[route = \"options\"] fn test() {}", Some(("options".to_string(), None, None)))] - #[case("#[vespera::route = \"get\"] fn test() {}", Some(("get".to_string(), None, None)))] - // Invalid cases (should return None) - #[case("#[other(get)] fn test() {}", None)] - #[case("#[derive(Schema)] fn test() {}", None)] - #[case("#[test] fn test() {}", None)] - #[case("fn test() {}", None)] - // Invalid method in NameValue format - #[case("#[route = \"invalid\"] fn test() {}", None)] - #[case("#[route = \"GET\"] fn test() {}", Some(("get".to_string(), None, None)))] // lowercase conversion - // Multiple attributes - should find route attribute - #[case("#[derive(Debug)] #[route(get, path = \"/api\")] #[test] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))] - // Multiple route attributes - first one wins - #[case("#[route(get, path = \"/first\")] #[route(post, path = \"/second\")] fn test() {}", Some(("get".to_string(), Some("/first".to_string()), None)))] - // Explicit tests for method.as_ref() and path.as_ref().map() coverage - #[case("#[route(path = \"/test\")] fn test() {}", Some(("get".to_string(), Some("/test".to_string()), None)))] // method None, path Some - #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] // method None, path None - #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] // method Some, path None - #[case("#[route(put, path = \"/test\")] fn test() {}", Some(("put".to_string(), Some("/test".to_string()), None)))] // method Some, path Some - fn test_extract_route_info( - #[case] code: &str, - #[case] expected: Option<(String, Option, Option>)>, - ) { - let attrs = parse_attrs_from_code(code); - let result = extract_route_info(&attrs); - - match expected { - Some((exp_method, exp_path, exp_error_status)) => { - assert!( - result.is_some(), - "Expected Some but got None for code: {}", - code - ); - let route_info = result.unwrap(); - assert_eq!( - route_info.method, exp_method, - "Method mismatch for code: {}", - code - ); - assert_eq!( - route_info.path, exp_path, - "Path mismatch for code: {}", - code - ); - assert_eq!( - route_info.error_status, exp_error_status, - "Error status mismatch for code: {}", - code - ); - } - None => { - assert!( - result.is_none(), - "Expected None but got Some({:?}) for code: {}", - result, - code - ); - } - } - } -} +use crate::args::RouteArgs; + +/// Extract doc comments from attributes +/// Returns concatenated doc comment string or None if no doc comments +pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option { + let mut doc_lines = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("doc") + && let syn::Meta::NameValue(meta_nv) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta_nv.value + { + let line = lit_str.value(); + // Trim leading space that rustdoc adds + let trimmed = line.strip_prefix(' ').unwrap_or(&line); + doc_lines.push(trimmed.to_string()); + } + } + + if doc_lines.is_empty() { + None + } else { + Some(doc_lines.join("\n")) + } +} + +#[derive(Debug)] +pub struct RouteInfo { + pub method: String, + pub path: Option, + pub error_status: Option>, + pub tags: Option>, + pub description: Option, +} + +pub fn check_route_by_meta(meta: &syn::Meta) -> bool { + match meta { + syn::Meta::List(meta_list) => { + (meta_list.path.segments.len() == 2 + && meta_list.path.segments[0].ident == "vespera" + && meta_list.path.segments[1].ident == "route") + || (meta_list.path.segments.len() == 1 + && meta_list.path.segments[0].ident == "route") + } + syn::Meta::Path(path) => { + (path.segments.len() == 2 + && path.segments[0].ident == "vespera" + && path.segments[1].ident == "route") + || (path.segments.len() == 1 && path.segments[0].ident == "route") + } + syn::Meta::NameValue(meta_nv) => { + (meta_nv.path.segments.len() == 2 + && meta_nv.path.segments[0].ident == "vespera" + && meta_nv.path.segments[1].ident == "route") + || (meta_nv.path.segments.len() == 1 && meta_nv.path.segments[0].ident == "route") + } + } +} + +pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option { + for attr in attrs { + // Check if attribute path is "vespera" or "route" + if check_route_by_meta(&attr.meta) { + match &attr.meta { + syn::Meta::List(meta_list) => { + // Try to parse as RouteArgs + if let Ok(route_args) = meta_list.parse_args::() { + let method = route_args + .method + .as_ref() + .map(syn::Ident::to_string) + .unwrap_or_else(|| "get".to_string()); + let path = route_args.path.as_ref().map(syn::LitStr::value); + + // Parse error_status array if present + let error_status = route_args.error_status.as_ref().and_then(|array| { + 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); + } + } + if status_codes.is_empty() { + None + } else { + Some(status_codes) + } + }); + + // Parse tags array if present + let tags = route_args.tags.as_ref().and_then(|array| { + let mut tag_list = Vec::new(); + for elem in &array.elems { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = elem + { + tag_list.push(lit_str.value()); + } + } + if tag_list.is_empty() { + None + } else { + Some(tag_list) + } + }); + + // Parse description if present + let description = route_args.description.as_ref().map(|s| s.value()); + + return Some(RouteInfo { + method, + path, + error_status, + tags, + description, + }); + } + } + // Try to parse as Meta::NameValue (e.g., #[route = "patch"]) + syn::Meta::NameValue(meta_nv) => { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(lit_str), + .. + }) = &meta_nv.value + { + let method_str = lit_str.value().to_lowercase(); + if method_str == "get" + || method_str == "post" + || method_str == "put" + || method_str == "patch" + || method_str == "delete" + || method_str == "head" + || method_str == "options" + { + return Some(RouteInfo { + method: method_str, + path: None, + error_status: None, + tags: None, + description: None, + }); + } + } + } + // Try to parse as Meta::Path (e.g., #[route]) + syn::Meta::Path(_) => { + return Some(RouteInfo { + method: "get".to_string(), + path: None, + error_status: None, + tags: None, + description: None, + }); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + fn parse_meta_from_attr(attr_str: &str) -> syn::Meta { + // Parse attribute from string like "#[route()]" or "#[vespera::route(get)]" + let full_code = format!("{} fn test() {{}}", attr_str); + let file: syn::File = syn::parse_str(&full_code).expect("Failed to parse with attribute"); + + // Extract the first attribute from the function + if let Some(syn::Item::Fn(fn_item)) = file.items.first() + && let Some(attr) = fn_item.attrs.first() + { + return attr.meta.clone(); + } + + panic!("Failed to extract meta from attribute: {}", attr_str); + } + + #[rstest] + // Valid route attributes (List meta) + #[case("#[route()]", true)] + #[case("#[vespera::route()]", true)] + #[case("#[route(get)]", true)] + #[case("#[vespera::route(get)]", true)] + #[case("#[route(post)]", true)] + #[case("#[vespera::route(post)]", true)] + #[case("#[route(get, path = \"/api\")]", true)] + #[case("#[vespera::route(get, path = \"/api\")]", true)] + // Path meta (without parentheses) should return true + #[case("#[route]", true)] + #[case("#[vespera::route]", true)] + // NameValue meta should return true + #[case("#[route = \"get\"]", true)] + #[case("#[vespera::route = \"get\"]", true)] + // Invalid route attributes + #[case("#[other()]", false)] + #[case("#[vespera::other()]", false)] + #[case("#[other(get)]", false)] + #[case("#[vespera::other(get)]", false)] + #[case("#[derive(Schema)]", false)] + #[case("#[serde(rename_all = \"camelCase\")]", false)] + #[case("#[test]", false)] + // Nested paths with more than 2 segments should return false + #[case("#[vespera::route::something]", false)] + #[case("#[vespera::route::something()]", false)] + fn test_check_route_by_meta(#[case] attr_str: &str, #[case] expected: bool) { + let meta = parse_meta_from_attr(attr_str); + let result = check_route_by_meta(&meta); + assert_eq!( + result, expected, + "Failed for attribute: {}, expected: {}", + attr_str, expected + ); + } + + fn parse_attrs_from_code(code: &str) -> Vec { + let file: syn::File = syn::parse_str(code).expect("Failed to parse code"); + if let Some(syn::Item::Fn(fn_item)) = file.items.first() { + return fn_item.attrs.clone(); + } + vec![] + } + + #[rstest] + // Route with method only + #[case("#[route(get)] fn test() {}", Some(("get".to_string(), None, None)))] + #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] + #[case("#[route(put)] fn test() {}", Some(("put".to_string(), None, None)))] + #[case("#[route(patch)] fn test() {}", Some(("patch".to_string(), None, None)))] + #[case("#[route(delete)] fn test() {}", Some(("delete".to_string(), None, None)))] + #[case("#[route(head)] fn test() {}", Some(("head".to_string(), None, None)))] + #[case("#[route(options)] fn test() {}", Some(("options".to_string(), None, None)))] + #[case("#[vespera::route(get)] fn test() {}", Some(("get".to_string(), None, None)))] + // Route with method and path + #[case("#[route(get, path = \"/api\")] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))] + #[case("#[route(post, path = \"/users\")] fn test() {}", Some(("post".to_string(), Some("/users".to_string()), None)))] + #[case("#[route(get, path = \"/api/v1\")] fn test() {}", Some(("get".to_string(), Some("/api/v1".to_string()), None)))] + // Route with method and error_status + #[case("#[route(get, error_status = [400])] fn test() {}", Some(("get".to_string(), None, Some(vec![400]))))] + #[case("#[route(get, error_status = [400, 404])] fn test() {}", Some(("get".to_string(), None, Some(vec![400, 404]))))] + #[case("#[route(get, error_status = [400, 404, 500])] fn test() {}", Some(("get".to_string(), None, Some(vec![400, 404, 500]))))] + // Route with method, path, and error_status + #[case("#[route(get, path = \"/api\", error_status = [400])] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), Some(vec![400]))))] + #[case("#[route(post, path = \"/users\", error_status = [400, 404])] fn test() {}", Some(("post".to_string(), Some("/users".to_string()), Some(vec![400, 404]))))] + // Route without method (defaults to "get") + #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] + #[case("#[route(path = \"/api\")] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))] + // Route with Path meta (e.g., #[route]) + #[case("#[route] fn test() {}", Some(("get".to_string(), None, None)))] + #[case("#[vespera::route] fn test() {}", Some(("get".to_string(), None, None)))] + // Route with empty error_status array (should return None for error_status) + #[case("#[route(get, error_status = [])] fn test() {}", Some(("get".to_string(), None, None)))] + // NameValue format (should work now) + #[case("#[route = \"get\"] fn test() {}", Some(("get".to_string(), None, None)))] + #[case("#[route = \"post\"] fn test() {}", Some(("post".to_string(), None, None)))] + #[case("#[route = \"put\"] fn test() {}", Some(("put".to_string(), None, None)))] + #[case("#[route = \"patch\"] fn test() {}", Some(("patch".to_string(), None, None)))] + #[case("#[route = \"delete\"] fn test() {}", Some(("delete".to_string(), None, None)))] + #[case("#[route = \"head\"] fn test() {}", Some(("head".to_string(), None, None)))] + #[case("#[route = \"options\"] fn test() {}", Some(("options".to_string(), None, None)))] + #[case("#[vespera::route = \"get\"] fn test() {}", Some(("get".to_string(), None, None)))] + // Invalid cases (should return None) + #[case("#[other(get)] fn test() {}", None)] + #[case("#[derive(Schema)] fn test() {}", None)] + #[case("#[test] fn test() {}", None)] + #[case("fn test() {}", None)] + // Invalid method in NameValue format + #[case("#[route = \"invalid\"] fn test() {}", None)] + #[case("#[route = \"GET\"] fn test() {}", Some(("get".to_string(), None, None)))] // lowercase conversion + // Multiple attributes - should find route attribute + #[case("#[derive(Debug)] #[route(get, path = \"/api\")] #[test] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))] + // Multiple route attributes - first one wins + #[case("#[route(get, path = \"/first\")] #[route(post, path = \"/second\")] fn test() {}", Some(("get".to_string(), Some("/first".to_string()), None)))] + // Explicit tests for method.as_ref() and path.as_ref().map() coverage + #[case("#[route(path = \"/test\")] fn test() {}", Some(("get".to_string(), Some("/test".to_string()), None)))] // method None, path Some + #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] // method None, path None + #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] // method Some, path None + #[case("#[route(put, path = \"/test\")] fn test() {}", Some(("put".to_string(), Some("/test".to_string()), None)))] // method Some, path Some + fn test_extract_route_info( + #[case] code: &str, + #[case] expected: Option<(String, Option, Option>)>, + ) { + let attrs = parse_attrs_from_code(code); + let result = extract_route_info(&attrs); + + match expected { + Some((exp_method, exp_path, exp_error_status)) => { + assert!( + result.is_some(), + "Expected Some but got None for code: {}", + code + ); + let route_info = result.unwrap(); + assert_eq!( + route_info.method, exp_method, + "Method mismatch for code: {}", + code + ); + assert_eq!( + route_info.path, exp_path, + "Path mismatch for code: {}", + code + ); + assert_eq!( + route_info.error_status, exp_error_status, + "Error status mismatch for code: {}", + code + ); + } + None => { + assert!( + result.is_none(), + "Expected None but got Some({:?}) for code: {}", + result, + code + ); + } + } + } + + // Tests for extract_doc_comment function + #[test] + fn test_extract_doc_comment_single_line() { + let code = r#" + /// This is a doc comment + fn test() {} + "#; + let file: syn::File = syn::parse_str(code).unwrap(); + if let Some(syn::Item::Fn(fn_item)) = file.items.first() { + let doc = extract_doc_comment(&fn_item.attrs); + assert_eq!(doc, Some("This is a doc comment".to_string())); + } + } + + #[test] + fn test_extract_doc_comment_multi_line() { + let code = r#" + /// First line + /// Second line + /// Third line + fn test() {} + "#; + let file: syn::File = syn::parse_str(code).unwrap(); + if let Some(syn::Item::Fn(fn_item)) = file.items.first() { + let doc = extract_doc_comment(&fn_item.attrs); + assert_eq!(doc, Some("First line\nSecond line\nThird line".to_string())); + } + } + + #[test] + fn test_extract_doc_comment_empty() { + let code = "fn test() {}"; + let file: syn::File = syn::parse_str(code).unwrap(); + if let Some(syn::Item::Fn(fn_item)) = file.items.first() { + let doc = extract_doc_comment(&fn_item.attrs); + assert_eq!(doc, None); + } + } + + #[test] + fn test_extract_doc_comment_with_other_attrs() { + let code = r#" + #[inline] + /// Doc comment + #[test] + fn test() {} + "#; + let file: syn::File = syn::parse_str(code).unwrap(); + if let Some(syn::Item::Fn(fn_item)) = file.items.first() { + let doc = extract_doc_comment(&fn_item.attrs); + assert_eq!(doc, Some("Doc comment".to_string())); + } + } + + #[test] + fn test_extract_doc_comment_no_leading_space() { + let code = r#" + ///No leading space + fn test() {} + "#; + let file: syn::File = syn::parse_str(code).unwrap(); + if let Some(syn::Item::Fn(fn_item)) = file.items.first() { + let doc = extract_doc_comment(&fn_item.attrs); + assert_eq!(doc, Some("No leading space".to_string())); + } + } + + // Tests for tags and description in extract_route_info + #[test] + fn test_extract_route_info_with_tags() { + let code = r#"#[route(get, tags = ["users", "admin"])] fn test() {}"#; + let attrs = parse_attrs_from_code(code); + let result = extract_route_info(&attrs); + assert!(result.is_some()); + let route_info = result.unwrap(); + assert_eq!( + route_info.tags, + Some(vec!["users".to_string(), "admin".to_string()]) + ); + } + + #[test] + fn test_extract_route_info_with_single_tag() { + let code = r#"#[route(get, tags = ["users"])] fn test() {}"#; + let attrs = parse_attrs_from_code(code); + let result = extract_route_info(&attrs); + assert!(result.is_some()); + let route_info = result.unwrap(); + assert_eq!(route_info.tags, Some(vec!["users".to_string()])); + } + + #[test] + fn test_extract_route_info_with_empty_tags() { + let code = r#"#[route(get, tags = [])] fn test() {}"#; + let attrs = parse_attrs_from_code(code); + let result = extract_route_info(&attrs); + assert!(result.is_some()); + let route_info = result.unwrap(); + assert_eq!(route_info.tags, None); // Empty array should return None + } + + #[test] + fn test_extract_route_info_with_description() { + let code = r#"#[route(get, description = "Get all users")] fn test() {}"#; + let attrs = parse_attrs_from_code(code); + let result = extract_route_info(&attrs); + assert!(result.is_some()); + let route_info = result.unwrap(); + assert_eq!(route_info.description, Some("Get all users".to_string())); + } + + #[test] + fn test_extract_route_info_with_tags_and_description() { + let code = r#"#[route(get, tags = ["users"], description = "Get users")] fn test() {}"#; + let attrs = parse_attrs_from_code(code); + let result = extract_route_info(&attrs); + assert!(result.is_some()); + let route_info = result.unwrap(); + assert_eq!(route_info.tags, Some(vec!["users".to_string()])); + assert_eq!(route_info.description, Some("Get users".to_string())); + } + + #[test] + fn test_extract_route_info_all_options() { + let code = r#"#[route(post, path = "/api/users", error_status = [400, 404], tags = ["users", "api"], description = "Create a new user")] fn test() {}"#; + let attrs = parse_attrs_from_code(code); + let result = extract_route_info(&attrs); + assert!(result.is_some()); + let route_info = result.unwrap(); + assert_eq!(route_info.method, "post"); + assert_eq!(route_info.path, Some("/api/users".to_string())); + assert_eq!(route_info.error_status, Some(vec![400, 404])); + assert_eq!( + route_info.tags, + Some(vec!["users".to_string(), "api".to_string()]) + ); + assert_eq!( + route_info.description, + Some("Create a new user".to_string()) + ); + } +}