Skip to content

feat: support container-level #[serde(rename_all = "...")] on enums and structs #7

Description

@cdbattags

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

  1. 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).

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions