use std::collections::{BTreeMap, HashMap};
use syn::{Fields, Type};
use vespera_core::schema::{Reference, Schema, SchemaRef, SchemaType};
pub fn extract_doc_comment(attrs: &[syn::Attribute]) -> Option<String> {
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();
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"))
}
}
pub fn strip_raw_prefix(ident: &str) -> &str {
ident.strip_prefix("r#").unwrap_or(ident)
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
fn extract_schema_name_from_entity(ty: &Type) -> Option<String> {
match ty {
Type::Path(type_path) => {
let segments: Vec<_> = type_path.path.segments.iter().collect();
if segments.len() < 2 {
return None;
}
let last = segments.last()?;
if last.ident != "Entity" {
return None;
}
let module_segment = segments.get(segments.len() - 2)?;
let module_name = module_segment.ident.to_string();
let schema_name = capitalize_first(&module_name);
Some(schema_name)
}
_ => None,
}
}
pub fn extract_rename_all(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("serde") {
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::<syn::Expr>()
{
found_rename_all = Some(s.value());
}
Ok(())
});
if found_rename_all.is_some() {
return found_rename_all;
}
let tokens = match attr.meta.require_list() {
Ok(t) => t,
Err(_) => continue,
};
let token_str = tokens.tokens.to_string();
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();
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());
}
}
}
}
}
}
None
}
pub fn extract_field_rename(attrs: &[syn::Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("serde")
&& let syn::Meta::List(meta_list) = &attr.meta
{
let mut found_rename = None;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("rename")
&& let Ok(value) = meta.value()
&& let Ok(syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
})) = value.parse::<syn::Expr>()
{
found_rename = Some(s.value());
}
Ok(())
});
if let Some(rename_value) = found_rename {
return Some(rename_value);
}
let tokens = meta_list.tokens.to_string();
if let Some(start) = tokens.find("rename") {
if tokens[start..].starts_with("rename_all") {
continue;
}
let before = if start > 0 { &tokens[..start] } else { "" };
let after_start = start + "rename".len();
let after = if after_start < tokens.len() {
&tokens[after_start..]
} else {
""
};
let before_char = before.chars().last().unwrap_or(' ');
let after_char = after.chars().next().unwrap_or(' ');
if (before_char == ' ' || before_char == ',' || before_char == '(')
&& (after_char == ' ' || after_char == '=')
{
if let Some(equals_pos) = after.find('=') {
let value_part = &after[equals_pos + 1..].trim();
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());
}
}
}
}
}
}
}
None
}
pub fn extract_skip(attrs: &[syn::Attribute]) -> bool {
for attr in attrs {
if attr.path().is_ident("serde")
&& let syn::Meta::List(meta_list) = &attr.meta
{
let tokens = meta_list.tokens.to_string();
if tokens.contains("skip") {
if !tokens.contains("skip_serializing_if") && !tokens.contains("skip_deserializing")
{
let skip_pos = tokens.find("skip");
if let Some(pos) = skip_pos {
let before = if pos > 0 { &tokens[..pos] } else { "" };
let after = &tokens[pos + "skip".len()..];
let before_char = before.chars().last().unwrap_or(' ');
let after_char = after.chars().next().unwrap_or(' ');
if (before_char == ' ' || before_char == ',' || before_char == '(')
&& (after_char == ' ' || after_char == ',' || after_char == ')')
{
return true;
}
}
}
}
}
}
false
}
pub fn extract_skip_serializing_if(attrs: &[syn::Attribute]) -> bool {
for attr in attrs {
if attr.path().is_ident("serde")
&& let syn::Meta::List(meta_list) = &attr.meta
{
let mut found = false;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("skip_serializing_if") {
found = true;
}
Ok(())
});
if found {
return true;
}
let tokens = meta_list.tokens.to_string();
if tokens.contains("skip_serializing_if") {
return true;
}
}
}
false
}
pub fn extract_default(attrs: &[syn::Attribute]) -> Option<Option<String>> {
for attr in attrs {
if attr.path().is_ident("serde")
&& let syn::Meta::List(meta_list) = &attr.meta
{
let mut found_default: Option<Option<String>> = None;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("default") {
if let Ok(value) = meta.value() {
if let Ok(syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
})) = value.parse::<syn::Expr>()
{
found_default = Some(Some(s.value()));
}
} else {
found_default = Some(None);
}
}
Ok(())
});
if let Some(default_value) = found_default {
return Some(default_value);
}
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('=') {
let after_equals = remaining
.trim_start()
.strip_prefix('=')
.unwrap_or("")
.trim_start();
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 {
let before = if start > 0 { &tokens[..start] } else { "" };
let after = &remaining;
let before_char = before.chars().last().unwrap_or(' ');
let after_char = after.chars().next().unwrap_or(' ');
if (before_char == ' ' || before_char == ',' || before_char == '(')
&& (after_char == ' ' || after_char == ',' || after_char == ')')
{
return Some(None);
}
}
}
}
}
None
}
pub fn rename_field(field_name: &str, rename_all: Option<&str>) -> String {
match rename_all {
Some("camelCase") => {
let mut result = String::new();
let mut capitalize_next = false;
let mut in_first_word = true;
let chars: Vec<char> = 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 {
let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase());
if ch.is_uppercase() && next_is_lower && i > 0 {
in_first_word = false;
result.push(ch);
} else {
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;
} else {
result.push(ch);
}
}
result
}
Some("snake_case") => {
let mut result = String::new();
for (i, ch) in field_name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('_');
}
result.push(ch.to_lowercase().next().unwrap_or(ch));
}
result
}
Some("kebab-case") => {
let mut result = String::new();
for (i, ch) in field_name.chars().enumerate() {
if ch.is_uppercase() {
if i > 0 && !result.ends_with('-') {
result.push('-');
}
result.push(ch.to_lowercase().next().unwrap_or(ch));
} else if ch == '_' {
result.push('-');
} else {
result.push(ch);
}
}
result
}
Some("PascalCase") => {
let mut result = String::new();
let mut capitalize_next = true;
for ch in field_name.chars() {
if ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_uppercase().next().unwrap_or(ch));
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
Some("lowercase") => {
field_name.to_lowercase()
}
Some("UPPERCASE") => {
field_name.to_uppercase()
}
Some("SCREAMING_SNAKE_CASE") => {
if field_name.chars().all(|c| c.is_uppercase() || c == '_') && field_name.contains('_')
{
return field_name.to_string();
}
let mut snake_case = String::new();
for (i, ch) in field_name.chars().enumerate() {
if ch.is_uppercase() && i > 0 && !snake_case.ends_with('_') {
snake_case.push('_');
}
if ch != '_' && ch != '-' {
snake_case.push(ch.to_lowercase().next().unwrap_or(ch));
} else if ch == '_' {
snake_case.push('_');
}
}
snake_case.to_uppercase()
}
Some("SCREAMING-KEBAB-CASE") => {
let mut kebab_case = String::new();
for (i, ch) in field_name.chars().enumerate() {
if ch.is_uppercase()
&& i > 0
&& !kebab_case.ends_with('-')
&& !kebab_case.ends_with('_')
{
kebab_case.push('-');
}
if ch == '_' {
kebab_case.push('-');
} else if ch != '-' {
kebab_case.push(ch.to_lowercase().next().unwrap_or(ch));
} else {
kebab_case.push('-');
}
}
kebab_case.to_uppercase()
}
_ => field_name.to_string(),
}
}
pub fn parse_enum_to_schema(
enum_item: &syn::ItemEnum,
known_schemas: &HashMap<String, String>,
struct_definitions: &HashMap<String, String>,
) -> Schema {
let enum_description = extract_doc_comment(&enum_item.attrs);
let rename_all = extract_rename_all(&enum_item.attrs);
let all_unit = enum_item
.variants
.iter()
.all(|v| matches!(v.fields, syn::Fields::Unit));
if all_unit {
let mut enum_values = Vec::new();
for variant in &enum_item.variants {
let variant_name = strip_raw_prefix(&variant.ident.to_string()).to_string();
let enum_value = if let Some(renamed) = extract_field_rename(&variant.attrs) {
renamed
} else {
rename_field(&variant_name, rename_all.as_deref())
};
enum_values.push(serde_json::Value::String(enum_value));
}
Schema {
schema_type: Some(SchemaType::String),
description: enum_description,
r#enum: if enum_values.is_empty() {
None
} else {
Some(enum_values)
},
..Schema::string()
}
} else {
let mut one_of_schemas = Vec::new();
for variant in &enum_item.variants {
let variant_name = strip_raw_prefix(&variant.ident.to_string()).to_string();
let variant_key = if let Some(renamed) = extract_field_rename(&variant.attrs) {
renamed
} else {
rename_field(&variant_name, rename_all.as_deref())
};
let variant_description = extract_doc_comment(&variant.attrs);
let variant_schema = match &variant.fields {
syn::Fields::Unit => {
Schema {
description: variant_description,
r#enum: Some(vec![serde_json::Value::String(variant_key)]),
..Schema::string()
}
}
syn::Fields::Unnamed(fields_unnamed) => {
if fields_unnamed.unnamed.len() == 1 {
let inner_type = &fields_unnamed.unnamed[0].ty;
let inner_schema =
parse_type_to_schema_ref(inner_type, known_schemas, struct_definitions);
let mut properties = BTreeMap::new();
properties.insert(variant_key.clone(), inner_schema);
Schema {
description: variant_description.clone(),
properties: Some(properties),
required: Some(vec![variant_key]),
..Schema::object()
}
} else {
let mut tuple_item_schemas = Vec::new();
for field in &fields_unnamed.unnamed {
let field_schema = parse_type_to_schema_ref(
&field.ty,
known_schemas,
struct_definitions,
);
tuple_item_schemas.push(field_schema);
}
let tuple_len = tuple_item_schemas.len();
let array_schema = Schema {
prefix_items: Some(tuple_item_schemas),
min_items: Some(tuple_len),
max_items: Some(tuple_len),
items: None, ..Schema::new(SchemaType::Array)
};
let mut properties = BTreeMap::new();
properties.insert(
variant_key.clone(),
SchemaRef::Inline(Box::new(array_schema)),
);
Schema {
description: variant_description.clone(),
properties: Some(properties),
required: Some(vec![variant_key]),
..Schema::object()
}
}
}
syn::Fields::Named(fields_named) => {
let mut variant_properties = BTreeMap::new();
let mut variant_required = Vec::new();
let variant_rename_all = extract_rename_all(&variant.attrs);
for field in &fields_named.named {
let rust_field_name = field
.ident
.as_ref()
.map(|i| strip_raw_prefix(&i.to_string()).to_string())
.unwrap_or_else(|| "unknown".to_string());
let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) {
renamed
} else {
rename_field(
&rust_field_name,
variant_rename_all.as_deref().or(rename_all.as_deref()),
)
};
let field_type = &field.ty;
let mut schema_ref =
parse_type_to_schema_ref(field_type, known_schemas, struct_definitions);
if let Some(doc) = extract_doc_comment(&field.attrs) {
match &mut schema_ref {
SchemaRef::Inline(schema) => {
schema.description = Some(doc);
}
SchemaRef::Ref(_) => {
let ref_schema = std::mem::replace(
&mut schema_ref,
SchemaRef::Inline(Box::new(Schema::object())),
);
if let SchemaRef::Ref(reference) = ref_schema {
schema_ref = SchemaRef::Inline(Box::new(Schema {
description: Some(doc),
all_of: Some(vec![SchemaRef::Ref(reference)]),
..Default::default()
}));
}
}
}
}
variant_properties.insert(field_name.clone(), schema_ref);
let is_optional = matches!(
field_type,
Type::Path(type_path)
if type_path
.path
.segments
.first()
.map(|s| s.ident == "Option")
.unwrap_or(false)
);
if !is_optional {
variant_required.push(field_name);
}
}
let inner_struct_schema = Schema {
properties: if variant_properties.is_empty() {
None
} else {
Some(variant_properties)
},
required: if variant_required.is_empty() {
None
} else {
Some(variant_required)
},
..Schema::object()
};
let mut properties = BTreeMap::new();
properties.insert(
variant_key.clone(),
SchemaRef::Inline(Box::new(inner_struct_schema)),
);
Schema {
description: variant_description,
properties: Some(properties),
required: Some(vec![variant_key]),
..Schema::object()
}
}
};
one_of_schemas.push(SchemaRef::Inline(Box::new(variant_schema)));
}
Schema {
schema_type: None, description: enum_description,
one_of: if one_of_schemas.is_empty() {
None
} else {
Some(one_of_schemas)
},
..Schema::new(SchemaType::Object)
}
}
}
pub fn parse_struct_to_schema(
struct_item: &syn::ItemStruct,
known_schemas: &HashMap<String, String>,
struct_definitions: &HashMap<String, String>,
) -> Schema {
let mut properties = BTreeMap::new();
let mut required = Vec::new();
let struct_description = extract_doc_comment(&struct_item.attrs);
let rename_all = extract_rename_all(&struct_item.attrs);
match &struct_item.fields {
Fields::Named(fields_named) => {
for field in &fields_named.named {
if extract_skip(&field.attrs) {
continue;
}
let rust_field_name = field
.ident
.as_ref()
.map(|i| strip_raw_prefix(&i.to_string()).to_string())
.unwrap_or_else(|| "unknown".to_string());
let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) {
renamed
} else {
rename_field(&rust_field_name, rename_all.as_deref())
};
let field_type = &field.ty;
let mut schema_ref =
parse_type_to_schema_ref(field_type, known_schemas, struct_definitions);
if let Some(doc) = extract_doc_comment(&field.attrs) {
match &mut schema_ref {
SchemaRef::Inline(schema) => {
schema.description = Some(doc);
}
SchemaRef::Ref(_) => {
let ref_schema = std::mem::replace(
&mut schema_ref,
SchemaRef::Inline(Box::new(Schema::object())),
);
if let SchemaRef::Ref(reference) = ref_schema {
schema_ref = SchemaRef::Inline(Box::new(Schema {
description: Some(doc),
all_of: Some(vec![SchemaRef::Ref(reference)]),
..Default::default()
}));
}
}
}
}
let has_default = extract_default(&field.attrs).is_some();
let has_skip_serializing_if = extract_skip_serializing_if(&field.attrs);
if has_default || has_skip_serializing_if {
if let SchemaRef::Inline(ref mut _schema) = schema_ref {
}
} else {
let is_optional = matches!(
field_type,
Type::Path(type_path)
if type_path
.path
.segments
.first()
.map(|s| s.ident == "Option")
.unwrap_or(false)
);
if !is_optional {
required.push(field_name.clone());
}
}
properties.insert(field_name, schema_ref);
}
}
Fields::Unnamed(_) => {
}
Fields::Unit => {
}
}
Schema {
schema_type: Some(SchemaType::Object),
description: struct_description,
properties: if properties.is_empty() {
None
} else {
Some(properties)
},
required: if required.is_empty() {
None
} else {
Some(required)
},
..Schema::object()
}
}
fn substitute_type(ty: &Type, generic_params: &[String], concrete_types: &[&Type]) -> Type {
match ty {
Type::Path(type_path) => {
let path = &type_path.path;
if path.segments.is_empty() {
return ty.clone();
}
if path.segments.len() == 1 {
let segment = &path.segments[0];
let ident_str = segment.ident.to_string();
if let syn::PathArguments::None = &segment.arguments {
if let Some(index) = generic_params.iter().position(|p| p == &ident_str)
&& let Some(concrete_ty) = concrete_types.get(index)
{
return (*concrete_ty).clone();
}
}
}
let mut new_segments = syn::punctuated::Punctuated::new();
for segment in &path.segments {
let new_arguments = match &segment.arguments {
syn::PathArguments::AngleBracketed(args) => {
let mut new_args = syn::punctuated::Punctuated::new();
for arg in &args.args {
let new_arg = match arg {
syn::GenericArgument::Type(inner_ty) => syn::GenericArgument::Type(
substitute_type(inner_ty, generic_params, concrete_types),
),
other => other.clone(),
};
new_args.push(new_arg);
}
syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
colon2_token: args.colon2_token,
lt_token: args.lt_token,
args: new_args,
gt_token: args.gt_token,
})
}
other => other.clone(),
};
new_segments.push(syn::PathSegment {
ident: segment.ident.clone(),
arguments: new_arguments,
});
}
Type::Path(syn::TypePath {
qself: type_path.qself.clone(),
path: syn::Path {
leading_colon: path.leading_colon,
segments: new_segments,
},
})
}
Type::Reference(type_ref) => {
Type::Reference(syn::TypeReference {
and_token: type_ref.and_token,
lifetime: type_ref.lifetime.clone(),
mutability: type_ref.mutability,
elem: Box::new(substitute_type(
&type_ref.elem,
generic_params,
concrete_types,
)),
})
}
Type::Slice(type_slice) => {
Type::Slice(syn::TypeSlice {
bracket_token: type_slice.bracket_token,
elem: Box::new(substitute_type(
&type_slice.elem,
generic_params,
concrete_types,
)),
})
}
Type::Array(type_array) => {
Type::Array(syn::TypeArray {
bracket_token: type_array.bracket_token,
elem: Box::new(substitute_type(
&type_array.elem,
generic_params,
concrete_types,
)),
semi_token: type_array.semi_token,
len: type_array.len.clone(),
})
}
Type::Tuple(type_tuple) => {
let new_elems = type_tuple
.elems
.iter()
.map(|elem| substitute_type(elem, generic_params, concrete_types))
.collect();
Type::Tuple(syn::TypeTuple {
paren_token: type_tuple.paren_token,
elems: new_elems,
})
}
_ => ty.clone(),
}
}
pub(super) fn is_primitive_type(ty: &Type) -> bool {
match ty {
Type::Path(type_path) => {
let path = &type_path.path;
if path.segments.len() == 1 {
let ident = path.segments[0].ident.to_string();
matches!(
ident.as_str(),
"i8" | "i16"
| "i32"
| "i64"
| "u8"
| "u16"
| "u32"
| "u64"
| "f32"
| "f64"
| "bool"
| "String"
| "str"
)
} else {
false
}
}
_ => false,
}
}
pub fn parse_type_to_schema_ref(
ty: &Type,
known_schemas: &HashMap<String, String>,
struct_definitions: &HashMap<String, String>,
) -> SchemaRef {
parse_type_to_schema_ref_with_schemas(ty, known_schemas, struct_definitions)
}
pub(super) fn parse_type_to_schema_ref_with_schemas(
ty: &Type,
known_schemas: &HashMap<String, String>,
struct_definitions: &HashMap<String, String>,
) -> SchemaRef {
match ty {
Type::Path(type_path) => {
let path = &type_path.path;
if path.segments.is_empty() {
return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object)));
}
let segment = path.segments.last().unwrap();
let ident_str = segment.ident.to_string();
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
match ident_str.as_str() {
"Box" => {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
return parse_type_to_schema_ref(
inner_ty,
known_schemas,
struct_definitions,
);
}
}
"Vec" | "Option" => {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() {
let inner_schema = parse_type_to_schema_ref(
inner_ty,
known_schemas,
struct_definitions,
);
if ident_str == "Vec" {
return SchemaRef::Inline(Box::new(Schema::array(inner_schema)));
} else {
match inner_schema {
SchemaRef::Inline(mut schema) => {
schema.nullable = Some(true);
return SchemaRef::Inline(schema);
}
SchemaRef::Ref(reference) => {
return SchemaRef::Inline(Box::new(Schema {
ref_path: Some(reference.ref_path),
schema_type: None,
nullable: Some(true),
..Schema::new(SchemaType::Object)
}));
}
}
}
}
}
"HasOne" => {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first()
&& let Some(schema_name) = extract_schema_name_from_entity(inner_ty)
{
return SchemaRef::Inline(Box::new(Schema {
ref_path: Some(format!("#/components/schemas/{}", schema_name)),
schema_type: None,
nullable: Some(true),
..Schema::new(SchemaType::Object)
}));
}
return SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object)));
}
"HasMany" => {
if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first()
&& let Some(schema_name) = extract_schema_name_from_entity(inner_ty)
{
let inner_ref = SchemaRef::Ref(Reference::new(format!(
"#/components/schemas/{}",
schema_name
)));
return SchemaRef::Inline(Box::new(Schema::array(inner_ref)));
}
return SchemaRef::Inline(Box::new(Schema::array(SchemaRef::Inline(
Box::new(Schema::new(SchemaType::Object)),
))));
}
"HashMap" | "BTreeMap" => {
if args.args.len() >= 2
&& let (
Some(syn::GenericArgument::Type(_key_ty)),
Some(syn::GenericArgument::Type(value_ty)),
) = (args.args.get(0), args.args.get(1))
{
let value_schema = parse_type_to_schema_ref(
value_ty,
known_schemas,
struct_definitions,
);
let additional_props_value = match value_schema {
SchemaRef::Ref(ref_ref) => {
serde_json::json!({ "$ref": ref_ref.ref_path })
}
SchemaRef::Inline(schema) => {
serde_json::to_value(&*schema).unwrap_or(serde_json::json!({}))
}
};
return SchemaRef::Inline(Box::new(Schema {
schema_type: Some(SchemaType::Object),
additional_properties: Some(additional_props_value),
..Schema::object()
}));
}
}
_ => {}
}
}
match ident_str.as_str() {
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => {
SchemaRef::Inline(Box::new(Schema::integer()))
}
"f32" | "f64" => SchemaRef::Inline(Box::new(Schema::number())),
"bool" => SchemaRef::Inline(Box::new(Schema::boolean())),
"String" | "str" => SchemaRef::Inline(Box::new(Schema::string())),
"DateTime"
| "NaiveDateTime"
| "DateTimeWithTimeZone"
| "DateTimeUtc"
| "DateTimeLocal" => 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()
})),
"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" => SchemaRef::Inline(Box::new(Schema {
format: Some("duration".to_string()),
..Schema::string()
})),
"Vec" | "Option" | "Result" | "Json" | "Path" | "Query" | "Header" => {
SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object)))
}
_ => {
let type_name = ident_str.clone();
let resolved_name = if type_name == "Schema" && path.segments.len() > 1 {
let parent_segment = &path.segments[path.segments.len() - 2];
let parent_name = parent_segment.ident.to_string();
let pascal_name = format!("{}Schema", capitalize_first(&parent_name));
if known_schemas.contains_key(&pascal_name) {
pascal_name
} else {
let lower_name = format!("{}Schema", parent_name);
if known_schemas.contains_key(&lower_name) {
lower_name
} else {
type_name.clone()
}
}
} else {
type_name.clone()
};
if known_schemas.contains_key(&resolved_name) {
if let syn::PathArguments::AngleBracketed(args) = &segment.arguments {
if let Some(base_def) = struct_definitions.get(&resolved_name)
&& let Ok(mut parsed) = syn::parse_str::<syn::ItemStruct>(base_def)
{
let generic_params: Vec<String> = parsed
.generics
.params
.iter()
.filter_map(|param| {
if let syn::GenericParam::Type(type_param) = param {
Some(type_param.ident.to_string())
} else {
None
}
})
.collect();
let concrete_types: Vec<&Type> = args
.args
.iter()
.filter_map(|arg| {
if let syn::GenericArgument::Type(ty) = arg {
Some(ty)
} else {
None
}
})
.collect();
if generic_params.len() == concrete_types.len() {
if let syn::Fields::Named(fields_named) = &mut parsed.fields {
for field in &mut fields_named.named {
field.ty = substitute_type(
&field.ty,
&generic_params,
&concrete_types,
);
}
}
parsed.generics.params.clear();
parsed.generics.where_clause = None;
let schema = parse_struct_to_schema(
&parsed,
known_schemas,
struct_definitions,
);
return SchemaRef::Inline(Box::new(schema));
}
}
}
SchemaRef::Ref(Reference::schema(&resolved_name))
} else {
SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object)))
}
}
}
}
Type::Reference(type_ref) => {
parse_type_to_schema_ref_with_schemas(&type_ref.elem, known_schemas, struct_definitions)
}
_ => SchemaRef::Inline(Box::new(Schema::new(SchemaType::Object))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::{assert_debug_snapshot, with_settings};
use rstest::rstest;
use std::collections::HashMap;
use vespera_core::schema::{SchemaRef, SchemaType};
#[rstest]
#[case("HashMap<String, i32>", Some(SchemaType::Object), true)]
#[case("Option<String>", Some(SchemaType::String), false)] fn test_parse_type_to_schema_ref_cases(
#[case] ty_src: &str,
#[case] expected_type: Option<SchemaType>,
#[case] expect_additional_props: bool,
) {
let ty: syn::Type = syn::parse_str(ty_src).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, expected_type);
if expect_additional_props {
assert!(schema.additional_properties.is_some());
}
if ty_src.starts_with("Option") {
assert_eq!(schema.nullable, Some(true));
}
} else {
panic!("Expected inline schema for {}", ty_src);
}
}
#[test]
fn test_parse_type_to_schema_ref_option_ref_nullable() {
let mut known = HashMap::new();
known.insert("User".to_string(), "struct User;".to_string());
let ty: syn::Type = syn::parse_str("Option<User>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(
schema.ref_path,
Some("#/components/schemas/User".to_string())
);
assert_eq!(schema.nullable, Some(true));
assert_eq!(schema.schema_type, None);
}
_ => panic!("Expected inline schema for Option<User>"),
}
}
#[rstest]
#[case(
r#"
#[serde(rename_all = "kebab-case")]
enum Status {
#[serde(rename = "ok-status")]
Ok,
ErrorCode,
}
"#,
SchemaType::String,
vec!["ok-status", "error-code"], // rename_all is not applied in this path
"status"
)]
#[case(
r#"
enum Simple {
First,
Second,
}
"#,
SchemaType::String,
vec!["First", "Second"],
"simple"
)]
#[case(
r#"
#[serde(rename_all = "snake_case")]
enum Simple {
FirstItem,
SecondItem,
}
"#,
SchemaType::String,
vec!["first_item", "second_item"],
"simple_snake"
)]
fn test_parse_enum_to_schema_unit_variants(
#[case] enum_src: &str,
#[case] expected_type: SchemaType,
#[case] expected_enum: Vec<&str>,
#[case] suffix: &str,
) {
let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
assert_eq!(schema.schema_type, Some(expected_type));
let got = schema
.clone()
.r#enum
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>();
assert_eq!(got, expected_enum);
with_settings!({ snapshot_suffix => format!("unit_{}", suffix) }, {
assert_debug_snapshot!(schema);
});
}
#[rstest]
#[case(
r#"
enum Event {
Data(String),
}
"#,
1,
Some(SchemaType::String),
0, // single-field tuple variant stored as object with inline schema
"tuple_single"
)]
#[case(
r#"
enum Pair {
Values(i32, String),
}
"#,
1,
Some(SchemaType::Array),
2, // tuple array prefix_items length
"tuple_multi"
)]
#[case(
r#"
enum Msg {
Detail { id: i32, note: Option<String> },
}
"#,
1,
Some(SchemaType::Object),
0, // not an array; ignore prefix_items length
"named_object"
)]
fn test_parse_enum_to_schema_tuple_and_named_variants(
#[case] enum_src: &str,
#[case] expected_one_of_len: usize,
#[case] expected_inner_type: Option<SchemaType>,
#[case] expected_prefix_items_len: usize,
#[case] suffix: &str,
) {
let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
let one_of = schema.clone().one_of.expect("one_of missing");
assert_eq!(one_of.len(), expected_one_of_len);
if let Some(inner_expected) = expected_inner_type.clone() {
if let SchemaRef::Inline(obj) = &one_of[0] {
let props = obj.properties.as_ref().expect("props missing");
let inner_schema = props.values().next().expect("no property value");
match inner_expected {
SchemaType::Array => {
if let SchemaRef::Inline(array_schema) = inner_schema {
assert_eq!(array_schema.schema_type, Some(SchemaType::Array));
if expected_prefix_items_len > 0 {
assert_eq!(
array_schema.prefix_items.as_ref().unwrap().len(),
expected_prefix_items_len
);
}
} else {
panic!("Expected inline array schema");
}
}
SchemaType::Object => {
if let SchemaRef::Inline(inner_obj) = inner_schema {
assert_eq!(inner_obj.schema_type, Some(SchemaType::Object));
let inner_props = inner_obj.properties.as_ref().unwrap();
assert!(inner_props.contains_key("id"));
assert!(inner_props.contains_key("note"));
assert!(
inner_obj
.required
.as_ref()
.unwrap()
.contains(&"id".to_string())
);
} else {
panic!("Expected inline object schema");
}
}
_ => {}
}
} else {
panic!("Expected inline schema in one_of");
}
}
with_settings!({ snapshot_suffix => format!("tuple_named_{}", suffix) }, {
assert_debug_snapshot!(schema);
});
}
#[rstest]
#[case(
r#"
enum Mixed {
Ready,
Data(String),
}
"#,
2,
SchemaType::String,
"Ready"
)]
fn test_parse_enum_to_schema_mixed_unit_variant(
#[case] enum_src: &str,
#[case] expected_one_of_len: usize,
#[case] expected_unit_type: SchemaType,
#[case] expected_unit_value: &str,
) {
let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
let one_of = schema.one_of.expect("one_of missing for mixed enum");
assert_eq!(one_of.len(), expected_one_of_len);
let unit_schema = match &one_of[0] {
SchemaRef::Inline(s) => s,
_ => panic!("Expected inline schema for unit variant"),
};
assert_eq!(unit_schema.schema_type, Some(expected_unit_type));
let unit_enum = unit_schema.r#enum.as_ref().expect("enum values missing");
assert_eq!(unit_enum[0].as_str().unwrap(), expected_unit_value);
}
#[test]
fn test_parse_enum_to_schema_rename_all_for_data_variant() {
let enum_item: syn::ItemEnum = syn::parse_str(
r#"
#[serde(rename_all = "kebab-case")]
enum Payload {
DataItem(String),
}
"#,
)
.unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
let one_of = schema.one_of.expect("one_of missing");
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("data-item"));
}
#[test]
fn test_parse_enum_to_schema_field_uses_enum_rename_all() {
let enum_item: syn::ItemEnum = syn::parse_str(
r#"
#[serde(rename_all = "snake_case")]
enum Event {
Detail { UserId: i32 },
}
"#,
)
.unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
let one_of = schema.one_of.expect("one_of missing");
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");
let inner = match props.get("detail").expect("variant key missing") {
SchemaRef::Inline(s) => s,
_ => panic!("Expected inline inner schema"),
};
let inner_props = inner.properties.as_ref().expect("inner props missing");
assert!(inner_props.contains_key("user_id"));
assert!(!inner_props.contains_key("UserId"));
}
#[test]
fn test_parse_enum_to_schema_variant_rename_overrides_rename_all() {
let enum_item: syn::ItemEnum = syn::parse_str(
r#"
#[serde(rename_all = "snake_case")]
enum Payload {
#[serde(rename = "Explicit")]
DataItem(i32),
}
"#,
)
.unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
let one_of = schema.one_of.expect("one_of missing");
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("Explicit"));
assert!(!props.contains_key("data_item"));
}
#[test]
fn test_parse_enum_to_schema_field_rename_overrides_variant_rename_all() {
let enum_item: syn::ItemEnum = syn::parse_str(
r#"
#[serde(rename_all = "snake_case")]
enum Payload {
#[serde(rename_all = "kebab-case")]
Detail { #[serde(rename = "ID")] user_id: i32 },
}
"#,
)
.unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
let one_of = schema.one_of.expect("one_of missing");
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");
let inner = match props
.get("detail")
.or_else(|| props.get("Detail"))
.expect("variant key missing")
{
SchemaRef::Inline(s) => s,
_ => panic!("Expected inline inner schema"),
};
let inner_props = inner.properties.as_ref().expect("inner props missing");
assert!(inner_props.contains_key("ID")); assert!(!inner_props.contains_key("user-id")); }
#[test]
fn test_parse_enum_to_schema_rename_all_with_other_attrs_unit() {
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() {
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");
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"));
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() {
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(
r#"
struct User {
id: i32,
name: Option<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!(
schema
.required
.as_ref()
.unwrap()
.contains(&"id".to_string())
);
assert!(
!schema
.required
.as_ref()
.unwrap()
.contains(&"name".to_string())
);
}
#[test]
fn test_parse_struct_to_schema_rename_all_and_field_rename() {
let struct_item: syn::ItemStruct = syn::parse_str(
r#"
#[serde(rename_all = "camelCase")]
struct Profile {
#[serde(rename = "id")]
user_id: i32,
display_name: Option<String>,
}
"#,
)
.unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new());
let props = schema.properties.as_ref().expect("props missing");
assert!(props.contains_key("id")); assert!(props.contains_key("displayName")); let required = schema.required.as_ref().expect("required missing");
assert!(required.contains(&"id".to_string()));
assert!(!required.contains(&"displayName".to_string())); }
#[rstest]
#[case("struct Wrapper(i32);")]
#[case("struct Empty;")]
fn test_parse_struct_to_schema_tuple_and_unit_structs(#[case] struct_src: &str) {
let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new());
assert!(schema.properties.is_none());
assert!(schema.required.is_none());
}
#[test]
fn test_parse_type_to_schema_ref_empty_path_and_reference() {
let ty = Type::Path(syn::TypePath {
qself: None,
path: syn::Path {
leading_colon: None,
segments: syn::punctuated::Punctuated::new(),
},
});
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
assert!(matches!(schema_ref, SchemaRef::Inline(_)));
let ty: Type = syn::parse_str("&i32").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::Integer));
} else {
panic!("Expected inline integer schema");
}
}
#[test]
fn test_parse_type_to_schema_ref_known_schema_ref_and_unknown_custom() {
let mut known_schemas = HashMap::new();
known_schemas.insert("Known".to_string(), "Known".to_string());
let ty: Type = syn::parse_str("Known").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new());
assert!(matches!(schema_ref, SchemaRef::Ref(_)));
let ty: Type = syn::parse_str("UnknownType").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new());
assert!(matches!(schema_ref, SchemaRef::Inline(_)));
}
#[test]
fn test_parse_type_to_schema_ref_generic_substitution() {
let mut known_schemas = HashMap::new();
known_schemas.insert("Wrapper".to_string(), "Wrapper".to_string());
let mut struct_definitions = HashMap::new();
struct_definitions.insert(
"Wrapper".to_string(),
"struct Wrapper<T> { value: T }".to_string(),
);
let ty: syn::Type = syn::parse_str("Wrapper<String>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &struct_definitions);
if let SchemaRef::Inline(schema) = schema_ref {
let props = schema.properties.as_ref().unwrap();
let value = props.get("value").unwrap();
if let SchemaRef::Inline(inner) = value {
assert_eq!(inner.schema_type, Some(SchemaType::String));
} else {
panic!("Expected inline schema for value");
}
} else {
panic!("Expected inline schema for generic substitution");
}
}
#[rstest]
#[case("$invalid", "String")]
fn test_substitute_type_parse_failure_uses_original(
#[case] invalid: &str,
#[case] concrete_src: &str,
) {
use proc_macro2::TokenStream;
use std::str::FromStr;
let ty = Type::Verbatim(TokenStream::from_str(invalid).unwrap());
let concrete: Type = syn::parse_str(concrete_src).unwrap();
let substituted = substitute_type(&ty, &[String::from("T")], &[&concrete]);
assert_eq!(substituted, ty);
}
#[rstest]
#[case("T", &["T"], &["String"], "String")]
#[case("Vec<T>", &["T"], &["String"], "Vec < String >")]
#[case("Option<T>", &["T"], &["i32"], "Option < i32 >")]
#[case("Vec<Option<T>>", &["T"], &["String"], "Vec < Option < String > >")]
#[case("Option<Vec<Option<T>>>", &["T"], &["bool"], "Option < Vec < Option < bool > > >")]
#[case("HashMap<K, V>", &["K", "V"], &["String", "i32"], "HashMap < String , i32 >")]
#[case("Vec<U>", &["T"], &["String"], "Vec < U >")]
#[case("String", &["T"], &["i32"], "String")]
#[case("&T", &["T"], &["String"], "& String")]
#[case("&mut T", &["T"], &["i32"], "& mut i32")]
#[case("[T]", &["T"], &["String"], "[String]")]
#[case("[T; 5]", &["T"], &["u8"], "[u8 ; 5]")]
#[case("(T, U)", &["T", "U"], &["String", "i32"], "(String , i32)")]
#[case("(Vec<T>, Option<U>)", &["T", "U"], &["String", "bool"], "(Vec < String > , Option < bool >)")]
#[case("&Vec<T>", &["T"], &["String"], "& Vec < String >")]
#[case("std::vec::Vec<T>", &["T"], &["String"], "std :: vec :: Vec < String >")]
fn test_substitute_type_comprehensive(
#[case] input: &str,
#[case] params: &[&str],
#[case] concrete: &[&str],
#[case] expected: &str,
) {
let ty: Type = syn::parse_str(input).unwrap();
let generic_params: Vec<String> = params.iter().map(|s| s.to_string()).collect();
let concrete_types: Vec<Type> = concrete
.iter()
.map(|s| syn::parse_str(s).unwrap())
.collect();
let concrete_refs: Vec<&Type> = concrete_types.iter().collect();
let result = substitute_type(&ty, &generic_params, &concrete_refs);
let result_str = quote::quote!(#result).to_string();
assert_eq!(result_str, expected, "Input: {}", input);
}
#[test]
fn test_substitute_type_empty_path_segments() {
let ty = Type::Path(syn::TypePath {
qself: None,
path: syn::Path {
leading_colon: None,
segments: syn::punctuated::Punctuated::new(),
},
});
let concrete: Type = syn::parse_str("String").unwrap();
let result = substitute_type(&ty, &[String::from("T")], &[&concrete]);
assert_eq!(result, ty);
}
#[test]
fn test_substitute_type_with_lifetime_generic_argument() {
let ty: Type = syn::parse_str("std::borrow::Cow<'static, T>").unwrap();
let concrete: Type = syn::parse_str("String").unwrap();
let result = substitute_type(&ty, &[String::from("T")], &[&concrete]);
let result_str = quote::quote!(#result).to_string();
assert_eq!(result_str, "std :: borrow :: Cow < 'static , String >");
}
#[test]
fn test_substitute_type_parenthesized_args() {
let ty: Type = syn::parse_str("fn(T) -> U").unwrap();
let concrete_t: Type = syn::parse_str("String").unwrap();
let concrete_u: Type = syn::parse_str("i32").unwrap();
let result = substitute_type(
&ty,
&[String::from("T"), String::from("U")],
&[&concrete_t, &concrete_u],
);
assert_eq!(result, ty);
}
#[test]
fn test_substitute_type_path_without_angle_brackets() {
let ty: Type = syn::parse_str("dyn Fn(T) -> U").unwrap();
let concrete_t: Type = syn::parse_str("String").unwrap();
let concrete_u: Type = syn::parse_str("i32").unwrap();
let result = substitute_type(
&ty,
&[String::from("T"), String::from("U")],
&[&concrete_t, &concrete_u],
);
assert_eq!(result, ty);
}
#[rstest]
#[case("&i32")]
#[case("std::string::String")]
fn test_is_primitive_type_non_path_variants(#[case] ty_src: &str) {
let ty: Type = syn::parse_str(ty_src).unwrap();
assert!(!is_primitive_type(&ty));
}
#[rstest]
#[case(
"HashMap<String, Value>",
true,
None,
Some("#/components/schemas/Value")
)]
#[case("Result<String, i32>", false, Some(SchemaType::Object), None)]
#[case("crate::Value", false, None, None)]
#[case("(i32, bool)", false, Some(SchemaType::Object), None)]
fn test_parse_type_to_schema_ref_additional_cases(
#[case] ty_src: &str,
#[case] expect_additional_props: bool,
#[case] expected_type: Option<SchemaType>,
#[case] expected_ref: Option<&str>,
) {
let mut known_schemas = HashMap::new();
known_schemas.insert("Value".to_string(), "Value".to_string());
let ty: Type = syn::parse_str(ty_src).unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known_schemas, &HashMap::new());
match expected_ref {
Some(expected) => {
let SchemaRef::Inline(schema) = schema_ref else {
panic!("Expected inline schema for {}", ty_src);
};
let additional = schema
.additional_properties
.as_ref()
.expect("additional_properties missing");
assert_eq!(additional.get("$ref").unwrap(), expected);
}
None => match schema_ref {
SchemaRef::Inline(schema) => {
if expect_additional_props {
assert!(schema.additional_properties.is_some());
} else {
assert_eq!(schema.schema_type, expected_type);
}
}
SchemaRef::Ref(_) => {
assert!(ty_src.contains("Value"));
}
},
}
}
#[rstest]
#[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")]
#[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")]
#[case("userName", Some("snake_case"), "user_name")]
#[case("firstName", Some("snake_case"), "first_name")]
#[case("lastName", Some("snake_case"), "last_name")]
#[case("userId", Some("snake_case"), "user_id")]
#[case("apiKey", Some("snake_case"), "api_key")]
#[case("already_snake", Some("snake_case"), "already_snake")]
#[case("user_name", Some("kebab-case"), "user-name")]
#[case("first_name", Some("kebab-case"), "first-name")]
#[case("last_name", Some("kebab-case"), "last-name")]
#[case("user_id", Some("kebab-case"), "user-id")]
#[case("api_key", Some("kebab-case"), "api-key")]
#[case("already-kebab", Some("kebab-case"), "already-kebab")]
#[case("user_name", Some("PascalCase"), "UserName")]
#[case("first_name", Some("PascalCase"), "FirstName")]
#[case("last_name", Some("PascalCase"), "LastName")]
#[case("user_id", Some("PascalCase"), "UserId")]
#[case("api_key", Some("PascalCase"), "ApiKey")]
#[case("AlreadyPascal", Some("PascalCase"), "AlreadyPascal")]
#[case("UserName", Some("lowercase"), "username")]
#[case("FIRST_NAME", Some("lowercase"), "first_name")]
#[case("lastName", Some("lowercase"), "lastname")]
#[case("User_ID", Some("lowercase"), "user_id")]
#[case("API_KEY", Some("lowercase"), "api_key")]
#[case("already_lower", Some("lowercase"), "already_lower")]
#[case("user_name", Some("UPPERCASE"), "USER_NAME")]
#[case("firstName", Some("UPPERCASE"), "FIRSTNAME")]
#[case("LastName", Some("UPPERCASE"), "LASTNAME")]
#[case("user_id", Some("UPPERCASE"), "USER_ID")]
#[case("apiKey", Some("UPPERCASE"), "APIKEY")]
#[case("ALREADY_UPPER", Some("UPPERCASE"), "ALREADY_UPPER")]
#[case("user_name", Some("SCREAMING_SNAKE_CASE"), "USER_NAME")]
#[case("firstName", Some("SCREAMING_SNAKE_CASE"), "FIRST_NAME")]
#[case("LastName", Some("SCREAMING_SNAKE_CASE"), "LAST_NAME")]
#[case("user_id", Some("SCREAMING_SNAKE_CASE"), "USER_ID")]
#[case("apiKey", Some("SCREAMING_SNAKE_CASE"), "API_KEY")]
#[case("ALREADY_SCREAMING", Some("SCREAMING_SNAKE_CASE"), "ALREADY_SCREAMING")]
#[case("user_name", Some("SCREAMING-KEBAB-CASE"), "USER-NAME")]
#[case("firstName", Some("SCREAMING-KEBAB-CASE"), "FIRST-NAME")]
#[case("LastName", Some("SCREAMING-KEBAB-CASE"), "LAST-NAME")]
#[case("user_id", Some("SCREAMING-KEBAB-CASE"), "USER-ID")]
#[case("apiKey", Some("SCREAMING-KEBAB-CASE"), "API-KEY")]
#[case("already-kebab", Some("SCREAMING-KEBAB-CASE"), "ALREADY-KEBAB")]
#[case("user_name", None, "user_name")]
#[case("firstName", None, "firstName")]
#[case("LastName", None, "LastName")]
#[case("user-id", None, "user-id")]
fn test_rename_field(
#[case] field_name: &str,
#[case] rename_all: Option<&str>,
#[case] expected: &str,
) {
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")
)]
#[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"))]
#[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"));
}
#[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)]
#[case(r#"#[serde(rename_all = "camelCase")] field: i32"#, None)]
#[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>) {
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);
}
}
#[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)]
#[case(
r#"#[serde(skip_serializing_if = "Option::is_none")] field: i32"#,
false
)]
#[case(r#"#[serde(skip_deserializing)] field: i32"#, false)]
#[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);
}
}
#[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);
}
}
#[rstest]
#[case(r#"#[serde(default)] field: i32"#, Some(None))]
#[case(
r#"#[serde(default = "default_value")] field: i32"#,
Some(Some("default_value"))
)]
#[case(
r#"#[serde(default = "Default::default")] field: i32"#,
Some(Some("Default::default"))
)]
#[case(r#"#[serde(skip)] field: i32"#, None)]
#[case(r#"#[serde(rename = "x")] field: i32"#, None)]
#[case(r#"field: i32"#, None)]
#[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<Option<&str>>) {
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]
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")); }
#[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<i32>,
}
"#,
)
.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()));
assert!(!required.contains(&"optional_with_default".to_string()));
assert!(!required.contains(&"maybe_skip".to_string()));
}
#[test]
fn test_parse_type_to_schema_ref_btreemap() {
let ty: Type = syn::parse_str("BTreeMap<String, i32>").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");
}
}
mod fallback_parsing_tests {
use super::*;
fn get_struct_attrs(serde_content: &str) -> Vec<syn::Attribute> {
let src = format!(r#"#[serde({})] struct Foo;"#, serde_content);
let item: syn::ItemStruct = syn::parse_str(&src).unwrap();
item.attrs
}
fn get_field_attrs(serde_content: &str) -> Vec<syn::Attribute> {
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]
fn test_extract_rename_all_fallback_path() {
let attrs = get_struct_attrs(r#"rename_all = "camelCase""#);
let result = extract_rename_all(&attrs);
assert_eq!(result.as_deref(), Some("camelCase"));
}
#[test]
fn test_extract_field_rename_fallback_path() {
let attrs = get_field_attrs(r#"rename = "myField""#);
let result = extract_field_rename(&attrs);
assert_eq!(result.as_deref(), Some("myField"));
}
#[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]
fn test_extract_default_standalone_fallback_path() {
let attrs = get_field_attrs(r#"default"#);
let result = extract_default(&attrs);
assert_eq!(result, Some(None));
}
#[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]
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); }
#[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]
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]
fn test_extract_rename_all_non_list_serde() {
let item: syn::ItemStruct = syn::parse_str(r#"#[serde] struct Foo;"#).unwrap();
let result = extract_rename_all(&item.attrs);
assert_eq!(result, None);
}
#[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]
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]
fn test_extract_rename_all_with_whitespace() {
let attrs = get_struct_attrs(r#"rename_all = "PascalCase""#);
let result = extract_rename_all(&attrs);
assert_eq!(result.as_deref(), Some("PascalCase"));
}
#[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]
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]
fn test_extract_skip_basic() {
let attrs = get_field_attrs(r#"skip"#);
let result = extract_skip(&attrs);
assert!(result);
}
#[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]
fn test_extract_skip_not_skip_deserializing() {
let attrs = get_field_attrs(r#"skip_deserializing"#);
let result = extract_skip(&attrs);
assert!(!result);
}
#[test]
fn test_extract_skip_with_other_attrs() {
let attrs = get_field_attrs(r#"skip, default"#);
let result = extract_skip(&attrs);
assert!(result);
}
#[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]
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);
}
#[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]
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]
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]
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#"), "");
}
mod programmatic_attr_tests {
use super::*;
use proc_macro2::{Span, TokenStream};
use quote::quote;
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]
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]
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]);
assert!(result.is_none());
}
#[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]
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]
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]
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]
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]
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]
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]
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]
fn test_extract_rename_all_fallback_manual_parsing() {
let tokens = quote!(rename_all = "kebab-case");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("kebab-case"));
}
#[test]
fn test_extract_rename_all_complex_attribute_fallback() {
let tokens = quote!(default, rename_all = "SCREAMING_SNAKE_CASE", skip);
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("SCREAMING_SNAKE_CASE"));
}
#[test]
fn test_extract_rename_all_no_quote_start() {
let tokens = quote!(rename_all = snake_case);
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert!(result.is_none());
}
#[test]
fn test_extract_rename_all_unclosed_quote() {
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]
fn test_extract_rename_all_empty_string() {
let tokens = quote!(rename_all = "");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some(""));
}
#[test]
fn test_extract_rename_all_qualified_path_forces_fallback() {
let tokens = quote!(serde_with::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]
fn test_extract_rename_all_module_qualified_forces_fallback() {
let tokens = quote!(my_module::rename_all = "snake_case");
let attr = create_attr_with_raw_tokens(tokens);
let result = extract_rename_all(&[attr]);
assert_eq!(result.as_deref(), Some("snake_case"));
}
#[test]
fn test_extract_rename_all_deeply_qualified_forces_fallback() {
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"));
}
#[test]
fn test_extract_rename_all_raw_tokens_force_fallback() {
let tokens: TokenStream = "__rename_all_prefix::rename_all = \"lowercase\""
.parse()
.unwrap();
let attr = create_attr_with_raw_tokens(tokens);
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]);
assert_eq!(
result.as_deref(),
Some("lowercase"),
"Fallback parsing must extract the value"
);
}
#[test]
fn test_extract_rename_all_crate_qualified_forces_fallback() {
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]
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"));
}
}
}
#[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());
assert!(matches!(schema_ref, SchemaRef::Inline(_)));
}
#[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());
assert!(schema.r#enum.is_none() || schema.r#enum.as_ref().unwrap().is_empty());
}
#[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);
}
#[test]
fn test_rename_field_unknown_format() {
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]
fn test_parse_type_to_schema_ref_unknown_custom_type() {
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]
fn test_parse_type_to_schema_ref_qualified_unknown_type() {
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]
fn test_rename_field_camelcase_with_digits() {
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");
}
#[test]
fn test_extract_doc_comment_single_line() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[doc = " This is a doc comment"]
};
let result = extract_doc_comment(&attrs);
assert_eq!(result, Some("This is a doc comment".to_string()));
}
#[test]
fn test_extract_doc_comment_multi_line() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[doc = " First line"]
#[doc = " Second line"]
#[doc = " Third line"]
};
let result = extract_doc_comment(&attrs);
assert_eq!(
result,
Some("First line\nSecond line\nThird line".to_string())
);
}
#[test]
fn test_extract_doc_comment_no_leading_space() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[doc = "No leading space"]
};
let result = extract_doc_comment(&attrs);
assert_eq!(result, Some("No leading space".to_string()));
}
#[test]
fn test_extract_doc_comment_empty() {
let attrs: Vec<syn::Attribute> = vec![];
let result = extract_doc_comment(&attrs);
assert_eq!(result, None);
}
#[test]
fn test_extract_doc_comment_with_non_doc_attrs() {
let attrs: Vec<syn::Attribute> = syn::parse_quote! {
#[derive(Debug)]
#[doc = " The doc comment"]
#[serde(rename = "test")]
};
let result = extract_doc_comment(&attrs);
assert_eq!(result, Some("The doc comment".to_string()));
}
#[test]
fn test_extract_schema_name_from_entity_super_path() {
let ty: Type = syn::parse_str("super::user::Entity").unwrap();
let result = extract_schema_name_from_entity(&ty);
assert_eq!(result, Some("User".to_string()));
}
#[test]
fn test_extract_schema_name_from_entity_crate_path() {
let ty: Type = syn::parse_str("crate::models::memo::Entity").unwrap();
let result = extract_schema_name_from_entity(&ty);
assert_eq!(result, Some("Memo".to_string()));
}
#[test]
fn test_extract_schema_name_from_entity_not_entity() {
let ty: Type = syn::parse_str("crate::models::user::Model").unwrap();
let result = extract_schema_name_from_entity(&ty);
assert_eq!(result, None);
}
#[test]
fn test_extract_schema_name_from_entity_single_segment() {
let ty: Type = syn::parse_str("Entity").unwrap();
let result = extract_schema_name_from_entity(&ty);
assert_eq!(result, None);
}
#[test]
fn test_extract_schema_name_from_entity_non_path_type() {
let ty: Type = syn::parse_str("&str").unwrap();
let result = extract_schema_name_from_entity(&ty);
assert_eq!(result, None);
}
#[test]
fn test_extract_schema_name_from_entity_empty_module_name() {
let ty: Type = syn::parse_str("super::some_module::Entity").unwrap();
let result = extract_schema_name_from_entity(&ty);
assert_eq!(result, Some("Some_module".to_string()));
}
#[test]
fn test_parse_enum_to_schema_with_variant_descriptions() {
let enum_src = r#"
/// Enum description
enum Status {
/// Active variant
Active,
/// Inactive variant
Inactive,
}
"#;
let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
assert_eq!(schema.description, Some("Enum description".to_string()));
}
#[test]
fn test_parse_enum_to_schema_data_variant_with_description() {
let enum_src = r#"
/// Data enum
enum Event {
/// Text event description
Text(String),
/// Number event description
Number(i32),
}
"#;
let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
assert_eq!(schema.description, Some("Data enum".to_string()));
assert!(schema.one_of.is_some());
let one_of = schema.one_of.unwrap();
assert_eq!(one_of.len(), 2);
if let SchemaRef::Inline(variant_schema) = &one_of[0] {
assert_eq!(
variant_schema.description,
Some("Text event description".to_string())
);
}
}
#[test]
fn test_parse_enum_to_schema_struct_variant_with_field_docs() {
let enum_src = r#"
enum Event {
/// Record variant
Record {
/// The value field
value: i32,
/// The name field
name: String,
},
}
"#;
let enum_item: syn::ItemEnum = syn::parse_str(enum_src).unwrap();
let schema = parse_enum_to_schema(&enum_item, &HashMap::new(), &HashMap::new());
assert!(schema.one_of.is_some());
let one_of = schema.one_of.unwrap();
if let SchemaRef::Inline(variant_schema) = &one_of[0] {
assert_eq!(
variant_schema.description,
Some("Record variant".to_string())
);
}
}
#[test]
fn test_parse_struct_to_schema_with_description() {
let struct_src = r#"
/// User struct description
struct User {
/// User ID
id: i32,
/// User name
name: String,
}
"#;
let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
let schema = parse_struct_to_schema(&struct_item, &HashMap::new(), &HashMap::new());
assert_eq!(
schema.description,
Some("User struct description".to_string())
);
let props = schema.properties.unwrap();
if let SchemaRef::Inline(id_schema) = props.get("id").unwrap() {
assert_eq!(id_schema.description, Some("User ID".to_string()));
}
if let SchemaRef::Inline(name_schema) = props.get("name").unwrap() {
assert_eq!(name_schema.description, Some("User name".to_string()));
}
}
#[test]
fn test_parse_struct_to_schema_field_with_ref_and_description() {
let struct_src = r#"
struct Container {
/// The user reference
user: User,
}
"#;
let struct_item: syn::ItemStruct = syn::parse_str(struct_src).unwrap();
let mut known = HashMap::new();
known.insert("User".to_string(), "struct User { id: i32 }".to_string());
let schema = parse_struct_to_schema(&struct_item, &known, &HashMap::new());
let props = schema.properties.unwrap();
if let SchemaRef::Inline(user_schema) = props.get("user").unwrap() {
assert_eq!(
user_schema.description,
Some("The user reference".to_string())
);
assert!(user_schema.all_of.is_some());
}
}
#[test]
fn test_parse_type_to_schema_ref_box_type() {
let ty: Type = syn::parse_str("Box<String>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(SchemaType::String));
}
_ => panic!("Expected inline schema for Box<String>"),
}
}
#[test]
fn test_parse_type_to_schema_ref_box_with_known_type() {
let mut known = HashMap::new();
known.insert("User".to_string(), "User".to_string());
let ty: Type = syn::parse_str("Box<User>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new());
match schema_ref {
SchemaRef::Ref(reference) => {
assert_eq!(reference.ref_path, "#/components/schemas/User");
}
_ => panic!("Expected ref for Box<User>"),
}
}
#[test]
fn test_parse_type_to_schema_ref_has_one_entity() {
let ty: Type = syn::parse_str("HasOne<super::user::Entity>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(
schema.ref_path,
Some("#/components/schemas/User".to_string())
);
assert_eq!(schema.nullable, Some(true));
}
_ => panic!("Expected inline schema for HasOne"),
}
}
#[test]
fn test_parse_type_to_schema_ref_has_one_fallback() {
let ty: Type = syn::parse_str("HasOne<i32>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(SchemaType::Object));
assert!(schema.ref_path.is_none());
}
_ => panic!("Expected inline schema for HasOne fallback"),
}
}
#[test]
fn test_parse_type_to_schema_ref_has_one_non_entity_path() {
let ty: Type = syn::parse_str("HasOne<crate::models::User>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(SchemaType::Object));
}
_ => panic!("Expected inline schema"),
}
}
#[test]
fn test_parse_type_to_schema_ref_has_many_entity() {
let ty: Type = syn::parse_str("HasMany<super::comment::Entity>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(SchemaType::Array));
if let Some(SchemaRef::Ref(items_ref)) = schema.items.as_deref() {
assert_eq!(items_ref.ref_path, "#/components/schemas/Comment");
} else {
panic!("Expected items to be a $ref");
}
}
_ => panic!("Expected inline schema for HasMany"),
}
}
#[test]
fn test_parse_type_to_schema_ref_has_many_fallback() {
let ty: Type = syn::parse_str("HasMany<String>").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(SchemaType::Array));
if let Some(SchemaRef::Inline(items)) = schema.items.as_deref() {
assert_eq!(items.schema_type, Some(SchemaType::Object));
} else {
panic!("Expected inline items for HasMany fallback");
}
}
_ => panic!("Expected inline schema for HasMany fallback"),
}
}
#[test]
fn test_parse_type_to_schema_ref_module_schema_path_pascal() {
let mut known = HashMap::new();
known.insert("UserSchema".to_string(), "UserSchema".to_string());
let ty: Type = syn::parse_str("crate::models::user::Schema").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new());
match schema_ref {
SchemaRef::Ref(reference) => {
assert_eq!(reference.ref_path, "#/components/schemas/UserSchema");
}
_ => panic!("Expected $ref for module::Schema"),
}
}
#[test]
fn test_parse_type_to_schema_ref_module_schema_path_lower() {
let mut known = HashMap::new();
known.insert("userSchema".to_string(), "userSchema".to_string());
let ty: Type = syn::parse_str("crate::models::user::Schema").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new());
match schema_ref {
SchemaRef::Ref(reference) => {
assert_eq!(reference.ref_path, "#/components/schemas/userSchema");
}
_ => panic!("Expected $ref for module::Schema with lowercase"),
}
}
#[test]
fn test_parse_type_to_schema_ref_module_schema_path_fallback() {
let ty: Type = syn::parse_str("crate::models::user::Schema").unwrap();
let schema_ref = parse_type_to_schema_ref(&ty, &HashMap::new(), &HashMap::new());
match schema_ref {
SchemaRef::Inline(schema) => {
assert_eq!(schema.schema_type, Some(SchemaType::Object));
}
_ => panic!("Expected inline for unknown Schema type"),
}
}
#[test]
fn test_parse_enum_to_schema_variant_field_with_doc_comment_and_ref() {
let enum_item: syn::ItemEnum = syn::parse_str(
r#"
enum Message {
Data {
/// The user associated with this message
user: User,
},
}
"#,
)
.unwrap();
let mut known_schemas = HashMap::new();
known_schemas.insert("User".to_string(), "User".to_string());
let schema = parse_enum_to_schema(&enum_item, &known_schemas, &HashMap::new());
let one_of = schema.one_of.expect("one_of missing");
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");
let inner = match props.get("Data").expect("variant key missing") {
SchemaRef::Inline(s) => s,
_ => panic!("Expected inline inner schema"),
};
let inner_props = inner.properties.as_ref().expect("inner props missing");
let user_field = inner_props.get("user").expect("user field missing");
match user_field {
SchemaRef::Inline(schema) => {
assert_eq!(
schema.description.as_deref(),
Some("The user associated with this message")
);
let all_of = schema.all_of.as_ref().expect("allOf missing");
assert_eq!(all_of.len(), 1);
match &all_of[0] {
SchemaRef::Ref(reference) => {
assert_eq!(reference.ref_path, "#/components/schemas/User");
}
_ => panic!("Expected $ref in allOf"),
}
}
SchemaRef::Ref(_) => panic!("Expected inline schema with allOf, not direct $ref"),
}
}
#[test]
fn test_parse_type_to_schema_ref_module_schema_with_empty_parent() {
let ty: Type = syn::parse_str("Schema").unwrap();
let mut known = HashMap::new();
known.insert("Schema".to_string(), "Schema".to_string());
let schema_ref = parse_type_to_schema_ref(&ty, &known, &HashMap::new());
match schema_ref {
SchemaRef::Ref(reference) => {
assert_eq!(reference.ref_path, "#/components/schemas/Schema");
}
_ => panic!("Expected $ref for Schema type"),
}
}
#[rstest]
#[case("", "")]
#[case("a", "A")]
#[case("user", "User")]
#[case("User", "User")]
#[case("USER", "USER")]
#[case("user_name", "User_name")]
fn test_capitalize_first(#[case] input: &str, #[case] expected: &str) {
assert_eq!(capitalize_first(input), expected);
}
}