Yes, the below came from AI. Yes, I reviewed it and this first message is from a hooman!
Summary
The derive macro currently supports per-variant/per-field #[serde(rename = "...")] but does not handle the container-level #[serde(rename_all = "...")] attribute. This means enum variant names and struct field names are emitted as their raw Rust identifiers unless every single variant/field has an explicit rename.
Reproduction
use serde::{Serialize, Deserialize};
use zod_gen_derive::ZodSchema;
#[derive(ZodSchema, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
ChatResponse { speaker: String, text: String },
MemorySnapshot { data: String },
ParticipantUpdate { count: u64 },
}
Expected output
export const ServerMessageSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('chat_response'), speaker: z.string(), text: z.string() }),
z.object({ type: z.literal('memory_snapshot'), data: z.string() }),
z.object({ type: z.literal('participant_update'), count: z.number() }),
]);
Actual output
export const ServerMessageSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('ChatResponse'), speaker: z.string(), text: z.string() }),
z.object({ type: z.literal('MemorySnapshot'), data: z.string() }),
z.object({ type: z.literal('ParticipantUpdate'), count: z.number() }),
]);
The variant names are PascalCase (Rust ident) instead of the snake_case that serde would serialize.
Root cause
In zod_gen_derive/src/lib.rs, extract_serde_rename_variant falls back to variant.ident.to_string() when no per-variant rename is found:
fn extract_serde_rename_variant(variant: &syn::Variant) -> String {
if let Some(rename_value) = find_serde_rename_from_attrs(&variant.attrs) {
rename_value
} else {
variant.ident.to_string() // <-- no rename_all applied
}
}
Similarly, struct field names fall back to ident.to_string() without checking container-level rename_all.
There is no parsing of rename_all from the container attrs anywhere in the macro.
Suggested implementation
-
Add a find_serde_rename_all_from_attrs function that parses the rename_all value from container attributes (similar to find_serde_tag_from_attrs).
-
Implement the serde rename transformations. The full set from serde is:
rename_all value |
Transformation |
lowercase |
all lowercase |
UPPERCASE |
ALL UPPERCASE |
PascalCase |
PascalCase (usually a no-op for variants) |
camelCase |
camelCase |
snake_case |
snake_case |
SCREAMING_SNAKE_CASE |
SCREAMING_SNAKE_CASE |
kebab-case |
kebab-case |
SCREAMING-KEBAB-CASE |
SCREAMING-KEBAB-CASE |
A reference implementation exists in serde itself: RenameRule.
-
Pass the parsed rename_all into extract_serde_rename_variant and the struct field name resolution, applying the transformation when no explicit per-item rename is present.
Sketch:
fn find_serde_rename_all_from_attrs(attrs: &[Attribute]) -> Option<String> {
for attr in attrs {
if attr.path().is_ident("serde") {
let attr_str = quote!(#attr).to_string();
if let Some(start) = attr_str.find("rename_all") {
let part = &attr_str[start..];
if let Some(q1) = part.find('"') {
if let Some(q2) = part[q1 + 1..].find('"') {
return Some(part[q1 + 1..q1 + 1 + q2].to_string());
}
}
}
}
}
None
}
fn apply_rename_all(name: &str, convention: &str) -> String {
match convention {
"snake_case" => to_snake_case(name),
"camelCase" => to_camel_case(name),
"kebab-case" => to_kebab_case(name),
"SCREAMING_SNAKE_CASE" => to_snake_case(name).to_uppercase(),
"lowercase" => name.to_lowercase(),
"UPPERCASE" => name.to_uppercase(),
"PascalCase" => name.to_string(), // variants are already PascalCase
_ => name.to_string(),
}
}
fn extract_serde_rename_variant(variant: &syn::Variant, rename_all: Option<&str>) -> String {
if let Some(rename_value) = find_serde_rename_from_attrs(&variant.attrs) {
rename_value
} else if let Some(convention) = rename_all {
apply_rename_all(&variant.ident.to_string(), convention)
} else {
variant.ident.to_string()
}
}
Current workaround
Adding explicit #[serde(rename = "...")] to every variant works but is verbose and redundant when rename_all is already set:
#[derive(ZodSchema, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SessionMode {
#[serde(rename = "solo")]
Solo,
#[serde(rename = "team")]
Team,
#[serde(rename = "global")]
Global,
}
Scope
This should apply to:
- Enum variant names (externally tagged, internally tagged, adjacently tagged)
- Struct field names
Both code paths fall back to the raw ident and would benefit from applying rename_all.
Yes, the below came from AI. Yes, I reviewed it and this first message is from a hooman!
Summary
The derive macro currently supports per-variant/per-field
#[serde(rename = "...")]but does not handle the container-level#[serde(rename_all = "...")]attribute. This means enum variant names and struct field names are emitted as their raw Rust identifiers unless every single variant/field has an explicitrename.Reproduction
Expected output
Actual output
The variant names are PascalCase (Rust ident) instead of the snake_case that serde would serialize.
Root cause
In
zod_gen_derive/src/lib.rs,extract_serde_rename_variantfalls back tovariant.ident.to_string()when no per-variantrenameis found:Similarly, struct field names fall back to
ident.to_string()without checking container-levelrename_all.There is no parsing of
rename_allfrom the containerattrsanywhere in the macro.Suggested implementation
Add a
find_serde_rename_all_from_attrsfunction that parses therename_allvalue from container attributes (similar tofind_serde_tag_from_attrs).Implement the serde rename transformations. The full set from serde is:
rename_allvaluelowercaseUPPERCASEPascalCasecamelCasesnake_caseSCREAMING_SNAKE_CASEkebab-caseSCREAMING-KEBAB-CASEA reference implementation exists in serde itself:
RenameRule.Pass the parsed
rename_allintoextract_serde_rename_variantand the struct field name resolution, applying the transformation when no explicit per-itemrenameis present.Sketch:
Current workaround
Adding explicit
#[serde(rename = "...")]to every variant works but is verbose and redundant whenrename_allis already set:Scope
This should apply to:
Both code paths fall back to the raw ident and would benefit from applying
rename_all.