diff --git a/buffa-build/src/lib.rs b/buffa-build/src/lib.rs index 72010b9..891cc92 100644 --- a/buffa-build/src/lib.rs +++ b/buffa-build/src/lib.rs @@ -441,6 +441,36 @@ impl Config { self } + /// Store the matching message-typed oneof variants inline instead of + /// wrapping them in `Box`. + /// + /// By default every message/group oneof variant is boxed so that recursive + /// types compile. For non-recursive variants the `Box` is pure overhead (an + /// allocation per construction); this opts the matching variants out. + /// + /// Each path is a fully-qualified proto variant path prefix, e.g. + /// `".my.pkg.MyMessage.body.small"` for one variant or `".my.pkg"` for a + /// package (same matching as [`use_bytes_type_in`](Self::use_bytes_type_in)). + /// Opting a *recursive* variant out is rejected at codegen time, since the + /// resulting type would be unsized. + #[must_use] + pub fn unbox_oneof_in(mut self, paths: &[impl AsRef]) -> Self { + self.codegen_config + .unboxed_oneof_fields + .extend(paths.iter().map(|p| p.as_ref().to_string())); + self + } + + /// Store every non-recursive message-typed oneof variant inline instead of + /// boxing it. Convenience for `.unbox_oneof_in(&["."])`. + #[must_use] + pub fn unbox_oneof(mut self) -> Self { + self.codegen_config + .unboxed_oneof_fields + .push(".".to_string()); + self + } + /// Map `string` fields to a [`StringRepr`] other than `String` for the /// given proto path prefixes. The string counterpart to /// [`use_bytes_type_in`](Self::use_bytes_type_in). diff --git a/buffa-codegen/src/context.rs b/buffa-codegen/src/context.rs index a6b9cbb..3ccfa74 100644 --- a/buffa-codegen/src/context.rs +++ b/buffa-codegen/src/context.rs @@ -699,6 +699,19 @@ impl<'a> CodeGenContext<'a> { .any(|prefix| matches_proto_prefix(prefix, field_fqn)) } + /// Check whether a message-typed oneof variant at the given proto path was + /// opted out of `Box` wrapping via `config.unboxed_oneof_fields`. + /// + /// `variant_fqn` is the fully-qualified variant path, e.g. + /// `".my.pkg.MyMessage.body.small"`. Matched with the same + /// proto-segment-aware prefix logic as [`use_bytes_type`](Self::use_bytes_type). + pub fn oneof_unboxed(&self, variant_fqn: &str) -> bool { + self.config + .unboxed_oneof_fields + .iter() + .any(|prefix| matches_proto_prefix(prefix, variant_fqn)) + } + /// Resolve the [`StringRepr`](crate::StringRepr) for a `string` field at the /// given proto path. /// diff --git a/buffa-codegen/src/impl_message.rs b/buffa-codegen/src/impl_message.rs index 98c3144..f5d95e3 100644 --- a/buffa-codegen/src/impl_message.rs +++ b/buffa-codegen/src/impl_message.rs @@ -2329,10 +2329,24 @@ fn oneof_merge_arm( preserve_unknown_fields: bool, use_bytes: bool, string_repr: crate::StringRepr, + boxed: bool, ) -> TokenStream { let wire_type = wire_type_token(ty); let wire_byte = wire_type_byte(ty); let wire_check = wire_type_check(field_number, &wire_type, wire_byte); + // Message/group variants merge into the existing value. When boxed, the + // binding is `&mut Box` (deref once); when stored inline it is `&mut M`, + // and the freshly decoded value is moved in without a `Box`. + let existing_ref = if boxed { + quote! { &mut **existing } + } else { + quote! { existing } + }; + let wrapped_val = if boxed { + quote! { ::buffa::alloc::boxed::Box::new(val) } + } else { + quote! { val } + }; match ty { Type::TYPE_STRING => { let decoded = if string_repr.is_default() { @@ -2405,12 +2419,12 @@ fn oneof_merge_arm( if let ::core::option::Option::Some( #enum_ident::#variant_ident(ref mut existing) ) = self.#field_ident { - ::buffa::Message::merge_length_delimited(&mut **existing, buf, depth)?; + ::buffa::Message::merge_length_delimited(#existing_ref, buf, depth)?; } else { let mut val = ::core::default::Default::default(); ::buffa::Message::merge_length_delimited(&mut val, buf, depth)?; self.#field_ident = ::core::option::Option::Some( - #enum_ident::#variant_ident(::buffa::alloc::boxed::Box::new(val)) + #enum_ident::#variant_ident(#wrapped_val) ); } } @@ -2421,12 +2435,12 @@ fn oneof_merge_arm( if let ::core::option::Option::Some( #enum_ident::#variant_ident(ref mut existing) ) = self.#field_ident { - ::buffa::Message::merge_group(&mut **existing, buf, depth, #field_number)?; + ::buffa::Message::merge_group(#existing_ref, buf, depth, #field_number)?; } else { let mut val = ::core::default::Default::default(); ::buffa::Message::merge_group(&mut val, buf, depth, #field_number)?; self.#field_ident = ::core::option::Option::Some( - #enum_ident::#variant_ident(::buffa::alloc::boxed::Box::new(val)) + #enum_ident::#variant_ident(#wrapped_val) ); } } @@ -2489,6 +2503,11 @@ fn generate_oneof_impls( let field_features = crate::features::resolve_field(ctx, field, features); let use_bytes = ty == Type::TYPE_BYTES && field_uses_bytes(ctx, proto_fqn, field_name); let string_repr = field_string_repr(ctx, proto_fqn, field_name); + let boxed = crate::oneof::variant_boxed( + ctx, + ty, + &format!(".{proto_fqn}.{oneof_name}.{field_name}"), + ); merge_arm_list.push(oneof_merge_arm( &field_ident, &qualified_enum, @@ -2499,6 +2518,7 @@ fn generate_oneof_impls( preserve_unknown_fields, use_bytes, string_repr, + boxed, )); } diff --git a/buffa-codegen/src/impl_text.rs b/buffa-codegen/src/impl_text.rs index f3cdc38..e68cc63 100644 --- a/buffa-codegen/src/impl_text.rs +++ b/buffa-codegen/src/impl_text.rs @@ -30,7 +30,6 @@ use crate::impl_message::{ is_required_field, is_supported_field_type, }; use crate::message::{is_closed_enum, is_map_field, make_field_ident}; -use crate::oneof::is_boxed_variant; use crate::CodeGenError; /// Wrap a text-decoded owned `String` expression in the conversion to the @@ -181,7 +180,15 @@ pub(crate) fn generate_text_impl( let oneof_encode: Vec<_> = oneof_groups .iter() .map(|(name, enum_ident, fields)| { - oneof_encode_stmt(ctx, enum_ident, name, fields, oneof_prefix, features) + oneof_encode_stmt( + ctx, + enum_ident, + name, + fields, + oneof_prefix, + proto_fqn, + features, + ) }) .collect::>()?; let map_encode: Vec<_> = map_fields @@ -762,6 +769,7 @@ fn oneof_encode_stmt( oneof_name: &str, fields: &[&FieldDescriptorProto], oneof_prefix: &TokenStream, + proto_fqn: &str, parent_features: &ResolvedFeatures, ) -> Result { let field_ident = make_field_ident(oneof_name); @@ -777,7 +785,11 @@ fn oneof_encode_stmt( let variant = crate::oneof::oneof_variant_ident(proto_name); let ty = effective_type(ctx, field, &features); let (name_lit, _) = text_field_name(proto_name, field, ty); - let boxed = is_boxed_variant(ty); + let boxed = crate::oneof::variant_boxed( + ctx, + ty, + &format!(".{proto_fqn}.{oneof_name}.{proto_name}"), + ); // Box auto-derefs through `&**__v` → `&M`. For string/bytes, // `__v: &String` / `&Vec` / `&bytes::Bytes` deref-coerces. @@ -841,20 +853,35 @@ fn oneof_merge_arms( let (_, name_pat) = text_field_name(proto_name, field, ty); let use_bytes = ty == Type::TYPE_BYTES && field_uses_bytes(ctx, proto_fqn, proto_name); - // Message/group variants are boxed. Merge-into-existing matches - // binary oneof semantics (oneof_merge_arm in impl_message.rs). + // Message/group variants are boxed unless opted out. Merge-into-existing + // matches binary oneof semantics (oneof_merge_arm in impl_message.rs). + let boxed = crate::oneof::variant_boxed( + ctx, + ty, + &format!(".{proto_fqn}.{oneof_name}.{proto_name}"), + ); let assign = match ty { Type::TYPE_MESSAGE | Type::TYPE_GROUP => { + let existing_ref = if boxed { + quote! { &mut **__existing } + } else { + quote! { __existing } + }; + let wrapped = if boxed { + quote! { ::buffa::alloc::boxed::Box::new(__m) } + } else { + quote! { __m } + }; quote! { if let ::core::option::Option::Some( #qualified::#variant(ref mut __existing) ) = self.#field_ident { - dec.merge_message(&mut **__existing)?; + dec.merge_message(#existing_ref)?; } else { let mut __m = ::core::default::Default::default(); dec.merge_message(&mut __m)?; self.#field_ident = ::core::option::Option::Some( - #qualified::#variant(::buffa::alloc::boxed::Box::new(__m)) + #qualified::#variant(#wrapped) ); } } diff --git a/buffa-codegen/src/lib.rs b/buffa-codegen/src/lib.rs index f38afca..16f6769 100644 --- a/buffa-codegen/src/lib.rs +++ b/buffa-codegen/src/lib.rs @@ -297,6 +297,16 @@ pub struct CodeGenConfig { /// `string` variants. Map keys and values always stay `String`, mirroring /// the bytes path (where map values always stay `Vec`). pub string_fields: Vec<(String, StringRepr)>, + /// Fully-qualified proto paths whose message-typed oneof variants should + /// **not** be wrapped in `Box`. By default every message/group oneof + /// variant is boxed (so recursive types compile); entries here opt matching + /// variants out, storing the message inline in the enum. + /// + /// Each entry is a proto path prefix matched with the same + /// proto-segment-aware logic as [`bytes_fields`](Self::bytes_fields) + /// (`"."` matches every variant). Opting a *recursive* variant out is + /// rejected at codegen time, since the resulting type would be unsized. + pub unboxed_oneof_fields: Vec, /// Honor `features.utf8_validation = NONE` by emitting `Vec` / `&[u8]` /// for such string fields instead of `String` / `&str`. /// @@ -484,6 +494,7 @@ impl Default for CodeGenConfig { extern_paths: Vec::new(), bytes_fields: Vec::new(), string_fields: Vec::new(), + unboxed_oneof_fields: Vec::new(), strict_utf8_mapping: false, allow_message_set: false, generate_text: false, diff --git a/buffa-codegen/src/message.rs b/buffa-codegen/src/message.rs index d6a6ca7..009c10d 100644 --- a/buffa-codegen/src/message.rs +++ b/buffa-codegen/src/message.rs @@ -1278,7 +1278,11 @@ fn custom_deser_oneof_group( proto_name, field_type, null_forward: crate::oneof::null_is_valid_value(field), - is_boxed: crate::oneof::is_boxed_variant(field_type), + is_boxed: crate::oneof::variant_boxed( + ctx, + field_type, + &format!(".{proto_fqn}.{oneof_name}.{proto_name}"), + ), enum_ident: &qualified_enum, result_var: &var_ident, oneof_name, diff --git a/buffa-codegen/src/oneof.rs b/buffa-codegen/src/oneof.rs index 3169397..6591991 100644 --- a/buffa-codegen/src/oneof.rs +++ b/buffa-codegen/src/oneof.rs @@ -1,7 +1,9 @@ //! Oneof enum code generation. use crate::generated::descriptor::field_descriptor_proto::Type; -use crate::generated::descriptor::{DescriptorProto, FieldDescriptorProto, OneofDescriptorProto}; +use crate::generated::descriptor::{ + DescriptorProto, FieldDescriptorProto, FileDescriptorProto, OneofDescriptorProto, +}; use proc_macro2::{Ident, TokenStream}; use quote::{format_ident, quote}; @@ -48,6 +50,106 @@ pub(crate) fn is_boxed_variant(ty: Type) -> bool { matches!(ty, Type::TYPE_MESSAGE | Type::TYPE_GROUP) } +/// Returns `true` when a oneof variant is stored in a `Box`. +/// +/// Message and group variants box by default (see [`is_boxed_variant`]); a +/// matching `config.unboxed_oneof_fields` rule opts the variant out, storing +/// it inline. `variant_fqn` is the leading-dot variant path, e.g. +/// `".my.pkg.MyMessage.body.small"`. Recursive opt-outs are rejected at the +/// point of collection (see `collect_variant_info`), so callers can store the +/// value inline without a further unsized-type check. +pub(crate) fn variant_boxed(ctx: &CodeGenContext, ty: Type, variant_fqn: &str) -> bool { + is_boxed_variant(ty) && !ctx.oneof_unboxed(variant_fqn) +} + +/// Build a map from fully-qualified message name (no leading dot) to its +/// descriptor, walking every file and its nested types. +fn message_index( + files: &[FileDescriptorProto], +) -> std::collections::HashMap { + fn walk<'a>( + map: &mut std::collections::HashMap, + prefix: &str, + msg: &'a DescriptorProto, + ) { + let Some(name) = msg.name.as_deref() else { + return; + }; + let fqn = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + }; + for nested in &msg.nested_type { + walk(map, &fqn, nested); + } + map.insert(fqn, msg); + } + + let mut map = std::collections::HashMap::new(); + for file in files { + let package = file.package.as_deref().unwrap_or(""); + for msg in &file.message_type { + walk(&mut map, package, msg); + } + } + map +} + +/// Returns `true` when storing a variant of message type `target` inline +/// inside `enclosing` would produce an unsized type. +/// +/// `enclosing` and `target` are fully-qualified message names without a +/// leading dot. A cycle is only reachable through message-typed oneof variants +/// that are themselves opted out of boxing (singular message fields are +/// `Option>`, repeated fields are `Vec`, and maps are heap-backed, so +/// none of those carry storage inline). The walk follows those opted-out edges +/// from `target`; if it reaches `enclosing`, the opt-out is recursive. +fn unboxing_is_recursive(ctx: &CodeGenContext, enclosing: &str, target: &str) -> bool { + let index = message_index(ctx.files); + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![target.to_string()]; + while let Some(current) = stack.pop() { + if current == enclosing { + return true; + } + if !seen.insert(current.clone()) { + continue; + } + let Some(msg) = index.get(current.as_str()) else { + continue; + }; + for field in &msg.field { + if !crate::impl_message::is_real_oneof_member(field) { + continue; + } + let ty = field.r#type.unwrap_or_default(); + if !is_boxed_variant(ty) { + continue; + } + let (Some(oneof_idx), Some(field_name), Some(type_name)) = ( + field.oneof_index, + field.name.as_deref(), + field.type_name.as_deref(), + ) else { + continue; + }; + let Some(oneof_name) = msg + .oneof_decl + .get(oneof_idx as usize) + .and_then(|o| o.name.as_deref()) + else { + continue; + }; + let variant_fqn = format!(".{current}.{oneof_name}.{field_name}"); + if ctx.oneof_unboxed(&variant_fqn) { + stack.push(type_name.trim_start_matches('.').to_string()); + } + } + } + false +} + /// Metadata for a single oneof variant. struct VariantInfo { variant_ident: proc_macro2::Ident, @@ -58,7 +160,9 @@ struct VariantInfo { field_type: Type, /// See [`is_null_value_field`]. is_null_value: bool, - /// True for message/group types (boxed in the owned enum). + /// Whether the variant is stored in a `Box` (see [`variant_boxed`]): + /// message/group types are boxed unless opted out via + /// `config.unboxed_oneof_fields`. is_boxed: bool, /// Custom attributes matched via `CodeGenConfig::field_attributes` on the /// variant's fully-qualified path (`{oneof_fqn}.{variant_proto_name}`). @@ -141,12 +245,28 @@ fn collect_variant_info( let variant_fqn = format!("{proto_fqn}.{oneof_name}.{proto_name}"); let custom_attrs = CodeGenContext::matching_attributes(&ctx.config.field_attributes, &variant_fqn)?; + // Reject opting a recursive variant out of boxing: stored inline the + // type would be unsized. Checked once here so the four downstream + // boxing sites can trust `variant_boxed`. + let dotted_fqn = format!(".{variant_fqn}"); + let is_boxed = variant_boxed(ctx, field_type, &dotted_fqn); + if is_boxed_variant(field_type) && !is_boxed { + if let Some(target) = field.type_name.as_deref() { + if unboxing_is_recursive(ctx, proto_fqn, target.trim_start_matches('.')) { + return Err(CodeGenError::Other(format!( + "oneof variant `{variant_fqn}` is recursive and cannot be \ + unboxed via unbox_oneof: storing it inline would make the \ + generated enum unsized. Drop the rule for this variant." + ))); + } + } + } Ok(VariantInfo { variant_ident, rust_type, json_name, field_type, - is_boxed: is_boxed_variant(field_type), + is_boxed, is_null_value: is_null_value_field(field), custom_attrs, use_bytes, @@ -239,12 +359,15 @@ pub fn generate_oneof_enum( // proto type names (so their string representations match). let mut type_counts: std::collections::HashMap = std::collections::HashMap::new(); - for v in variants_info.iter().filter(|v| v.is_boxed) { + for v in variants_info + .iter() + .filter(|v| is_boxed_variant(v.field_type)) + { *type_counts.entry(v.rust_type.to_string()).or_insert(0) += 1; } let from_impls: Vec<_> = variants_info .iter() - .filter(|v| v.is_boxed && type_counts[&v.rust_type.to_string()] == 1) + .filter(|v| is_boxed_variant(v.field_type) && type_counts[&v.rust_type.to_string()] == 1) .map(|v| { let ident = &v.variant_ident; let ty = &v.rust_type; @@ -257,11 +380,17 @@ pub fn generate_oneof_enum( // type in the impl header. `crate::…` is treated as local for // orphan purposes (it IS the current crate) so only `::` gates. let ty_is_extern = ty_str.trim_start().starts_with("::"); + // Unboxed variants store the value inline; boxed ones wrap it. + let wrapped = if v.is_boxed { + quote! { ::buffa::alloc::boxed::Box::new(v) } + } else { + quote! { v } + }; // From for Oneof — always legal (Oneof is local in T0 position). let from_oneof = quote! { impl From<#ty> for #rust_enum_ident { fn from(v: #ty) -> Self { - Self::#ident(::buffa::alloc::boxed::Box::new(v)) + Self::#ident(#wrapped) } } }; diff --git a/buffa-codegen/src/view.rs b/buffa-codegen/src/view.rs index 9057047..5a53506 100644 --- a/buffa-codegen/src/view.rs +++ b/buffa-codegen/src/view.rs @@ -1392,7 +1392,7 @@ fn build_to_owned_fields( .ok_or(CodeGenError::MissingField("field.name"))?; let variant = crate::oneof::oneof_variant_ident(fname); let ty = effective_type(ctx, f, features); - let conv = oneof_variant_to_owned(scope, ty, fname); + let conv = oneof_variant_to_owned(scope, ty, oneof_name, fname); Ok(quote! { #view_enum::#variant(v) => #owned_enum::#variant(#conv), }) @@ -1562,7 +1562,12 @@ fn map_to_owned_expr( }) } -fn oneof_variant_to_owned(scope: MessageScope<'_>, ty: Type, field_name: &str) -> TokenStream { +fn oneof_variant_to_owned( + scope: MessageScope<'_>, + ty: Type, + oneof_name: &str, + field_name: &str, +) -> TokenStream { let MessageScope { ctx, proto_fqn, .. } = scope; match ty { Type::TYPE_STRING => { @@ -1576,7 +1581,18 @@ fn oneof_variant_to_owned(scope: MessageScope<'_>, ty: Type, field_name: &str) - // match-ergonomics on &ViewEnum → v: &&[u8]. bytes_to_owned handles it. Type::TYPE_BYTES => bytes_to_owned(ctx, proto_fqn, field_name, quote! { v }), Type::TYPE_MESSAGE | Type::TYPE_GROUP => { - quote! { ::buffa::alloc::boxed::Box::new(v.to_owned_from_source(__buffa_src)) } + // The owned variant is boxed unless opted out; `v` derefs through + // the view's own `Box` either way, so only the wrapper differs. + let owned = quote! { v.to_owned_from_source(__buffa_src) }; + if crate::oneof::variant_boxed( + ctx, + ty, + &format!(".{proto_fqn}.{oneof_name}.{field_name}"), + ) { + quote! { ::buffa::alloc::boxed::Box::new(#owned) } + } else { + owned + } } _ => quote! { *v }, } diff --git a/buffa-codegen/tests/codegen_integration.rs b/buffa-codegen/tests/codegen_integration.rs index 334d86d..388a2c1 100644 --- a/buffa-codegen/tests/codegen_integration.rs +++ b/buffa-codegen/tests/codegen_integration.rs @@ -561,6 +561,82 @@ fn inline_oneof_duplicate_message_type_no_from_collision() { ); } +#[test] +fn inline_oneof_unbox_opt_out_drops_box() { + // unbox_oneof opts a non-recursive message variant out of Box wrapping. + // The opted-out variant stores its message inline; sibling message + // variants left alone stay boxed. + let mut config = no_views(); + config + .unboxed_oneof_fields + .push(".test.Envelope.body.small".to_string()); + let content = generate_proto( + r#" + syntax = "proto3"; + package test; + message Small { int32 value = 1; } + message Large { string label = 1; } + message Envelope { + oneof body { + Small small = 1; + Large large = 2; + } + } + "#, + &config, + ); + // `small` is stored inline, `large` stays boxed. + assert!( + content.contains("Small(super::super::super::Small)"), + "opted-out variant should be stored inline: {content}" + ); + assert!( + content.contains("Large(::buffa::alloc::boxed::Box)"), + "unmatched variant should stay boxed: {content}" + ); + // The From impl for the inline variant moves the value in without a Box. + assert!( + content.contains("Self::Small(v)"), + "From impl for the inline variant must not wrap in Box: {content}" + ); +} + +#[test] +fn inline_oneof_unbox_recursive_variant_is_rejected() { + // Opting a recursive variant out of boxing would make the enum unsized, + // so codegen must reject it rather than emit code that fails to compile. + let proto = dedent( + r#" + syntax = "proto3"; + package test; + message Node { + oneof kind { + Node child = 1; + int32 leaf = 2; + } + } + "#, + ); + let dir = tempfile::tempdir().expect("temp dir"); + let proto_path = dir.path().join("test.proto"); + std::fs::write(&proto_path, &proto).expect("write proto"); + let fds = compile_protos( + &[proto_path.to_str().unwrap()], + &[dir.path().to_str().unwrap()], + ); + + let mut config = no_views(); + config + .unboxed_oneof_fields + .push(".test.Node.kind.child".to_string()); + let result = buffa_codegen::generate(&fds.file, &["test.proto".into()], &config); + let err = result.expect_err("unboxing a recursive variant should error"); + assert!( + err.to_string().contains("recursive"), + "error should explain the recursion: {err}" + ); +} + #[test] fn inline_proto2_required_no_json_skip() { // Regression: proto2 required fields got skip_serializing_if, so a diff --git a/buffa-test/build.rs b/buffa-test/build.rs index 9d0766f..df476c4 100644 --- a/buffa-test/build.rs +++ b/buffa-test/build.rs @@ -35,6 +35,20 @@ fn main() { .compile() .expect("buffa_build failed for nested_deep.proto"); + // unbox_oneof — a non-recursive message oneof variant stored inline rather + // than behind a Box. `Envelope.body.small` is opted out; `large` stays + // boxed. Views + JSON + text all enabled so every boxing site is compiled + // for both shapes (enum decl, From impl, binary merge, JSON deser, text + // encode). Runtime round-trips live in `tests/unbox_oneof.rs`. + buffa_build::Config::new() + .files(&["protos/unbox_oneof.proto"]) + .includes(&["protos/"]) + .unbox_oneof_in(&[".unboxoneof.Envelope.body.small"]) + .generate_json(true) + .generate_text(true) + .compile() + .expect("buffa_build failed for unbox_oneof.proto"); + // WKT usage — well-known types are auto-mapped to buffa-types. buffa_build::Config::new() .files(&["protos/wkt_usage.proto"]) diff --git a/buffa-test/protos/unbox_oneof.proto b/buffa-test/protos/unbox_oneof.proto new file mode 100644 index 0000000..9ae32dd --- /dev/null +++ b/buffa-test/protos/unbox_oneof.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package unboxoneof; + +// Fixtures for unbox_oneof(): message-typed oneof variants stored inline +// instead of behind a Box. `Envelope.body` mixes an opted-out message variant +// (`small`), a still-boxed message variant (`large`), and a scalar (`number`) +// so encode/decode/merge/JSON/text all exercise both storage shapes. + +message Small { + int32 value = 1; +} + +message Large { + string label = 1; +} + +message Envelope { + oneof body { + Small small = 1; + Large large = 2; + int32 number = 3; + } +} diff --git a/buffa-test/src/lib.rs b/buffa-test/src/lib.rs index 1272734..e7dfb18 100644 --- a/buffa-test/src/lib.rs +++ b/buffa-test/src/lib.rs @@ -38,6 +38,14 @@ pub mod wkt { buffa::include_proto!("test.wkt"); } +// unbox_oneof: `Envelope.body.small` is stored inline (opted out of Box), +// `large` stays boxed. Compiling this module exercises every boxing site for +// both shapes; runtime round-trips live in `tests/unbox_oneof.rs`. +#[allow(clippy::derivable_impls, clippy::match_single_binding)] +pub mod unbox_oneof { + buffa::include_proto!("unboxoneof"); +} + #[allow(clippy::derivable_impls, clippy::match_single_binding)] pub mod cross { buffa::include_proto!("test.cross"); diff --git a/buffa-test/src/tests/mod.rs b/buffa-test/src/tests/mod.rs index 69c52fd..a96b97c 100644 --- a/buffa-test/src/tests/mod.rs +++ b/buffa-test/src/tests/mod.rs @@ -46,6 +46,7 @@ mod proto2; mod proto3_semantics; mod string_type; mod textproto; +mod unbox_oneof; mod utf8_validation; mod view; mod view_json; diff --git a/buffa-test/src/tests/unbox_oneof.rs b/buffa-test/src/tests/unbox_oneof.rs new file mode 100644 index 0000000..5fe10b4 --- /dev/null +++ b/buffa-test/src/tests/unbox_oneof.rs @@ -0,0 +1,98 @@ +//! unbox_oneof(): message-typed oneof variants stored inline instead of +//! behind a `Box`. +//! +//! `Envelope.body.small` is opted out (stored inline as `Small`); `large` +//! stays boxed (`Box`). These tests round-trip the inline variant +//! through binary, JSON, and text, exercise the `From` impl and merge +//! semantics, and confirm the boxed sibling still works. + +use crate::unbox_oneof::__buffa::oneof::envelope::Body; +use crate::unbox_oneof::{Envelope, Large, Small}; +use buffa::text::{decode_from_str, encode_to_string}; +use buffa::Message; + +fn small(value: i32) -> Small { + Small { + value, + ..Default::default() + } +} + +fn envelope_small(value: i32) -> Envelope { + Envelope { + body: Some(Body::Small(small(value))), + ..Default::default() + } +} + +#[test] +fn inline_variant_binary_roundtrip() { + // The opted-out variant holds `Small` directly (no `Box::new`). + let decoded = super::round_trip(&envelope_small(7)); + match decoded.body { + Some(Body::Small(s)) => assert_eq!(s.value, 7), + other => panic!("expected Body::Small, got {other:?}"), + } +} + +#[test] +fn boxed_sibling_variant_roundtrip() { + // A variant left alone is still boxed and round-trips unchanged. + let msg = Envelope { + body: Some(Body::Large(std::boxed::Box::new(Large { + label: "hello".to_string(), + ..Default::default() + }))), + ..Default::default() + }; + let decoded = super::round_trip(&msg); + match decoded.body { + Some(Body::Large(large)) => assert_eq!(large.label, "hello"), + other => panic!("expected Body::Large, got {other:?}"), + } +} + +#[test] +fn from_impl_stores_inline_value() { + // `From` moves the message in without wrapping it in a `Box`. + let body: Body = small(3).into(); + match body { + Body::Small(s) => assert_eq!(s.value, 3), + other => panic!("expected Body::Small, got {other:?}"), + } +} + +#[test] +fn inline_variant_merges_existing() { + // Two wire messages carrying the same message variant merge into one + // (proto3 oneof merge semantics), even when stored inline. + let mut concatenated = envelope_small(1).encode_to_vec(); + concatenated.extend_from_slice(&envelope_small(9).encode_to_vec()); + + let decoded = Envelope::decode(&mut concatenated.as_slice()).expect("decode"); + match decoded.body { + // Scalar fields take the last value on the wire after merge. + Some(Body::Small(s)) => assert_eq!(s.value, 9), + other => panic!("expected Body::Small, got {other:?}"), + } +} + +#[test] +fn inline_variant_json_roundtrip() { + let json = serde_json::to_string(&envelope_small(11)).expect("serialize"); + let decoded: Envelope = serde_json::from_str(&json).expect("deserialize"); + match decoded.body { + Some(Body::Small(s)) => assert_eq!(s.value, 11), + other => panic!("expected Body::Small, got {other:?}"), + } +} + +#[test] +fn inline_variant_text_roundtrip() { + let text = encode_to_string(&envelope_small(5)); + let decoded: Envelope = decode_from_str(&text).expect("decode_from_str"); + match decoded.body { + Some(Body::Small(s)) => assert_eq!(s.value, 5), + other => panic!("expected Body::Small, got {other:?}"), + } +}