use std::collections::HashSet;
use heck::ToUpperCamelCase;
use crate::core::ir::EnumDef;
use crate::e2e::field_access::FieldResolver;
pub(super) fn hash_serialized_enum_names(enums: &[EnumDef]) -> HashSet<String> {
enums
.iter()
.filter(|enum_def| enum_def.variants.iter().any(|variant| !variant.fields.is_empty()))
.map(|enum_def| enum_def.name.clone())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RubyEnumAccess {
Available,
SerializedAsHash,
VariantAccessorUnavailable,
}
pub(super) fn classify(field_resolver: &FieldResolver, field: &str) -> RubyEnumAccess {
if field_resolver.ruby_enum_serialized_as_hash(field) == Some(true) {
return RubyEnumAccess::SerializedAsHash;
}
let segments: Vec<&str> = field.split('.').collect();
for i in 1..segments.len() {
let prefix = segments[..i].join(".");
if field_resolver.ruby_enum_serialized_as_hash(&prefix) == Some(true) {
return RubyEnumAccess::VariantAccessorUnavailable;
}
}
RubyEnumAccess::Available
}
pub(super) fn variant_field_accessor(field_resolver: &FieldResolver, field: &str, result_var: &str) -> Option<String> {
let resolved = field_resolver.resolve(field);
let segments: Vec<&str> = resolved.split('.').collect();
for enum_index in 1..segments.len().saturating_sub(1) {
let prefix = segments[..enum_index].join(".");
if field_resolver.ruby_enum_serialized_as_hash(&prefix) != Some(true) {
continue;
}
let variant = segments[enum_index];
let [payload_field] = segments.get(enum_index + 1..)? else {
return None;
};
if !payload_field
.chars()
.all(|character| character == '_' || character.is_ascii_alphanumeric())
{
return None;
}
let enum_type = field_resolver.ir_enum_type_name(&prefix)?;
let variant = variant.to_upper_camel_case();
let (wrapped_field, payload_type) = field_resolver.union_variant_payload(&enum_type, &variant)?;
if field_resolver.is_declared_field_of_type(payload_type, payload_field) != Some(true) {
return None;
}
let (serde_tag, wire_variant) = field_resolver.tagged_enum_wire_discriminator(&enum_type, &variant)?;
let magnus_wrapper_key = field_resolver
.tagged_enum_content_key(&enum_type)
.unwrap_or(wrapped_field);
let enum_hash = field_resolver.accessor(&prefix, "ruby", result_var);
let tag = crate::e2e::escape::ruby_string_literal(serde_tag);
let wire_variant = crate::e2e::escape::ruby_string_literal(wire_variant);
let magnus_wrapper_key = crate::e2e::escape::ruby_string_literal(magnus_wrapper_key);
let payload_field = crate::e2e::escape::ruby_string_literal(payload_field);
return Some(format!(
concat!(
"{enum_hash}.then {{ |enum_hash| raise \"unexpected tagged enum variant\" ",
"unless enum_hash.fetch({tag}.to_sym) == {wire_variant}; ",
"enum_hash.fetch({magnus_wrapper_key}.to_sym).fetch({payload_field}.to_sym) }}"
),
enum_hash = enum_hash,
tag = tag,
wire_variant = wire_variant,
magnus_wrapper_key = magnus_wrapper_key,
payload_field = payload_field,
));
}
None
}
#[cfg(test)]
mod tests {
use super::{RubyEnumAccess, classify, hash_serialized_enum_names};
use crate::core::ir::{EnumDef, EnumVariant, FieldDef, PrimitiveType, TypeDef, TypeRef};
use crate::e2e::field_access::FieldResolver;
fn field(name: &str, ty: TypeRef) -> FieldDef {
FieldDef {
name: name.to_string(),
ty,
..FieldDef::default()
}
}
fn named(name: &str) -> TypeRef {
TypeRef::Named(name.to_string())
}
fn ir() -> (Vec<TypeDef>, Vec<EnumDef>) {
let type_defs = vec![
TypeDef {
name: "ProcessingResult".to_string(),
fields: vec![
field("summary", named("DocumentSummary")),
field("metadata", named("PageMetadata")),
],
..TypeDef::default()
},
TypeDef {
name: "DocumentSummary".to_string(),
fields: vec![
field("encoding", named("EncodingDetails")),
field("kind", named("DocumentKind")),
field("adjacent", named("AdjacentDetails")),
],
..TypeDef::default()
},
TypeDef {
name: "PageMetadata".to_string(),
fields: vec![field("format", TypeRef::String)],
..TypeDef::default()
},
TypeDef {
name: "SpreadsheetDetails".to_string(),
fields: vec![field("sheet_count", TypeRef::Primitive(PrimitiveType::U32))],
..TypeDef::default()
},
TypeDef {
name: "WrappedPayload".to_string(),
fields: vec![field("value", TypeRef::String)],
..TypeDef::default()
},
];
let enums = vec![
EnumDef {
name: "EncodingDetails".to_string(),
serde_tag: Some("type'kind".to_string()),
variants: vec![
EnumVariant {
name: "Spreadsheet".to_string(),
serde_rename: Some("sheet'kind".to_string()),
is_tuple: true,
fields: vec![field("_0", named("SpreadsheetDetails"))],
..EnumVariant::default()
},
EnumVariant {
name: "Empty".to_string(),
..EnumVariant::default()
},
EnumVariant {
name: "Pair".to_string(),
fields: vec![field("left", TypeRef::String), field("right", TypeRef::String)],
..EnumVariant::default()
},
],
..EnumDef::default()
},
EnumDef {
name: "DocumentKind".to_string(),
variants: vec![EnumVariant {
name: "Report".to_string(),
..EnumVariant::default()
}],
..EnumDef::default()
},
EnumDef {
name: "AdjacentDetails".to_string(),
serde_tag: Some("kind".to_string()),
serde_content: Some("body".to_string()),
variants: vec![EnumVariant {
name: "Wrapped".to_string(),
is_tuple: true,
fields: vec![field("_0", named("WrappedPayload"))],
..EnumVariant::default()
}],
..EnumDef::default()
},
];
(type_defs, enums)
}
fn resolver() -> FieldResolver {
let (type_defs, enums) = ir();
let map = FieldResolver::ir_enum_fields(&type_defs, &enums);
FieldResolver::new(
&std::collections::HashMap::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
)
.with_ir_enum_map(map, Some("ProcessingResult".to_string()))
.with_ruby_hash_serialized_enum_names(hash_serialized_enum_names(&enums))
.with_ir_result_fields(
FieldResolver::ir_result_field_facts(&type_defs, "ruby"),
Some("ProcessingResult".to_string()),
)
}
#[test]
fn a_variant_path_through_a_hash_serialized_enum_is_refused_under_any_name() {
let out = classify(&resolver(), "summary.encoding.spreadsheet.sheet_count");
assert_eq!(out, RubyEnumAccess::VariantAccessorUnavailable);
}
#[test]
fn a_path_landing_exactly_on_a_hash_serialized_enum_is_flagged_as_hash() {
let out = classify(&resolver(), "summary.encoding");
assert_eq!(out, RubyEnumAccess::SerializedAsHash);
}
#[test]
fn a_field_that_merely_shares_the_old_literal_name_is_not_refused() {
let out = classify(&resolver(), "metadata.format");
assert_eq!(out, RubyEnumAccess::Available);
}
#[test]
fn a_symbol_lowered_enum_field_is_available() {
let out = classify(&resolver(), "summary.kind");
assert_eq!(out, RubyEnumAccess::Available);
}
#[test]
fn with_no_ir_wired_in_nothing_is_ever_refused() {
let resolver = FieldResolver::new(
&std::collections::HashMap::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
&std::collections::HashSet::new(),
);
assert_eq!(
classify(&resolver, "summary.encoding.spreadsheet.sheet_count"),
RubyEnumAccess::Available
);
assert_eq!(classify(&resolver, "metadata.format"), RubyEnumAccess::Available);
}
#[test]
fn should_match_the_binding_backends_partition() {
let (_, enums) = ir();
let names = hash_serialized_enum_names(&enums);
assert!(
names.contains("EncodingDetails"),
"a data-carrying enum lowers to a Hash"
);
assert!(
!names.contains("DocumentKind"),
"a unit-variant-only enum lowers to a Symbol"
);
}
fn render(field: &str) -> String {
let assertion = crate::e2e::fixture::Assertion {
assertion_type: "equals".to_string(),
field: Some(field.to_string()),
value: Some(serde_json::json!("Excel")),
..Default::default()
};
let mut out = String::new();
super::super::assertions::render_assertion(
&mut out,
&assertion,
"result",
&resolver(),
false,
&crate::e2e::config::E2eConfig::default(),
&std::collections::HashSet::new(),
&std::collections::HashMap::new(),
);
out
}
#[test]
fn render_assertion_reaches_a_hash_serialized_variant_field_under_any_name() {
let out = render("summary.encoding.spreadsheet.sheet_count");
assert!(out.contains("result.summary.encoding.then { |enum_hash|"), "got: {out}");
assert!(
out.contains("enum_hash.fetch(\"type'kind\".to_sym) == \"sheet'kind\""),
"got: {out}"
);
assert!(
out.contains("enum_hash.fetch('_0'.to_sym).fetch('sheet_count'.to_sym)"),
"got: {out}"
);
assert!(!out.contains("# skipped:"), "got: {out}");
}
#[test]
fn render_assertion_hops_through_the_magnus_wrapper_field_before_the_payload_field() {
let out = render("summary.encoding.spreadsheet.sheet_count");
assert!(
out.contains("enum_hash.fetch('_0'.to_sym).fetch('sheet_count'.to_sym)"),
"got: {out}"
);
}
#[test]
fn render_assertion_hops_through_the_configured_content_key_for_adjacently_tagged_enums() {
let out = render("summary.adjacent.wrapped.value");
assert!(
out.contains("enum_hash.fetch('body'.to_sym).fetch('value'.to_sym)"),
"got: {out}"
);
assert!(!out.contains("fetch('_0'.to_sym)"), "got: {out}");
}
#[test]
fn render_assertion_supported_variant_field_has_no_skip_classification() {
use crate::e2e::codegen::field_skip::FieldSkip;
let out = render("summary.encoding.spreadsheet.sheet_count");
assert_eq!(FieldSkip::extract_classified(&out), None, "got: {out}");
}
#[test]
fn runtime_accessor_rejects_a_different_wire_variant() {
let out = render("summary.encoding.spreadsheet.sheet_count");
assert!(
out.contains("raise \"unexpected tagged enum variant\" unless"),
"got: {out}"
);
assert!(
out.contains("fetch(\"type'kind\".to_sym) == \"sheet'kind\""),
"got: {out}"
);
}
#[test]
fn nonexistent_payload_leaf_remains_a_generator_gap() {
let out = render("summary.encoding.spreadsheet.not_declared");
assert!(out.contains("# skipped: enum variant accessor"), "got: {out}");
assert!(!out.contains("fetch(\"not_declared\".to_sym)"), "got: {out}");
}
#[test]
fn nested_and_indexed_payload_suffixes_remain_generator_gaps() {
for field in [
"summary.encoding.spreadsheet.sheet_count.value",
"summary.encoding.spreadsheet.sheet_count[0]",
] {
let out = render(field);
assert!(
out.contains("# skipped: enum variant accessor"),
"field={field}, got: {out}"
);
}
}
#[test]
fn unknown_variant_remains_a_generator_gap() {
for field in [
"summary.encoding.unknown.sheet_count",
"summary.encoding.empty.sheet_count",
"summary.encoding.pair.left",
] {
let out = render(field);
assert!(
out.contains("# skipped: enum variant accessor"),
"field={field}, got: {out}"
);
}
}
}