use crate::core::ir::{EnumDef, EnumVariant, TypeDef, TypeRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SerdeEnumRepr {
External,
Internal { tag: String },
Adjacent { tag: String, content: String },
Untagged,
}
impl SerdeEnumRepr {
pub fn kind(&self) -> &'static str {
match self {
Self::External => "external",
Self::Internal { .. } => "internal",
Self::Adjacent { .. } => "adjacent",
Self::Untagged => "untagged",
}
}
pub fn tag(&self) -> Option<&str> {
match self {
Self::Internal { tag } | Self::Adjacent { tag, .. } => Some(tag),
Self::External | Self::Untagged => None,
}
}
pub fn content(&self) -> Option<&str> {
match self {
Self::Adjacent { content, .. } => Some(content),
Self::External | Self::Internal { .. } | Self::Untagged => None,
}
}
}
pub fn serde_enum_repr(enum_def: &EnumDef) -> SerdeEnumRepr {
if enum_def.serde_untagged {
return SerdeEnumRepr::Untagged;
}
match (enum_def.serde_tag.as_deref(), enum_def.serde_content.as_deref()) {
(Some(tag), Some(content)) => SerdeEnumRepr::Adjacent {
tag: tag.to_string(),
content: content.to_string(),
},
(Some(tag), None) => SerdeEnumRepr::Internal { tag: tag.to_string() },
(None, _) => SerdeEnumRepr::External,
}
}
pub const DEFAULT_TAGGED_OBJECT_TAG_KEY: &str = "type";
#[must_use]
pub fn tagged_object_tag_key(enum_def: &EnumDef) -> &str {
enum_def.serde_tag.as_deref().unwrap_or(DEFAULT_TAGGED_OBJECT_TAG_KEY)
}
#[cfg(test)]
mod tagged_object_tag_key_tests {
use super::*;
fn enum_with(tag: Option<&str>, content: Option<&str>) -> EnumDef {
EnumDef {
name: "Sample".to_string(),
serde_tag: tag.map(str::to_string),
serde_content: content.map(str::to_string),
..EnumDef::default()
}
}
type TagKeyCase<'a> = (&'a str, Option<&'a str>, Option<&'a str>, &'a str);
#[test]
fn should_resolve_the_discriminator_key_for_every_container_attribute_shape() {
let cases: [TagKeyCase<'_>; 5] = [
(
"no container attribute falls back to the convention",
None,
None,
"type",
),
("an explicit tag wins over the fallback", Some("kind"), None, "kind"),
(
"an explicit tag wins even when it equals the fallback",
Some("type"),
None,
"type",
),
(
"adjacent tagging keys on the tag, not the content",
Some("kind"),
Some("payload"),
"kind",
),
(
"a content without a tag still falls back to the convention",
None,
Some("payload"),
"type",
),
];
for (case, tag, content, expected) in cases {
assert_eq!(tagged_object_tag_key(&enum_with(tag, content)), expected, "{case}");
}
}
#[test]
fn should_agree_with_the_named_constant() {
assert_eq!(
tagged_object_tag_key(&enum_with(None, None)),
DEFAULT_TAGGED_OBJECT_TAG_KEY
);
}
}
#[must_use]
pub fn serde_flattens_newtype_payload(enum_def: &EnumDef, variant: &EnumVariant, types: &[TypeDef]) -> bool {
if !matches!(serde_enum_repr(enum_def), SerdeEnumRepr::Internal { .. }) {
return false;
}
if !(variant.is_tuple && variant.fields.len() == 1) {
return false;
}
let TypeRef::Named(inner_name) = &variant.fields[0].ty else {
return false;
};
types.iter().any(|t| &t.name == inner_name)
}
#[cfg(test)]
mod serde_flattens_newtype_payload_tests {
use super::*;
use crate::core::ir::FieldDef;
fn enum_with(tag: Option<&str>, content: Option<&str>, untagged: bool, variant: EnumVariant) -> EnumDef {
EnumDef {
name: "FormatMetadata".to_string(),
serde_tag: tag.map(str::to_string),
serde_content: content.map(str::to_string),
serde_untagged: untagged,
variants: vec![variant],
..EnumDef::default()
}
}
fn variant(is_tuple: bool, field_names: &[&str]) -> EnumVariant {
EnumVariant {
name: "Excel".to_string(),
is_tuple,
fields: field_names
.iter()
.map(|name| FieldDef {
name: (*name).to_string(),
ty: TypeRef::Named("ExcelMetadata".to_string()),
..FieldDef::default()
})
.collect(),
..EnumVariant::default()
}
}
fn excel_metadata_types() -> Vec<TypeDef> {
vec![TypeDef {
name: "ExcelMetadata".to_string(),
..TypeDef::default()
}]
}
type FlattenCase<'a> = (
&'a str,
Option<&'a str>,
Option<&'a str>,
bool,
EnumVariant,
Vec<TypeDef>,
bool,
);
fn flatten_cases() -> [FlattenCase<'static>; 8] {
[
(
"internally tagged newtype is the flattened shape",
Some("format_type"),
None,
false,
variant(true, &["_0"]),
excel_metadata_types(),
true,
),
(
"an internally tagged newtype whose payload type does not resolve is not flattened",
Some("format_type"),
None,
false,
variant(true, &["_0"]),
Vec::new(),
false,
),
(
"adjacent tagging keeps the payload under the content key",
Some("format_type"),
Some("payload"),
false,
variant(true, &["_0"]),
excel_metadata_types(),
false,
),
(
"untagged writes the bare payload",
None,
None,
true,
variant(true, &["_0"]),
excel_metadata_types(),
false,
),
(
"external tagging keys the payload on the variant name",
None,
None,
false,
variant(true, &["_0"]),
excel_metadata_types(),
false,
),
(
"untagged wins over a stray tag, so the payload is still not flattened",
Some("format_type"),
None,
true,
variant(true, &["_0"]),
excel_metadata_types(),
false,
),
(
"a struct variant's fields are already flat and carry real names",
Some("format_type"),
None,
false,
variant(false, &["sheet_count"]),
excel_metadata_types(),
false,
),
(
"a multi-field tuple variant is not a newtype",
Some("format_type"),
None,
false,
variant(true, &["_0", "_1"]),
excel_metadata_types(),
false,
),
]
}
#[test]
fn should_flatten_only_an_internally_tagged_newtype_variant_whose_payload_resolves() {
let cases = flatten_cases();
for (case, tag, content, untagged, variant, types, expected) in cases {
let enum_def = enum_with(tag, content, untagged, variant);
assert_eq!(
serde_flattens_newtype_payload(&enum_def, &enum_def.variants[0], &types),
expected,
"{case}"
);
}
}
}
#[must_use]
pub fn variant_name_for_wire<'a>(enum_def: &'a EnumDef, wire: &str) -> Option<&'a str> {
let rename_all = enum_def.serde_rename_all.as_deref();
enum_def
.variants
.iter()
.find(|variant| {
variant.name == wire
|| crate::codegen::naming::wire_variant_value(
&variant.name,
variant.serde_rename.as_deref(),
rename_all,
) == wire
})
.map(|variant| variant.name.as_str())
}
#[cfg(test)]
mod variant_name_for_wire_tests {
use super::*;
fn output_format(rename_all: Option<&str>, variants: &[(&str, Option<&str>)]) -> EnumDef {
EnumDef {
name: "OutputFormat".to_string(),
serde_rename_all: rename_all.map(str::to_string),
variants: variants
.iter()
.map(|(name, rename)| EnumVariant {
name: (*name).to_string(),
serde_rename: rename.map(str::to_string),
..EnumVariant::default()
})
.collect(),
..EnumDef::default()
}
}
type RenameCase<'a> = (
&'a str,
Option<&'a str>,
&'a [(&'a str, Option<&'a str>)],
&'a str,
Option<&'a str>,
);
#[test]
fn should_resolve_every_rename_strategy_back_to_the_declared_variant() {
let cases: [RenameCase<'_>; 7] = [
(
"single word, no rename",
None,
&[("Markdown", None)],
"Markdown",
Some("Markdown"),
),
(
"single word under lowercase",
Some("lowercase"),
&[("Markdown", None)],
"markdown",
Some("Markdown"),
),
(
"multi word under snake_case",
Some("snake_case"),
&[("PlainText", None)],
"plain_text",
Some("PlainText"),
),
(
"multi word under lowercase collapses, so re-casing cannot recover it",
Some("lowercase"),
&[("PlainText", None)],
"plaintext",
Some("PlainText"),
),
(
"explicit rename wins over rename_all",
Some("snake_case"),
&[("Markdown", Some("md"))],
"md",
Some("Markdown"),
),
(
"acronym variant under lowercase",
Some("lowercase"),
&[("HTML", None)],
"html",
Some("HTML"),
),
("value no variant declares", None, &[("Markdown", None)], "pdf", None),
];
for (case, rename_all, variants, wire, expected) in cases {
let enum_def = output_format(rename_all, variants);
assert_eq!(variant_name_for_wire(&enum_def, wire), expected, "{case}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn enum_def(tag: Option<&str>, content: Option<&str>, untagged: bool) -> EnumDef {
EnumDef {
name: "Sample".to_string(),
serde_tag: tag.map(str::to_string),
serde_content: content.map(str::to_string),
serde_untagged: untagged,
..EnumDef::default()
}
}
#[test]
fn should_be_external_when_no_serde_container_attribute_is_present() {
let repr = serde_enum_repr(&enum_def(None, None, false));
assert_eq!(repr, SerdeEnumRepr::External);
assert_eq!(repr.kind(), "external");
assert_eq!(repr.tag(), None);
assert_eq!(repr.content(), None);
}
#[test]
fn should_be_internal_when_only_tag_is_present() {
let repr = serde_enum_repr(&enum_def(Some("type"), None, false));
assert_eq!(
repr,
SerdeEnumRepr::Internal {
tag: "type".to_string()
}
);
assert_eq!(repr.kind(), "internal");
assert_eq!(repr.tag(), Some("type"));
assert_eq!(repr.content(), None);
}
#[test]
fn should_be_adjacent_when_tag_and_content_are_both_present() {
let repr = serde_enum_repr(&enum_def(Some("kind"), Some("payload"), false));
assert_eq!(
repr,
SerdeEnumRepr::Adjacent {
tag: "kind".to_string(),
content: "payload".to_string()
}
);
assert_eq!(repr.kind(), "adjacent");
assert_eq!(repr.tag(), Some("kind"));
assert_eq!(repr.content(), Some("payload"));
}
#[test]
fn should_be_untagged_when_untagged_is_set() {
let repr = serde_enum_repr(&enum_def(None, None, true));
assert_eq!(repr, SerdeEnumRepr::Untagged);
assert_eq!(repr.kind(), "untagged");
assert_eq!(repr.tag(), None);
}
#[test]
fn should_prefer_untagged_over_a_stray_tag() {
assert_eq!(
serde_enum_repr(&enum_def(Some("type"), None, true)),
SerdeEnumRepr::Untagged
);
}
#[test]
fn should_ignore_content_without_a_tag() {
assert_eq!(
serde_enum_repr(&enum_def(None, Some("payload"), false)),
SerdeEnumRepr::External
);
}
}