From ec8e2d975860323ecc6b9c343163b81ae7013053 Mon Sep 17 00:00:00 2001 From: tison Date: Wed, 24 Jun 2026 01:19:48 +0800 Subject: [PATCH 1/8] feat: consider edge custom serde cases Signed-off-by: tison --- README.md | 4 +- serde-shape/src/lib.rs | 55 +++++++++++----- serde-shape/src/tests.rs | 10 +++ tests/integration/tests/env_vars.rs | 63 ++++++++++++++++++- .../env_vars__snapshots_config_shape.snap | 54 +++++++++++++++- .../env_vars__snapshots_env_options.snap | 6 +- 6 files changed, 168 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 2503a21..97557cc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ `serde-shape` reflects the shape of Serde serialization and deserialization at compile time. -It gives libraries and tools a lightweight graph of the Rust types, Serde names, field metadata, enum tagging, defaults, aliases, skips, and custom serializer/deserializer boundaries that make up a type's wire shape. +It gives libraries and tools a lightweight graph of the Rust types, Serde names, field metadata, enum tagging, defaults, aliases, union-like value alternatives, skips, and custom serializer/deserializer boundaries that make up a type's wire shape. ## Install @@ -48,7 +48,7 @@ Typical use cases: - checking how a serialized or deserialized shape changes across releases; - building schema exporters that start from Serde metadata. -`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. +`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::OneOf` for format-native alternatives that do not fit one Rust shape. You may use [`schemars`](https://docs.rs/schemars) for JSON Schema generation and validation. But `schemars` is not a general-purpose Serde shape reflection library, and it does not support all Serde attributes. `serde-shape` is designed to be a more complete and general-purpose reflection of Serde shapes. diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 71abd42..bd600af 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -17,7 +17,7 @@ //! `serde-shape` builds a lightweight graph that describes what a Rust type emits through Serde //! serialization and accepts through Serde deserialization. It does not run Serde, and it is not a //! full validation schema. Instead, it gives tools access to the same structural information that -//! Serde derives from Rust types and `#[serde(...)]` attributes. +//! Serde derives from Rust types and `#[serde(...)]` attributes, including union-like value shapes. //! //! Common uses are generating configuration reference docs, deriving environment-variable maps //! from config structs, documenting wire formats, and checking whether two versions of a type @@ -169,11 +169,14 @@ //! //! impl DeserializeShape for ByteSize { //! fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { -//! ShapeRef::String +//! ShapeRef::OneOf(vec![ShapeRef::String, ShapeRef::U64]) //! } //! } //! -//! assert_eq!(ByteSize::deserialize_shape().root, ShapeRef::String); +//! assert_eq!( +//! ByteSize::deserialize_shape().root, +//! ShapeRef::OneOf(vec![ShapeRef::String, ShapeRef::U64]) +//! ); //! ``` //! //! For recursive or shared named types, use [`SerializeShapeContext::define_named_type`] or @@ -529,6 +532,8 @@ pub enum ShapeRef { }, /// Tuple shape. Tuple(Vec), + /// One of multiple possible value shapes. + OneOf(Vec), /// Named type definition reference. Definition(ShapeId), /// Shape intentionally left opaque. @@ -538,33 +543,55 @@ pub enum ShapeRef { impl ShapeRef { /// Return whether this is a signed integer shape. pub fn is_signed_integer(&self) -> bool { - matches!( - self, - Self::I8 | Self::I16 | Self::I32 | Self::I64 | Self::I128 | Self::Isize - ) + match self { + Self::I8 | Self::I16 | Self::I32 | Self::I64 | Self::I128 | Self::Isize => true, + Self::OneOf(alternatives) => { + !alternatives.is_empty() && alternatives.iter().all(Self::is_signed_integer) + } + _ => false, + } } /// Return whether this is an unsigned integer shape. pub fn is_unsigned_integer(&self) -> bool { - matches!( - self, - Self::U8 | Self::U16 | Self::U32 | Self::U64 | Self::U128 | Self::Usize - ) + match self { + Self::U8 | Self::U16 | Self::U32 | Self::U64 | Self::U128 | Self::Usize => true, + Self::OneOf(alternatives) => { + !alternatives.is_empty() && alternatives.iter().all(Self::is_unsigned_integer) + } + _ => false, + } } /// Return whether this is any integer shape. pub fn is_integer(&self) -> bool { - self.is_signed_integer() || self.is_unsigned_integer() + match self { + Self::OneOf(alternatives) => { + !alternatives.is_empty() && alternatives.iter().all(Self::is_integer) + } + _ => self.is_signed_integer() || self.is_unsigned_integer(), + } } /// Return whether this is a floating point shape. pub fn is_float(&self) -> bool { - matches!(self, Self::F32 | Self::F64) + match self { + Self::F32 | Self::F64 => true, + Self::OneOf(alternatives) => { + !alternatives.is_empty() && alternatives.iter().all(Self::is_float) + } + _ => false, + } } /// Return whether this is any numeric shape. pub fn is_number(&self) -> bool { - self.is_integer() || self.is_float() + match self { + Self::OneOf(alternatives) => { + !alternatives.is_empty() && alternatives.iter().all(Self::is_number) + } + _ => self.is_integer() || self.is_float(), + } } } diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index a980814..09165db 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -19,6 +19,7 @@ use alloc::collections::BinaryHeap; use alloc::collections::LinkedList; use alloc::collections::VecDeque; use alloc::string::String; +use alloc::vec; use core::cell::Cell; use core::cmp::Reverse; use core::num::Wrapping; @@ -38,6 +39,15 @@ fn classifies_flat_numeric_shapes() { assert!(!ShapeRef::String.is_number()); } +#[test] +fn classifies_one_of_numeric_shapes() { + assert!(ShapeRef::OneOf(vec![ShapeRef::I8, ShapeRef::U64]).is_integer()); + assert!(ShapeRef::OneOf(vec![ShapeRef::F32, ShapeRef::F64]).is_float()); + assert!(ShapeRef::OneOf(vec![ShapeRef::I16, ShapeRef::F64]).is_number()); + assert!(!ShapeRef::OneOf(vec![ShapeRef::String, ShapeRef::U64]).is_integer()); + assert!(!ShapeRef::OneOf(vec![]).is_number()); +} + #[cfg(target_has_atomic = "ptr")] #[test] fn maps_atomic_shapes() { diff --git a/tests/integration/tests/env_vars.rs b/tests/integration/tests/env_vars.rs index 066955d..1499834 100644 --- a/tests/integration/tests/env_vars.rs +++ b/tests/integration/tests/env_vars.rs @@ -179,7 +179,7 @@ struct ByteSize(u64); impl DeserializeShape for ByteSize { fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { - ShapeRef::String + string_or_integer_shape() } } @@ -188,10 +188,28 @@ struct HumanDuration(u64); impl DeserializeShape for HumanDuration { fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { - ShapeRef::String + string_or_integer_shape() } } +fn string_or_integer_shape() -> ShapeRef { + ShapeRef::OneOf(vec![ + ShapeRef::String, + ShapeRef::I8, + ShapeRef::I16, + ShapeRef::I32, + ShapeRef::I64, + ShapeRef::I128, + ShapeRef::Isize, + ShapeRef::U8, + ShapeRef::U16, + ShapeRef::U32, + ShapeRef::U64, + ShapeRef::U128, + ShapeRef::Usize, + ]) +} + fn default_dir() -> PathBuf { PathBuf::from("/var/lib/percas") } @@ -255,6 +273,10 @@ impl EnvCollector<'_> { ShapeRef::Option(inner) => { self.visit_shape_ref(inner, path, true, condition); } + ShapeRef::OneOf(alternatives) => { + let value_kind = one_of_kind(alternatives); + self.push_leaf(path, &value_kind, optional, condition); + } ShapeRef::Definition(id) => { self.visit_definition(*id, path, optional, condition); } @@ -448,6 +470,8 @@ fn primitive_kind(shape_ref: &ShapeRef) -> &'static str { "integer" } else if shape_ref.is_float() { "float" + } else if shape_ref.is_number() { + "number" } else { match shape_ref { ShapeRef::Unit => "unit", @@ -458,6 +482,7 @@ fn primitive_kind(shape_ref: &ShapeRef) -> &'static str { | ShapeRef::Array { .. } | ShapeRef::Map { .. } | ShapeRef::Tuple(_) + | ShapeRef::OneOf(_) | ShapeRef::Definition(_) | ShapeRef::Opaque(_) => { unreachable!("compound shapes are handled before leaf mapping") @@ -467,6 +492,40 @@ fn primitive_kind(shape_ref: &ShapeRef) -> &'static str { } } +fn one_of_alternative_kind(shape_ref: &ShapeRef) -> String { + match shape_ref { + ShapeRef::Option(inner) => one_of_alternative_kind(inner), + ShapeRef::Seq(_) | ShapeRef::Array { .. } | ShapeRef::Tuple(_) => "array".to_owned(), + ShapeRef::Map { .. } | ShapeRef::Definition(_) => "object".to_owned(), + ShapeRef::OneOf(alternatives) => one_of_kind(alternatives), + ShapeRef::Opaque(opaque) => format!("opaque({:?})", opaque.reason), + shape_ref => primitive_kind(shape_ref).to_owned(), + } +} + +fn one_of_kind(alternatives: &[ShapeRef]) -> String { + if !alternatives.is_empty() && alternatives.iter().all(ShapeRef::is_integer) { + return "integer".to_owned(); + } + if !alternatives.is_empty() && alternatives.iter().all(ShapeRef::is_float) { + return "float".to_owned(); + } + if !alternatives.is_empty() && alternatives.iter().all(ShapeRef::is_number) { + return "number".to_owned(); + } + + alternatives + .iter() + .fold(Vec::::new(), |mut kinds, alternative| { + let kind = one_of_alternative_kind(alternative); + if !kinds.contains(&kind) { + kinds.push(kind); + } + kinds + }) + .join("|") +} + fn env_name(prefix: &str, path: &[String]) -> String { let path = path .iter() diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap index 3f13525..b7b4f29 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap @@ -259,7 +259,23 @@ DeserializeShapeGraph { "disk_capacity", ], value_shape: Some( - String, + OneOf( + [ + String, + I8, + I16, + I32, + I64, + I128, + Isize, + U8, + U16, + U32, + U64, + U128, + Usize, + ], + ), ), default: Path( "default_disk_capacity", @@ -278,7 +294,23 @@ DeserializeShapeGraph { "memory_capacity", ], value_shape: Some( - String, + OneOf( + [ + String, + I8, + I16, + I32, + I64, + I128, + Isize, + U8, + U16, + U32, + U64, + U128, + Usize, + ], + ), ), default: Path( "default_memory_capacity", @@ -1097,7 +1129,23 @@ DeserializeShapeGraph { "push_interval", ], value_shape: Some( - String, + OneOf( + [ + String, + I8, + I16, + I32, + I64, + I128, + Isize, + U8, + U16, + U32, + U64, + U128, + Usize, + ], + ), ), default: Path( "default_metrics_push_interval", diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap index e2e69a7..12913a5 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap @@ -75,7 +75,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_CAPACITY", config_path: "storage.disk_capacity", - value_kind: "string", + value_kind: "string|integer", optional: true, condition: None, }, @@ -110,7 +110,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_MEMORY_CAPACITY", config_path: "storage.memory_capacity", - value_kind: "string", + value_kind: "string|integer", optional: true, condition: None, }, @@ -165,7 +165,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_METRICS_OPENTELEMETRY_PUSH_INTERVAL", config_path: "telemetry.metrics.opentelemetry.push_interval", - value_kind: "string", + value_kind: "string|integer", optional: true, condition: None, }, From 0dbf7f4044639481357d27f8b9da68e3772bce67 Mon Sep 17 00:00:00 2001 From: tison Date: Thu, 25 Jun 2026 02:08:16 +0800 Subject: [PATCH 2/8] wire_shap Signed-off-by: tison --- README.md | 2 + serde-shape-derive/src/lib.rs | 38 ++++++-- serde-shape/src/lib.rs | 23 ++++- tests/derive/tests/derive.rs | 13 ++- ..._tagged_enum_shape_from_variant_attrs.snap | 4 +- ...ata_generic_field_without_shape_bound.snap | 2 +- ...sive_type_reusing_the_same_definition.snap | 2 +- ...ped_generic_field_without_shape_bound.snap | 2 +- ..._shape_from_container_and_field_attrs.snap | 24 +++-- ...e__snapshots_transparent_struct_shape.snap | 2 +- tests/integration/tests/env_vars.rs | 94 +++++++++++++++---- .../env_vars__snapshots_config_shape.snap | 68 +++++++------- ...shots_no_std_config_deserialize_shape.snap | 6 +- ...apshots_no_std_config_serialize_shape.snap | 6 +- 14 files changed, 198 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 97557cc..25ad90b 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Typical use cases: `serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::OneOf` for format-native alternatives that do not fit one Rust shape. +Field shapes expose `wire_shape` as the source of truth for regular values, flattened fields, omitted fields, and custom serializer/deserializer boundaries. + You may use [`schemars`](https://docs.rs/schemars) for JSON Schema generation and validation. But `schemars` is not a general-purpose Serde shape reflection library, and it does not support all Serde attributes. `serde-shape` is designed to be a more complete and general-purpose reflection of Serde shapes. ## Example diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index a091182..c220bcc 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -573,17 +573,28 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { let flatten = field.attrs.flatten(); let transparent = field.attrs.transparent(); let ty = field.ty; - let value_shape = if skip || custom_serializer { - quote!(::core::option::Option::None) + let wire_shape = if skip { + quote!(::serde_shape::FieldWireShape::Omitted) + } else if custom_serializer { + let detail = option_path(field.attrs.serialize_with()); + quote! { + ::serde_shape::FieldWireShape::Custom(::serde_shape::OpaqueShape { + type_name: ::core::any::type_name::<#ty>(), + reason: ::serde_shape::OpaqueReason::CustomSerializer, + detail: #detail, + }) + } + } else if flatten { + quote!(::serde_shape::FieldWireShape::Flatten(<#ty as ::serde_shape::SerializeShape>::serialize_shape_in(context))) } else { - quote!(::core::option::Option::Some(<#ty as ::serde_shape::SerializeShape>::serialize_shape_in(context))) + quote!(::serde_shape::FieldWireShape::Value(<#ty as ::serde_shape::SerializeShape>::serialize_shape_in(context))) }; quote! { ::serde_shape::SerializeFieldShape { member: #member, name: #name, - value_shape: #value_shape, + wire_shape: #wire_shape, flatten: #flatten, skip: #skip, skip_if: #skip_if, @@ -603,10 +614,21 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { let flatten = field.attrs.flatten(); let transparent = field.attrs.transparent(); let ty = field.ty; - let value_shape = if skip || custom_deserializer { - quote!(::core::option::Option::None) + let wire_shape = if skip { + quote!(::serde_shape::FieldWireShape::Omitted) + } else if custom_deserializer { + let detail = option_path(field.attrs.deserialize_with()); + quote! { + ::serde_shape::FieldWireShape::Custom(::serde_shape::OpaqueShape { + type_name: ::core::any::type_name::<#ty>(), + reason: ::serde_shape::OpaqueReason::CustomDeserializer, + detail: #detail, + }) + } + } else if flatten { + quote!(::serde_shape::FieldWireShape::Flatten(<#ty as ::serde_shape::DeserializeShape>::deserialize_shape_in(context))) } else { - quote!(::core::option::Option::Some(<#ty as ::serde_shape::DeserializeShape>::deserialize_shape_in(context))) + quote!(::serde_shape::FieldWireShape::Value(<#ty as ::serde_shape::DeserializeShape>::deserialize_shape_in(context))) }; quote! { @@ -614,7 +636,7 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { member: #member, name: #name, aliases: #aliases, - value_shape: #value_shape, + wire_shape: #wire_shape, default: #default, flatten: #flatten, skip: #skip, diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index bd600af..1b6fbb3 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -153,6 +153,8 @@ //! A custom serializer or deserializer has no inferable inner shape, so the affected field or //! variant is marked as custom and its nested shape is omitted. Whole-container conversion and //! remote-derive attributes are represented as opaque definitions. +//! Field-level [`FieldWireShape`] distinguishes ordinary values from flattened fields, omitted +//! fields, and opaque custom serializer/deserializer boundaries. //! //! # Manual implementations //! @@ -756,8 +758,8 @@ pub struct SerializeFieldShape { pub member: FieldMember, /// The primary Serde serialize name. pub name: &'static str, - /// The field output shape, or `None` when the field has no inferred output. - pub value_shape: Option, + /// How this field contributes to the serialized wire shape. + pub wire_shape: FieldWireShape, /// Whether the field is flattened into the containing map. pub flatten: bool, /// Whether Serde skips this field during serialization. @@ -779,8 +781,8 @@ pub struct DeserializeFieldShape { pub name: &'static str, /// All accepted Serde deserialize names, including the primary name. pub aliases: Vec<&'static str>, - /// The field input shape, or `None` when the field has no inferred input. - pub value_shape: Option, + /// How this field contributes to the deserialized wire shape. + pub wire_shape: FieldWireShape, /// The default used if this field is missing. pub default: DefaultShape, /// Whether the field is flattened into the containing map. @@ -802,6 +804,19 @@ pub enum FieldMember { Unnamed(usize), } +/// How a field contributes to the wire representation in one Serde direction. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FieldWireShape { + /// The field emits or accepts no value in this direction. + Omitted, + /// The field appears as a regular value at its Serde position. + Value(ShapeRef), + /// The field is flattened into the containing map. + Flatten(ShapeRef), + /// A custom serializer or deserializer controls the field representation. + Custom(OpaqueShape), +} + /// Variant-level serialization metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SerializeVariantShape { diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index dd7720f..7432b0c 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -17,6 +17,8 @@ use serde_shape::DeserializeDefinitionKind; use serde_shape::DeserializeShape; use serde_shape::FieldMember; +use serde_shape::FieldWireShape; +use serde_shape::OpaqueReason; use serde_shape::SerializeDefinitionKind; use serde_shape::SerializeShape; @@ -201,6 +203,7 @@ fn exposes_deserialize_field_metadata() { assert_eq!(id.aliases, vec!["in-id", "legacy-id"]); assert!(!id.skip); assert!(!id.custom_deserializer); + assert!(matches!(&id.wire_shape, FieldWireShape::Value(_))); assert_eq!(maybe.name, "maybe"); assert!(!maybe.skip); @@ -211,7 +214,7 @@ fn exposes_deserialize_field_metadata() { assert_eq!(output_only.name, "only-in"); assert!(output_only.skip); assert!(!output_only.custom_deserializer); - assert_eq!(output_only.value_shape, None); + assert_eq!(output_only.wire_shape, FieldWireShape::Omitted); } #[test] @@ -242,12 +245,16 @@ fn exposes_serialize_field_metadata() { assert_eq!(secret.name, "secret"); assert!(secret.skip); - assert_eq!(secret.value_shape, None); + assert_eq!(secret.wire_shape, FieldWireShape::Omitted); assert_eq!(output_only.name, "only-out"); assert!(!output_only.skip); assert!(output_only.custom_serializer); - assert_eq!(output_only.value_shape, None); + let FieldWireShape::Custom(opaque) = &output_only.wire_shape else { + panic!("custom serialized field should expose an opaque wire shape"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomSerializer); + assert_eq!(opaque.detail, Some("serialize_not_shape")); } #[test] diff --git a/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap b/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap index fff525b..a263249 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap @@ -40,7 +40,7 @@ DeserializeShapeGraph { aliases: [ "bucket-name", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -71,7 +71,7 @@ DeserializeShapeGraph { aliases: [ "container-name", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, diff --git a/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap b/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap index 120f284..03fd921 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap @@ -29,7 +29,7 @@ DeserializeShapeGraph { aliases: [ "marker", ], - value_shape: Some( + wire_shape: Value( Unit, ), default: None, diff --git a/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap b/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap index fd0718a..9d6dca2 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap @@ -29,7 +29,7 @@ DeserializeShapeGraph { aliases: [ "child", ], - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( diff --git a/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap b/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap index eb80108..24544d1 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap @@ -29,7 +29,7 @@ DeserializeShapeGraph { aliases: [ "value", ], - value_shape: None, + wire_shape: Omitted, default: Default, flatten: false, skip: true, diff --git a/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap b/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap index 7300e64..c92120e 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap @@ -29,7 +29,7 @@ DeserializeShapeGraph { aliases: [ "http-port", ], - value_shape: Some( + wire_shape: Value( U16, ), default: None, @@ -47,7 +47,7 @@ DeserializeShapeGraph { "api-url", "endpoint", ], - value_shape: Some( + wire_shape: Value( Option( String, ), @@ -66,7 +66,7 @@ DeserializeShapeGraph { aliases: [ "retries", ], - value_shape: Some( + wire_shape: Value( U8, ), default: Path( @@ -85,7 +85,7 @@ DeserializeShapeGraph { aliases: [ "storage", ], - value_shape: Some( + wire_shape: Flatten( Definition( ShapeId( 1, @@ -106,7 +106,7 @@ DeserializeShapeGraph { aliases: [ "skipped", ], - value_shape: None, + wire_shape: Omitted, default: None, flatten: false, skip: true, @@ -121,7 +121,15 @@ DeserializeShapeGraph { aliases: [ "secret", ], - value_shape: None, + wire_shape: Custom( + OpaqueShape { + type_name: "derive::NotShape", + reason: CustomDeserializer, + detail: Some( + "custom_secret", + ), + }, + ), default: None, flatten: false, skip: false, @@ -174,7 +182,7 @@ DeserializeShapeGraph { aliases: [ "bucket-name", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -205,7 +213,7 @@ DeserializeShapeGraph { aliases: [ "container-name", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, diff --git a/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap b/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap index 7477017..f9102d8 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap @@ -29,7 +29,7 @@ DeserializeShapeGraph { aliases: [ "0", ], - value_shape: Some( + wire_shape: Value( U64, ), default: None, diff --git a/tests/integration/tests/env_vars.rs b/tests/integration/tests/env_vars.rs index 1499834..56d16cc 100644 --- a/tests/integration/tests/env_vars.rs +++ b/tests/integration/tests/env_vars.rs @@ -25,6 +25,7 @@ use serde_shape::DeserializeShape; use serde_shape::DeserializeShapeContext; use serde_shape::DeserializeShapeGraph; use serde_shape::DeserializeStructShape; +use serde_shape::FieldWireShape; use serde_shape::FieldsStyle; use serde_shape::ShapeId; use serde_shape::ShapeRef; @@ -339,23 +340,23 @@ impl EnvCollector<'_> { match shape.style { FieldsStyle::Struct => { for field in &shape.fields { - let Some(field_shape) = &field.value_shape else { - continue; - }; let field_optional = optional || !field.default.is_none(); - if field.flatten { - self.visit_shape_ref(field_shape, path, field_optional, condition.clone()); - } else { - path.push(field.name.to_owned()); - self.visit_shape_ref(field_shape, path, field_optional, condition.clone()); - path.pop(); - } + self.visit_field_wire_shape( + field.name, + &field.wire_shape, + path, + field_optional, + condition.clone(), + ); } } FieldsStyle::Newtype if shape.fields.len() == 1 => { - if let Some(field_shape) = &shape.fields[0].value_shape { - self.visit_shape_ref(field_shape, path, optional, condition); - } + self.visit_newtype_wire_shape( + &shape.fields[0].wire_shape, + path, + optional, + condition, + ); } FieldsStyle::Tuple | FieldsStyle::Newtype | FieldsStyle::Unit => { self.push_leaf(path, "object", optional, condition); @@ -412,12 +413,13 @@ impl EnvCollector<'_> { )); for field in &variant.fields { - let Some(field_shape) = &field.value_shape else { - continue; - }; - path.push(field.name.to_owned()); - self.visit_shape_ref(field_shape, path, optional, variant_condition.clone()); - path.pop(); + self.visit_field_wire_shape( + field.name, + &field.wire_shape, + path, + optional, + variant_condition.clone(), + ); } } return; @@ -431,6 +433,60 @@ impl EnvCollector<'_> { ); } + fn visit_field_wire_shape( + &mut self, + field_name: &str, + wire_shape: &FieldWireShape, + path: &mut Vec, + optional: bool, + condition: Option, + ) { + match wire_shape { + FieldWireShape::Omitted => {} + FieldWireShape::Value(shape_ref) => { + path.push(field_name.to_owned()); + self.visit_shape_ref(shape_ref, path, optional, condition); + path.pop(); + } + FieldWireShape::Flatten(shape_ref) => { + self.visit_shape_ref(shape_ref, path, optional, condition); + } + FieldWireShape::Custom(opaque) => { + path.push(field_name.to_owned()); + self.push_leaf( + path, + &format!("opaque({:?})", opaque.reason), + optional, + condition, + ); + path.pop(); + } + } + } + + fn visit_newtype_wire_shape( + &mut self, + wire_shape: &FieldWireShape, + path: &mut Vec, + optional: bool, + condition: Option, + ) { + match wire_shape { + FieldWireShape::Omitted => {} + FieldWireShape::Value(shape_ref) | FieldWireShape::Flatten(shape_ref) => { + self.visit_shape_ref(shape_ref, path, optional, condition); + } + FieldWireShape::Custom(opaque) => { + self.push_leaf( + path, + &format!("opaque({:?})", opaque.reason), + optional, + condition, + ); + } + } + } + fn push_leaf( &mut self, path: &[String], diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap index b7b4f29..2305dc1 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap @@ -29,7 +29,7 @@ DeserializeShapeGraph { aliases: [ "server", ], - value_shape: Some( + wire_shape: Value( Definition( ShapeId( 1, @@ -50,7 +50,7 @@ DeserializeShapeGraph { aliases: [ "storage", ], - value_shape: Some( + wire_shape: Value( Definition( ShapeId( 2, @@ -71,7 +71,7 @@ DeserializeShapeGraph { aliases: [ "telemetry", ], - value_shape: Some( + wire_shape: Value( Definition( ShapeId( 7, @@ -117,7 +117,7 @@ DeserializeShapeGraph { aliases: [ "dir", ], - value_shape: Some( + wire_shape: Value( String, ), default: Path( @@ -136,7 +136,7 @@ DeserializeShapeGraph { aliases: [ "listen_data_addr", ], - value_shape: Some( + wire_shape: Value( String, ), default: Path( @@ -155,7 +155,7 @@ DeserializeShapeGraph { aliases: [ "advertise_data_addr", ], - value_shape: Some( + wire_shape: Value( Option( String, ), @@ -174,7 +174,7 @@ DeserializeShapeGraph { aliases: [ "initial_peers", ], - value_shape: Some( + wire_shape: Value( Seq( String, ), @@ -193,7 +193,7 @@ DeserializeShapeGraph { aliases: [ "cluster_id", ], - value_shape: Some( + wire_shape: Value( String, ), default: Path( @@ -237,7 +237,7 @@ DeserializeShapeGraph { aliases: [ "backend", ], - value_shape: Some( + wire_shape: Value( Definition( ShapeId( 3, @@ -258,7 +258,7 @@ DeserializeShapeGraph { aliases: [ "disk_capacity", ], - value_shape: Some( + wire_shape: Value( OneOf( [ String, @@ -293,7 +293,7 @@ DeserializeShapeGraph { aliases: [ "memory_capacity", ], - value_shape: Some( + wire_shape: Value( OneOf( [ String, @@ -328,7 +328,7 @@ DeserializeShapeGraph { aliases: [ "disk_throttle", ], - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( @@ -386,7 +386,7 @@ DeserializeShapeGraph { aliases: [ "data_dir", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -417,7 +417,7 @@ DeserializeShapeGraph { aliases: [ "bucket", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -434,7 +434,7 @@ DeserializeShapeGraph { aliases: [ "region", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -484,7 +484,7 @@ DeserializeShapeGraph { aliases: [ "read_iops", ], - value_shape: Some( + wire_shape: Value( U64, ), default: None, @@ -501,7 +501,7 @@ DeserializeShapeGraph { aliases: [ "write_iops", ], - value_shape: Some( + wire_shape: Value( U64, ), default: None, @@ -518,7 +518,7 @@ DeserializeShapeGraph { aliases: [ "iops_counter", ], - value_shape: Some( + wire_shape: Value( Definition( ShapeId( 5, @@ -564,7 +564,7 @@ DeserializeShapeGraph { aliases: [ "mode", ], - value_shape: Some( + wire_shape: Value( Definition( ShapeId( 6, @@ -585,7 +585,7 @@ DeserializeShapeGraph { aliases: [ "size", ], - value_shape: Some( + wire_shape: Value( Usize, ), default: None, @@ -678,7 +678,7 @@ DeserializeShapeGraph { aliases: [ "logs", ], - value_shape: Some( + wire_shape: Value( Definition( ShapeId( 8, @@ -699,7 +699,7 @@ DeserializeShapeGraph { aliases: [ "traces", ], - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( @@ -722,7 +722,7 @@ DeserializeShapeGraph { aliases: [ "metrics", ], - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( @@ -770,7 +770,7 @@ DeserializeShapeGraph { aliases: [ "sink", ], - value_shape: Some( + wire_shape: Flatten( Definition( ShapeId( 9, @@ -791,7 +791,7 @@ DeserializeShapeGraph { aliases: [ "filter", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -843,7 +843,7 @@ DeserializeShapeGraph { aliases: [ "dir", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -860,7 +860,7 @@ DeserializeShapeGraph { aliases: [ "max_files", ], - value_shape: Some( + wire_shape: Value( Option( Usize, ), @@ -906,7 +906,7 @@ DeserializeShapeGraph { aliases: [ "otlp_endpoint", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -956,7 +956,7 @@ DeserializeShapeGraph { aliases: [ "capture_log_filter", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -973,7 +973,7 @@ DeserializeShapeGraph { aliases: [ "opentelemetry", ], - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( @@ -1021,7 +1021,7 @@ DeserializeShapeGraph { aliases: [ "otlp_endpoint", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -1063,7 +1063,7 @@ DeserializeShapeGraph { aliases: [ "opentelemetry", ], - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( @@ -1111,7 +1111,7 @@ DeserializeShapeGraph { aliases: [ "otlp_endpoint", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -1128,7 +1128,7 @@ DeserializeShapeGraph { aliases: [ "push_interval", ], - value_shape: Some( + wire_shape: Value( OneOf( [ String, diff --git a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap index ecb6b9f..4f74e98 100644 --- a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap +++ b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap @@ -29,7 +29,7 @@ DeserializeShapeGraph { aliases: [ "name", ], - value_shape: Some( + wire_shape: Value( String, ), default: None, @@ -46,7 +46,7 @@ DeserializeShapeGraph { aliases: [ "values", ], - value_shape: Some( + wire_shape: Value( Seq( Option( U16, @@ -67,7 +67,7 @@ DeserializeShapeGraph { aliases: [ "child", ], - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( diff --git a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap index 559a0c4..28e3f54 100644 --- a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap +++ b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap @@ -26,7 +26,7 @@ SerializeShapeGraph { "name", ), name: "name", - value_shape: Some( + wire_shape: Value( String, ), flatten: false, @@ -40,7 +40,7 @@ SerializeShapeGraph { "values", ), name: "values", - value_shape: Some( + wire_shape: Value( Seq( Option( U16, @@ -58,7 +58,7 @@ SerializeShapeGraph { "child", ), name: "child", - value_shape: Some( + wire_shape: Value( Option( Definition( ShapeId( From 290c9af24bc950d87aea3f83234fbe323aa5a522 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 25 Jul 2026 22:55:59 +0800 Subject: [PATCH 3/8] fix: make wire shapes composable Normalize union alternatives, encode field placement independently from opaque custom boundaries, and retain custom variant content metadata. Add real Serde compatibility tests for flatten, transparent fields, and custom variants. Signed-off-by: tison --- Cargo.lock | 80 +++- Cargo.toml | 2 + README.md | 6 +- serde-shape-derive/src/lib.rs | 124 +++--- serde-shape/src/lib.rs | 174 ++++++--- serde-shape/src/tests.rs | 36 +- tests/derive/Cargo.toml | 2 + tests/derive/tests/derive.rs | 50 +-- tests/derive/tests/serde_compat.rs | 277 ++++++++++++++ ..._tagged_enum_shape_from_variant_attrs.snap | 82 ++-- ...ata_generic_field_without_shape_bound.snap | 4 - ...sive_type_reusing_the_same_definition.snap | 4 - ...ped_generic_field_without_shape_bound.snap | 4 - ..._shape_from_container_and_field_attrs.snap | 124 +++--- ...e__snapshots_transparent_struct_shape.snap | 6 +- tests/integration/tests/env_vars.rs | 178 +++++---- .../env_vars__snapshots_config_shape.snap | 356 ++++++------------ .../env_vars__snapshots_env_options.snap | 6 +- ...shots_no_std_config_deserialize_shape.snap | 12 - ...apshots_no_std_config_serialize_shape.snap | 12 - 20 files changed, 920 insertions(+), 619 deletions(-) create mode 100644 tests/derive/tests/serde_compat.rs diff --git a/Cargo.lock b/Cargo.lock index ba116e3..a471cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,7 +95,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -178,6 +178,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.186" @@ -190,6 +196,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "once_cell" version = "1.21.4" @@ -239,6 +251,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + [[package]] name = "serde-shape" version = "0.0.1" @@ -253,7 +275,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.118", ] [[package]] @@ -261,7 +283,9 @@ name = "serde-shape-test-derive" version = "0.0.0" dependencies = [ "insta", + "serde", "serde-shape", + "serde_json", ] [[package]] @@ -280,6 +304,26 @@ dependencies = [ "serde-shape", ] +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_derive_internals" version = "0.29.1" @@ -288,7 +332,20 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] @@ -314,6 +371,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -370,3 +438,9 @@ dependencies = [ "clap", "which", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 1caacc7..c65c2df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,9 @@ clap = { version = "4.6.1" } insta = { version = "1.48.0" } proc-macro2 = { version = "1.0.95" } quote = { version = "1.0.40" } +serde = { version = "1.0.229", features = ["derive"] } serde_derive_internals = { version = "0.29.1" } +serde_json = { version = "1.0.151" } syn = { version = "2.0.104" } which = { version = "8.0.4" } diff --git a/README.md b/README.md index 25ad90b..8e9693a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ `serde-shape` reflects the shape of Serde serialization and deserialization at compile time. -It gives libraries and tools a lightweight graph of the Rust types, Serde names, field metadata, enum tagging, defaults, aliases, union-like value alternatives, skips, and custom serializer/deserializer boundaries that make up a type's wire shape. +It gives libraries and tools a lightweight graph of the Rust types, Serde names, field metadata, enum tagging, defaults, aliases, union value alternatives, skips, and custom serializer/deserializer boundaries that make up a type's wire shape. ## Install @@ -48,9 +48,9 @@ Typical use cases: - checking how a serialized or deserialized shape changes across releases; - building schema exporters that start from Serde metadata. -`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::OneOf` for format-native alternatives that do not fit one Rust shape. +`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::union` for format-native alternatives that do not fit one Rust shape. Union alternatives may overlap; they are flattened, deduplicated, and stored in canonical order. -Field shapes expose `wire_shape` as the source of truth for regular values, flattened fields, omitted fields, and custom serializer/deserializer boundaries. +Field shapes expose `wire_shape` as the source of truth for regular values, flattened fields, inline transparent fields, and omitted fields. Custom serializer/deserializer boundaries are represented by `ShapeRef::Opaque`, including when they are flattened or inline. You may use [`schemars`](https://docs.rs/schemars) for JSON Schema generation and validation. But `schemars` is not a general-purpose Serde shape reflection library, and it does not support all Serde attributes. `serde-shape` is designed to be a more complete and general-purpose reflection of Serde shapes. diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index c220bcc..f7d7231 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -513,12 +513,25 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { let name = lit(variant.attrs.name().serialize_name()); let style = fields_style(variant.style); let skip = variant.attrs.skip_serializing(); - let custom_serializer = variant.attrs.serialize_with().is_some(); let untagged = variant.attrs.untagged(); - let fields: Vec<_> = if skip || custom_serializer { - Vec::new() + let content = if skip { + quote!(::serde_shape::SerializeVariantContent::Omitted) + } else if let Some(custom_serializer) = variant.attrs.serialize_with() { + let detail = option_path(Some(custom_serializer)); + quote! { + ::serde_shape::SerializeVariantContent::Custom(::serde_shape::OpaqueShape { + type_name: ::core::any::type_name::(), + reason: ::serde_shape::OpaqueReason::CustomSerializer, + detail: #detail, + }) + } } else { - variant.fields.iter().map(serialize_field_shape).collect() + let fields = variant.fields.iter().map(serialize_field_shape); + quote! { + ::serde_shape::SerializeVariantContent::Fields( + ::serde_shape::__private::vec![#(#fields),*], + ) + } }; quote! { @@ -526,9 +539,7 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { rust_name: #rust_name, name: #name, style: #style, - fields: ::serde_shape::__private::vec![#(#fields),*], - skip: #skip, - custom_serializer: #custom_serializer, + content: #content, untagged: #untagged, } } @@ -540,13 +551,26 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { let aliases = aliases(variant.attrs.aliases()); let style = fields_style(variant.style); let skip = variant.attrs.skip_deserializing(); - let custom_deserializer = variant.attrs.deserialize_with().is_some(); let other = variant.attrs.other(); let untagged = variant.attrs.untagged(); - let fields: Vec<_> = if skip || custom_deserializer { - Vec::new() + let content = if skip { + quote!(::serde_shape::DeserializeVariantContent::Omitted) + } else if let Some(custom_deserializer) = variant.attrs.deserialize_with() { + let detail = option_path(Some(custom_deserializer)); + quote! { + ::serde_shape::DeserializeVariantContent::Custom(::serde_shape::OpaqueShape { + type_name: ::core::any::type_name::(), + reason: ::serde_shape::OpaqueReason::CustomDeserializer, + detail: #detail, + }) + } } else { - variant.fields.iter().map(deserialize_field_shape).collect() + let fields = variant.fields.iter().map(deserialize_field_shape); + quote! { + ::serde_shape::DeserializeVariantContent::Fields( + ::serde_shape::__private::vec![#(#fields),*], + ) + } }; quote! { @@ -555,9 +579,7 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { name: #name, aliases: #aliases, style: #style, - fields: ::serde_shape::__private::vec![#(#fields),*], - skip: #skip, - custom_deserializer: #custom_deserializer, + content: #content, other: #other, untagged: #untagged, } @@ -569,25 +591,32 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { let name = lit(field.attrs.name().serialize_name()); let skip = field.attrs.skip_serializing(); let skip_if = option_path(field.attrs.skip_serializing_if()); - let custom_serializer = field.attrs.serialize_with().is_some(); let flatten = field.attrs.flatten(); let transparent = field.attrs.transparent(); let ty = field.ty; let wire_shape = if skip { quote!(::serde_shape::FieldWireShape::Omitted) - } else if custom_serializer { - let detail = option_path(field.attrs.serialize_with()); - quote! { - ::serde_shape::FieldWireShape::Custom(::serde_shape::OpaqueShape { - type_name: ::core::any::type_name::<#ty>(), - reason: ::serde_shape::OpaqueReason::CustomSerializer, - detail: #detail, - }) - } - } else if flatten { - quote!(::serde_shape::FieldWireShape::Flatten(<#ty as ::serde_shape::SerializeShape>::serialize_shape_in(context))) } else { - quote!(::serde_shape::FieldWireShape::Value(<#ty as ::serde_shape::SerializeShape>::serialize_shape_in(context))) + let value_shape = if let Some(custom_serializer) = field.attrs.serialize_with() { + let detail = option_path(Some(custom_serializer)); + quote! { + ::serde_shape::ShapeRef::Opaque(::serde_shape::OpaqueShape { + type_name: ::core::any::type_name::<#ty>(), + reason: ::serde_shape::OpaqueReason::CustomSerializer, + detail: #detail, + }) + } + } else { + quote!(<#ty as ::serde_shape::SerializeShape>::serialize_shape_in(context)) + }; + + if transparent { + quote!(::serde_shape::FieldWireShape::Inline(#value_shape)) + } else if flatten { + quote!(::serde_shape::FieldWireShape::Flatten(#value_shape)) + } else { + quote!(::serde_shape::FieldWireShape::Value(#value_shape)) + } }; quote! { @@ -595,11 +624,7 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { member: #member, name: #name, wire_shape: #wire_shape, - flatten: #flatten, - skip: #skip, skip_if: #skip_if, - custom_serializer: #custom_serializer, - transparent: #transparent, } } } @@ -609,26 +634,33 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { let name = lit(field.attrs.name().deserialize_name()); let aliases = aliases(field.attrs.aliases()); let skip = field.attrs.skip_deserializing(); - let custom_deserializer = field.attrs.deserialize_with().is_some(); let default = default_shape(field.attrs.default()); let flatten = field.attrs.flatten(); let transparent = field.attrs.transparent(); let ty = field.ty; let wire_shape = if skip { quote!(::serde_shape::FieldWireShape::Omitted) - } else if custom_deserializer { - let detail = option_path(field.attrs.deserialize_with()); - quote! { - ::serde_shape::FieldWireShape::Custom(::serde_shape::OpaqueShape { - type_name: ::core::any::type_name::<#ty>(), - reason: ::serde_shape::OpaqueReason::CustomDeserializer, - detail: #detail, - }) - } - } else if flatten { - quote!(::serde_shape::FieldWireShape::Flatten(<#ty as ::serde_shape::DeserializeShape>::deserialize_shape_in(context))) } else { - quote!(::serde_shape::FieldWireShape::Value(<#ty as ::serde_shape::DeserializeShape>::deserialize_shape_in(context))) + let value_shape = if let Some(custom_deserializer) = field.attrs.deserialize_with() { + let detail = option_path(Some(custom_deserializer)); + quote! { + ::serde_shape::ShapeRef::Opaque(::serde_shape::OpaqueShape { + type_name: ::core::any::type_name::<#ty>(), + reason: ::serde_shape::OpaqueReason::CustomDeserializer, + detail: #detail, + }) + } + } else { + quote!(<#ty as ::serde_shape::DeserializeShape>::deserialize_shape_in(context)) + }; + + if transparent { + quote!(::serde_shape::FieldWireShape::Inline(#value_shape)) + } else if flatten { + quote!(::serde_shape::FieldWireShape::Flatten(#value_shape)) + } else { + quote!(::serde_shape::FieldWireShape::Value(#value_shape)) + } }; quote! { @@ -638,10 +670,6 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { aliases: #aliases, wire_shape: #wire_shape, default: #default, - flatten: #flatten, - skip: #skip, - custom_deserializer: #custom_deserializer, - transparent: #transparent, } } } diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 1b6fbb3..579bc73 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -17,7 +17,7 @@ //! `serde-shape` builds a lightweight graph that describes what a Rust type emits through Serde //! serialization and accepts through Serde deserialization. It does not run Serde, and it is not a //! full validation schema. Instead, it gives tools access to the same structural information that -//! Serde derives from Rust types and `#[serde(...)]` attributes, including union-like value shapes. +//! Serde derives from Rust types and `#[serde(...)]` attributes, including union value shapes. //! //! Common uses are generating configuration reference docs, deriving environment-variable maps //! from config structs, documenting wire formats, and checking whether two versions of a type @@ -151,10 +151,11 @@ //! follows the metadata Serde derives for each direction. //! //! A custom serializer or deserializer has no inferable inner shape, so the affected field or -//! variant is marked as custom and its nested shape is omitted. Whole-container conversion and +//! variant content is represented by an opaque boundary. Whole-container conversion and //! remote-derive attributes are represented as opaque definitions. -//! Field-level [`FieldWireShape`] distinguishes ordinary values from flattened fields, omitted -//! fields, and opaque custom serializer/deserializer boundaries. +//! Field-level [`FieldWireShape`] distinguishes ordinary values from flattened fields, inline +//! transparent fields, and omitted fields. Custom serializer/deserializer boundaries use +//! [`ShapeRef::Opaque`] and remain composable with those field positions. //! //! # Manual implementations //! @@ -171,13 +172,13 @@ //! //! impl DeserializeShape for ByteSize { //! fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { -//! ShapeRef::OneOf(vec![ShapeRef::String, ShapeRef::U64]) +//! ShapeRef::union([ShapeRef::String, ShapeRef::U64]) //! } //! } //! //! assert_eq!( //! ByteSize::deserialize_shape().root, -//! ShapeRef::OneOf(vec![ShapeRef::String, ShapeRef::U64]) +//! ShapeRef::union([ShapeRef::String, ShapeRef::U64]) //! ); //! ``` //! @@ -196,6 +197,7 @@ extern crate std; use alloc::boxed::Box; use alloc::collections::BTreeMap; use alloc::vec::Vec; +use core::fmt; /// Private exports used by generated derive code. #[doc(hidden)] @@ -474,7 +476,8 @@ pub struct DeserializeTypeName { } /// A reference to a shape node. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] pub enum ShapeRef { /// Unit shape. Unit, @@ -534,8 +537,10 @@ pub enum ShapeRef { }, /// Tuple shape. Tuple(Vec), - /// One of multiple possible value shapes. - OneOf(Vec), + /// A normalized union of two or more possible value shapes. + /// + /// Construct unions with [`ShapeRef::union`] or [`ShapeRef::try_union`]. + Union(UnionShape), /// Named type definition reference. Definition(ShapeId), /// Shape intentionally left opaque. @@ -543,13 +548,54 @@ pub enum ShapeRef { } impl ShapeRef { + /// Build a normalized union from one or more possible value shapes. + /// + /// Nested unions are flattened, duplicate alternatives are removed, and alternatives are + /// sorted into a canonical order. A single distinct alternative is returned directly. + /// + /// # Panics + /// + /// Panics when `alternatives` is empty. Use [`ShapeRef::try_union`] when the input may be + /// empty. + pub fn union(alternatives: I) -> Self + where + I: IntoIterator, + { + Self::try_union(alternatives).expect("shape union requires at least one alternative") + } + + /// Try to build a normalized union from possible value shapes. + /// + /// Returns `None` when `alternatives` is empty. Nested unions are flattened, duplicate + /// alternatives are removed, and alternatives are sorted into a canonical order. A single + /// distinct alternative is returned directly. + pub fn try_union(alternatives: I) -> Option + where + I: IntoIterator, + { + let mut normalized = Vec::new(); + for alternative in alternatives { + match alternative { + Self::Union(union) => normalized.extend(union.alternatives), + alternative => normalized.push(alternative), + } + } + let mut alternatives = normalized; + alternatives.sort(); + alternatives.dedup(); + + match alternatives.len() { + 0 => None, + 1 => alternatives.pop(), + _ => Some(Self::Union(UnionShape { alternatives })), + } + } + /// Return whether this is a signed integer shape. pub fn is_signed_integer(&self) -> bool { match self { Self::I8 | Self::I16 | Self::I32 | Self::I64 | Self::I128 | Self::Isize => true, - Self::OneOf(alternatives) => { - !alternatives.is_empty() && alternatives.iter().all(Self::is_signed_integer) - } + Self::Union(union) => union.alternatives.iter().all(Self::is_signed_integer), _ => false, } } @@ -558,9 +604,7 @@ impl ShapeRef { pub fn is_unsigned_integer(&self) -> bool { match self { Self::U8 | Self::U16 | Self::U32 | Self::U64 | Self::U128 | Self::Usize => true, - Self::OneOf(alternatives) => { - !alternatives.is_empty() && alternatives.iter().all(Self::is_unsigned_integer) - } + Self::Union(union) => union.alternatives.iter().all(Self::is_unsigned_integer), _ => false, } } @@ -568,9 +612,7 @@ impl ShapeRef { /// Return whether this is any integer shape. pub fn is_integer(&self) -> bool { match self { - Self::OneOf(alternatives) => { - !alternatives.is_empty() && alternatives.iter().all(Self::is_integer) - } + Self::Union(union) => union.alternatives.iter().all(Self::is_integer), _ => self.is_signed_integer() || self.is_unsigned_integer(), } } @@ -579,9 +621,7 @@ impl ShapeRef { pub fn is_float(&self) -> bool { match self { Self::F32 | Self::F64 => true, - Self::OneOf(alternatives) => { - !alternatives.is_empty() && alternatives.iter().all(Self::is_float) - } + Self::Union(union) => union.alternatives.iter().all(Self::is_float), _ => false, } } @@ -589,14 +629,35 @@ impl ShapeRef { /// Return whether this is any numeric shape. pub fn is_number(&self) -> bool { match self { - Self::OneOf(alternatives) => { - !alternatives.is_empty() && alternatives.iter().all(Self::is_number) - } + Self::Union(union) => union.alternatives.iter().all(Self::is_number), _ => self.is_integer() || self.is_float(), } } } +/// The normalized alternatives contained by [`ShapeRef::Union`]. +/// +/// A union always contains at least two distinct alternatives in canonical order. Use +/// [`ShapeRef::union`] or [`ShapeRef::try_union`] to construct one. Alternatives may overlap; a +/// union means that any alternative is possible, not that exactly one alternative must match. +#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct UnionShape { + alternatives: Vec, +} + +impl UnionShape { + /// Return the canonical union alternatives. + pub fn alternatives(&self) -> &[ShapeRef] { + &self.alternatives + } +} + +impl fmt::Debug for UnionShape { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.alternatives.fmt(formatter) + } +} + /// A named type definition in a serialization shape graph. #[derive(Clone, Debug, Eq, PartialEq)] pub struct SerializeDefinitionShape { @@ -760,16 +821,8 @@ pub struct SerializeFieldShape { pub name: &'static str, /// How this field contributes to the serialized wire shape. pub wire_shape: FieldWireShape, - /// Whether the field is flattened into the containing map. - pub flatten: bool, - /// Whether Serde skips this field during serialization. - pub skip: bool, /// The predicate used to skip this field during serialization. pub skip_if: Option<&'static str>, - /// Whether this field uses a custom serializer. - pub custom_serializer: bool, - /// Whether this is the transparent field of a transparent container. - pub transparent: bool, } /// Field-level deserialization metadata. @@ -785,14 +838,6 @@ pub struct DeserializeFieldShape { pub wire_shape: FieldWireShape, /// The default used if this field is missing. pub default: DefaultShape, - /// Whether the field is flattened into the containing map. - pub flatten: bool, - /// Whether Serde skips this field during deserialization. - pub skip: bool, - /// Whether this field uses a custom deserializer. - pub custom_deserializer: bool, - /// Whether this is the transparent field of a transparent container. - pub transparent: bool, } /// The Rust member represented by a field. @@ -806,6 +851,7 @@ pub enum FieldMember { /// How a field contributes to the wire representation in one Serde direction. #[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] pub enum FieldWireShape { /// The field emits or accepts no value in this direction. Omitted, @@ -813,8 +859,8 @@ pub enum FieldWireShape { Value(ShapeRef), /// The field is flattened into the containing map. Flatten(ShapeRef), - /// A custom serializer or deserializer controls the field representation. - Custom(OpaqueShape), + /// The field is serialized or deserialized directly at the containing type's position. + Inline(ShapeRef), } /// Variant-level serialization metadata. @@ -826,16 +872,24 @@ pub struct SerializeVariantShape { pub name: &'static str, /// The variant field style. pub style: FieldsStyle, - /// The variant fields, if their output shape can be inferred. - pub fields: Vec, - /// Whether Serde skips this variant during serialization. - pub skip: bool, - /// Whether this variant uses a custom serializer. - pub custom_serializer: bool, + /// How the variant contributes its serialized content. + pub content: SerializeVariantContent, /// Whether this variant is individually marked untagged. pub untagged: bool, } +/// The serialized content controlled by an enum variant. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SerializeVariantContent { + /// The variant is omitted during serialization. + Omitted, + /// Serde derives the variant content from these fields. + Fields(Vec), + /// A custom serializer controls the variant content. + Custom(OpaqueShape), +} + /// Variant-level deserialization metadata. #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeserializeVariantShape { @@ -847,18 +901,26 @@ pub struct DeserializeVariantShape { pub aliases: Vec<&'static str>, /// The variant field style. pub style: FieldsStyle, - /// The variant fields, if their input shape can be inferred. - pub fields: Vec, - /// Whether Serde skips this variant during deserialization. - pub skip: bool, - /// Whether this variant uses a custom deserializer. - pub custom_deserializer: bool, + /// How the variant contributes its deserialized content. + pub content: DeserializeVariantContent, /// Whether this is a Serde `other` catch-all variant. pub other: bool, /// Whether this variant is individually marked untagged. pub untagged: bool, } +/// The deserialized content controlled by an enum variant. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum DeserializeVariantContent { + /// The variant is omitted during deserialization. + Omitted, + /// Serde derives the variant content from these fields. + Fields(Vec), + /// A custom deserializer controls the variant content. + Custom(OpaqueShape), +} + /// A Serde default marker. #[derive(Clone, Debug, Eq, PartialEq)] pub enum DefaultShape { @@ -878,7 +940,7 @@ impl DefaultShape { } /// Shape intentionally left opaque. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct OpaqueShape { /// The Rust type or Serde item that is opaque. pub type_name: &'static str, @@ -889,7 +951,7 @@ pub struct OpaqueShape { } /// Reason a shape cannot be represented precisely. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub enum OpaqueReason { /// The type uses `#[serde(from = "...")]`. FromType, diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index 09165db..fd743e5 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -19,7 +19,6 @@ use alloc::collections::BinaryHeap; use alloc::collections::LinkedList; use alloc::collections::VecDeque; use alloc::string::String; -use alloc::vec; use core::cell::Cell; use core::cmp::Reverse; use core::num::Wrapping; @@ -40,12 +39,35 @@ fn classifies_flat_numeric_shapes() { } #[test] -fn classifies_one_of_numeric_shapes() { - assert!(ShapeRef::OneOf(vec![ShapeRef::I8, ShapeRef::U64]).is_integer()); - assert!(ShapeRef::OneOf(vec![ShapeRef::F32, ShapeRef::F64]).is_float()); - assert!(ShapeRef::OneOf(vec![ShapeRef::I16, ShapeRef::F64]).is_number()); - assert!(!ShapeRef::OneOf(vec![ShapeRef::String, ShapeRef::U64]).is_integer()); - assert!(!ShapeRef::OneOf(vec![]).is_number()); +fn classifies_union_numeric_shapes() { + assert!(ShapeRef::union([ShapeRef::I8, ShapeRef::U64]).is_integer()); + assert!(ShapeRef::union([ShapeRef::F32, ShapeRef::F64]).is_float()); + assert!(ShapeRef::union([ShapeRef::I16, ShapeRef::F64]).is_number()); + assert!(!ShapeRef::union([ShapeRef::String, ShapeRef::U64]).is_integer()); +} + +#[test] +fn normalizes_union_shapes() { + assert_eq!(ShapeRef::try_union([]), None); + assert_eq!(ShapeRef::union([ShapeRef::String]), ShapeRef::String); + assert_eq!( + ShapeRef::union([ShapeRef::String, ShapeRef::I8]), + ShapeRef::union([ShapeRef::I8, ShapeRef::String]) + ); + + let union = ShapeRef::union([ + ShapeRef::String, + ShapeRef::I8, + ShapeRef::union([ShapeRef::U64, ShapeRef::String]), + ShapeRef::I8, + ]); + let ShapeRef::Union(union) = union else { + panic!("multiple distinct alternatives should produce a union"); + }; + assert_eq!( + union.alternatives(), + &[ShapeRef::I8, ShapeRef::U64, ShapeRef::String] + ); } #[cfg(target_has_atomic = "ptr")] diff --git a/tests/derive/Cargo.toml b/tests/derive/Cargo.toml index 0b9977f..8cef4bc 100644 --- a/tests/derive/Cargo.toml +++ b/tests/derive/Cargo.toml @@ -27,6 +27,8 @@ serde-shape = { workspace = true, features = ["derive"] } [dev-dependencies] insta = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } [lints] workspace = true diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index 7432b0c..cce38a3 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -16,11 +16,14 @@ use serde_shape::DeserializeDefinitionKind; use serde_shape::DeserializeShape; +use serde_shape::DeserializeVariantContent; use serde_shape::FieldMember; use serde_shape::FieldWireShape; use serde_shape::OpaqueReason; use serde_shape::SerializeDefinitionKind; use serde_shape::SerializeShape; +use serde_shape::SerializeVariantContent; +use serde_shape::ShapeRef; #[derive(DeserializeShape)] #[serde( @@ -201,19 +204,15 @@ fn exposes_deserialize_field_metadata() { assert_eq!(id.member, FieldMember::Named("id")); assert_eq!(id.name, "in-id"); assert_eq!(id.aliases, vec!["in-id", "legacy-id"]); - assert!(!id.skip); - assert!(!id.custom_deserializer); assert!(matches!(&id.wire_shape, FieldWireShape::Value(_))); assert_eq!(maybe.name, "maybe"); - assert!(!maybe.skip); + assert!(matches!(&maybe.wire_shape, FieldWireShape::Value(_))); assert_eq!(secret.name, "secret-in"); - assert!(!secret.skip); + assert!(matches!(&secret.wire_shape, FieldWireShape::Value(_))); assert_eq!(output_only.name, "only-in"); - assert!(output_only.skip); - assert!(!output_only.custom_deserializer); assert_eq!(output_only.wire_shape, FieldWireShape::Omitted); } @@ -236,21 +235,17 @@ fn exposes_serialize_field_metadata() { assert_eq!(id.member, FieldMember::Named("id")); assert_eq!(id.name, "out-id"); - assert!(!id.skip); assert_eq!(id.skip_if, None); - assert!(!id.custom_serializer); + assert!(matches!(&id.wire_shape, FieldWireShape::Value(_))); assert_eq!(maybe.name, "maybe"); assert_eq!(maybe.skip_if, Some("is_missing")); assert_eq!(secret.name, "secret"); - assert!(secret.skip); assert_eq!(secret.wire_shape, FieldWireShape::Omitted); assert_eq!(output_only.name, "only-out"); - assert!(!output_only.skip); - assert!(output_only.custom_serializer); - let FieldWireShape::Custom(opaque) = &output_only.wire_shape else { + let FieldWireShape::Value(ShapeRef::Opaque(opaque)) = &output_only.wire_shape else { panic!("custom serialized field should expose an opaque wire shape"); }; assert_eq!(opaque.reason, OpaqueReason::CustomSerializer); @@ -274,9 +269,11 @@ fn exposes_deserialize_variant_metadata() { assert_eq!(struct_variant.rust_name, "StructVariant"); assert_eq!(struct_variant.name, "struct-variant"); - assert!(!struct_variant.skip); - let [field] = struct_variant.fields.as_slice() else { + let DeserializeVariantContent::Fields(fields) = &struct_variant.content else { + panic!("struct variant should expose derived fields"); + }; + let [field] = fields.as_slice() else { panic!("struct variant should expose its field"); }; assert_eq!(field.name, "field_name"); @@ -285,12 +282,13 @@ fn exposes_deserialize_variant_metadata() { assert_eq!(renamed.aliases, vec!["deserialized", "legacy"]); assert_eq!(input_only.name, "input-only"); - assert!(!input_only.skip); + assert!(matches!( + &input_only.content, + DeserializeVariantContent::Fields(_) + )); assert_eq!(output_only.name, "output-only"); - assert!(output_only.skip); - assert!(!output_only.custom_deserializer); - assert!(output_only.fields.is_empty()); + assert_eq!(output_only.content, DeserializeVariantContent::Omitted); } #[test] @@ -310,9 +308,11 @@ fn exposes_serialize_variant_metadata() { assert_eq!(struct_variant.rust_name, "StructVariant"); assert_eq!(struct_variant.name, "STRUCT_VARIANT"); - assert!(!struct_variant.skip); - let [field] = struct_variant.fields.as_slice() else { + let SerializeVariantContent::Fields(fields) = &struct_variant.content else { + panic!("struct variant should expose derived fields"); + }; + let [field] = fields.as_slice() else { panic!("struct variant should expose its field"); }; assert_eq!(field.name, "fieldName"); @@ -320,12 +320,14 @@ fn exposes_serialize_variant_metadata() { assert_eq!(renamed.name, "SERIALIZED"); assert_eq!(input_only.name, "INPUT_ONLY"); - assert!(input_only.skip); + assert_eq!(input_only.content, SerializeVariantContent::Omitted); assert_eq!(output_only.name, "OUTPUT_ONLY"); - assert!(!output_only.skip); - assert!(output_only.custom_serializer); - assert!(output_only.fields.is_empty()); + let SerializeVariantContent::Custom(opaque) = &output_only.content else { + panic!("custom serialized variant should expose opaque content"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomSerializer); + assert_eq!(opaque.detail, Some("serialize_variant")); } #[test] diff --git a/tests/derive/tests/serde_compat.rs b/tests/derive/tests/serde_compat.rs new file mode 100644 index 0000000..3783685 --- /dev/null +++ b/tests/derive/tests/serde_compat.rs @@ -0,0 +1,277 @@ +// Copyright 2026 FastLabs Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde::Deserialize; +use serde::Serialize; +use serde_shape::DeserializeDefinitionKind; +use serde_shape::DeserializeFieldShape; +use serde_shape::DeserializeShape; +use serde_shape::DeserializeVariantContent; +use serde_shape::DeserializeVariantShape; +use serde_shape::FieldWireShape; +use serde_shape::OpaqueReason; +use serde_shape::SerializeDefinitionKind; +use serde_shape::SerializeFieldShape; +use serde_shape::SerializeShape; +use serde_shape::SerializeVariantContent; +use serde_shape::SerializeVariantShape; +use serde_shape::ShapeRef; + +#[derive(Debug, PartialEq)] +struct FlatValue(u64); + +#[derive(Debug, PartialEq, Serialize, Deserialize, SerializeShape, DeserializeShape)] +struct FlattenedCustom { + #[serde(flatten, with = "flat_value")] + value: FlatValue, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, SerializeShape, DeserializeShape)] +#[serde(transparent)] +struct TransparentValue { + value: u64, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, SerializeShape, DeserializeShape)] +#[serde(transparent)] +struct TransparentCustom { + #[serde(with = "stringified")] + value: u64, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, SerializeShape, DeserializeShape)] +enum CustomVariant { + #[serde(with = "stringified")] + Value(u64), +} + +#[test] +fn composes_flatten_with_custom_field_boundaries() { + let value = FlattenedCustom { + value: FlatValue(7), + }; + let json = serde_json::to_value(&value).expect("value should serialize"); + assert_eq!(json, serde_json::json!({ "custom": 7 })); + assert_eq!( + serde_json::from_value::(json).expect("value should deserialize"), + value + ); + + let serialize_field = first_serialize_field::(); + let FieldWireShape::Flatten(ShapeRef::Opaque(opaque)) = serialize_field.wire_shape else { + panic!("custom serialized flatten field should be flattened and opaque"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomSerializer); + assert_eq!(opaque.detail, Some("flat_value::serialize")); + + let deserialize_field = first_deserialize_field::(); + let FieldWireShape::Flatten(ShapeRef::Opaque(opaque)) = deserialize_field.wire_shape else { + panic!("custom deserialized flatten field should be flattened and opaque"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomDeserializer); + assert_eq!(opaque.detail, Some("flat_value::deserialize")); +} + +#[test] +fn marks_named_transparent_fields_as_inline() { + let value = TransparentValue { value: 11 }; + let json = serde_json::to_value(&value).expect("value should serialize"); + assert_eq!(json, serde_json::json!(11)); + assert_eq!( + serde_json::from_value::(json).expect("value should deserialize"), + value + ); + + assert_eq!( + first_serialize_field::().wire_shape, + FieldWireShape::Inline(ShapeRef::U64) + ); + assert_eq!( + first_deserialize_field::().wire_shape, + FieldWireShape::Inline(ShapeRef::U64) + ); +} + +#[test] +fn composes_transparent_with_custom_field_boundaries() { + let value = TransparentCustom { value: 13 }; + let json = serde_json::to_value(&value).expect("value should serialize"); + assert_eq!(json, serde_json::json!("13")); + assert_eq!( + serde_json::from_value::(json).expect("value should deserialize"), + value + ); + + let serialize_field = first_serialize_field::(); + let FieldWireShape::Inline(ShapeRef::Opaque(opaque)) = serialize_field.wire_shape else { + panic!("custom serialized transparent field should be inline and opaque"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomSerializer); + assert_eq!(opaque.detail, Some("stringified::serialize")); + + let deserialize_field = first_deserialize_field::(); + let FieldWireShape::Inline(ShapeRef::Opaque(opaque)) = deserialize_field.wire_shape else { + panic!("custom deserialized transparent field should be inline and opaque"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomDeserializer); + assert_eq!(opaque.detail, Some("stringified::deserialize")); +} + +#[test] +fn retains_custom_variant_boundary_details() { + let value = CustomVariant::Value(17); + let json = serde_json::to_value(&value).expect("value should serialize"); + assert_eq!(json, serde_json::json!({ "Value": "17" })); + assert_eq!( + serde_json::from_value::(json).expect("value should deserialize"), + value + ); + + let serialize_variant = first_serialize_variant::(); + let SerializeVariantContent::Custom(opaque) = serialize_variant.content else { + panic!("custom serialized variant should expose opaque content"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomSerializer); + assert_eq!(opaque.detail, Some("stringified::serialize")); + + let deserialize_variant = first_deserialize_variant::(); + let DeserializeVariantContent::Custom(opaque) = deserialize_variant.content else { + panic!("custom deserialized variant should expose opaque content"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomDeserializer); + assert_eq!(opaque.detail, Some("stringified::deserialize")); +} + +fn first_serialize_field() -> SerializeFieldShape +where + T: SerializeShape, +{ + let graph = T::serialize_shape(); + let ShapeRef::Definition(id) = graph.root else { + panic!("root shape should be a definition"); + }; + let definition = graph.definition(id).expect("definition should exist"); + let SerializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("definition should be a struct"); + }; + shape.fields.first().expect("field should exist").clone() +} + +fn first_deserialize_field() -> DeserializeFieldShape +where + T: DeserializeShape, +{ + let graph = T::deserialize_shape(); + let ShapeRef::Definition(id) = graph.root else { + panic!("root shape should be a definition"); + }; + let definition = graph.definition(id).expect("definition should exist"); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("definition should be a struct"); + }; + shape.fields.first().expect("field should exist").clone() +} + +fn first_serialize_variant() -> SerializeVariantShape +where + T: SerializeShape, +{ + let graph = T::serialize_shape(); + let ShapeRef::Definition(id) = graph.root else { + panic!("root shape should be a definition"); + }; + let definition = graph.definition(id).expect("definition should exist"); + let SerializeDefinitionKind::Enum(shape) = &definition.kind else { + panic!("definition should be an enum"); + }; + shape + .variants + .first() + .expect("variant should exist") + .clone() +} + +fn first_deserialize_variant() -> DeserializeVariantShape +where + T: DeserializeShape, +{ + let graph = T::deserialize_shape(); + let ShapeRef::Definition(id) = graph.root else { + panic!("root shape should be a definition"); + }; + let definition = graph.definition(id).expect("definition should exist"); + let DeserializeDefinitionKind::Enum(shape) = &definition.kind else { + panic!("definition should be an enum"); + }; + shape + .variants + .first() + .expect("variant should exist") + .clone() +} + +mod flat_value { + use std::collections::BTreeMap; + + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + use serde::de::Error as _; + use serde::ser::SerializeMap; + + use super::FlatValue; + + pub fn serialize(value: &FlatValue, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("custom", &value.0)?; + map.end() + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let mut values = BTreeMap::::deserialize(deserializer)?; + values + .remove("custom") + .map(FlatValue) + .ok_or_else(|| D::Error::missing_field("custom")) + } +} + +mod stringified { + use serde::Deserialize; + use serde::Deserializer; + use serde::Serializer; + use serde::de::Error as _; + + pub fn serialize(value: &u64, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&value.to_string()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} diff --git a/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap b/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap index a263249..46c821a 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap @@ -31,27 +31,23 @@ DeserializeShapeGraph { "s3-compatible", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "bucket_name", - ), - name: "bucket-name", - aliases: [ - "bucket-name", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "bucket_name", + ), + name: "bucket-name", + aliases: [ + "bucket-name", + ], + wire_shape: Value( + String, + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -62,27 +58,23 @@ DeserializeShapeGraph { "az-blob", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "container_name", - ), - name: "container-name", - aliases: [ - "container-name", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "container_name", + ), + name: "container-name", + aliases: [ + "container-name", + ], + wire_shape: Value( + String, + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -93,9 +85,9 @@ DeserializeShapeGraph { "other", ], style: Unit, - fields: [], - skip: false, - custom_deserializer: false, + content: Fields( + [], + ), other: true, untagged: false, }, diff --git a/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap b/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap index 03fd921..174416f 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap @@ -33,10 +33,6 @@ DeserializeShapeGraph { Unit, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { diff --git a/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap b/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap index 9d6dca2..02a8c0e 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap @@ -39,10 +39,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { diff --git a/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap b/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap index 24544d1..e0f81c4 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap @@ -31,10 +31,6 @@ DeserializeShapeGraph { ], wire_shape: Omitted, default: Default, - flatten: false, - skip: true, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { diff --git a/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap b/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap index c92120e..e2e80a5 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap @@ -33,10 +33,6 @@ DeserializeShapeGraph { U16, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -53,10 +49,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -72,10 +64,6 @@ DeserializeShapeGraph { default: Path( "default_retries", ), - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -93,10 +81,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: true, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -108,10 +92,6 @@ DeserializeShapeGraph { ], wire_shape: Omitted, default: None, - flatten: false, - skip: true, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -121,20 +101,18 @@ DeserializeShapeGraph { aliases: [ "secret", ], - wire_shape: Custom( - OpaqueShape { - type_name: "derive::NotShape", - reason: CustomDeserializer, - detail: Some( - "custom_secret", - ), - }, + wire_shape: Value( + Opaque( + OpaqueShape { + type_name: "derive::NotShape", + reason: CustomDeserializer, + detail: Some( + "custom_secret", + ), + }, + ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: true, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -173,27 +151,23 @@ DeserializeShapeGraph { "s3-compatible", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "bucket_name", - ), - name: "bucket-name", - aliases: [ - "bucket-name", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "bucket_name", + ), + name: "bucket-name", + aliases: [ + "bucket-name", + ], + wire_shape: Value( + String, + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -204,27 +178,23 @@ DeserializeShapeGraph { "az-blob", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "container_name", - ), - name: "container-name", - aliases: [ - "container-name", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "container_name", + ), + name: "container-name", + aliases: [ + "container-name", + ], + wire_shape: Value( + String, + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -235,9 +205,9 @@ DeserializeShapeGraph { "other", ], style: Unit, - fields: [], - skip: false, - custom_deserializer: false, + content: Fields( + [], + ), other: true, untagged: false, }, diff --git a/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap b/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap index f9102d8..8dcc77b 100644 --- a/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap +++ b/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap @@ -29,14 +29,10 @@ DeserializeShapeGraph { aliases: [ "0", ], - wire_shape: Value( + wire_shape: Inline( U64, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: true, }, ], attributes: DeserializeContainerAttributes { diff --git a/tests/integration/tests/env_vars.rs b/tests/integration/tests/env_vars.rs index 56d16cc..948d5f5 100644 --- a/tests/integration/tests/env_vars.rs +++ b/tests/integration/tests/env_vars.rs @@ -25,11 +25,13 @@ use serde_shape::DeserializeShape; use serde_shape::DeserializeShapeContext; use serde_shape::DeserializeShapeGraph; use serde_shape::DeserializeStructShape; +use serde_shape::DeserializeVariantContent; use serde_shape::FieldWireShape; use serde_shape::FieldsStyle; use serde_shape::ShapeId; use serde_shape::ShapeRef; use serde_shape::Tagging; +use serde_shape::UnionShape; #[derive(Clone, Debug, Eq, PartialEq)] struct EnvOption { @@ -194,7 +196,7 @@ impl DeserializeShape for HumanDuration { } fn string_or_integer_shape() -> ShapeRef { - ShapeRef::OneOf(vec![ + ShapeRef::union([ ShapeRef::String, ShapeRef::I8, ShapeRef::I16, @@ -274,8 +276,8 @@ impl EnvCollector<'_> { ShapeRef::Option(inner) => { self.visit_shape_ref(inner, path, true, condition); } - ShapeRef::OneOf(alternatives) => { - let value_kind = one_of_kind(alternatives); + ShapeRef::Union(union) => { + let value_kind = self.union_kind(union); self.push_leaf(path, &value_kind, optional, condition); } ShapeRef::Definition(id) => { @@ -374,7 +376,7 @@ impl EnvCollector<'_> { let variants = shape .variants .iter() - .filter(|variant| !variant.skip) + .filter(|variant| !matches!(&variant.content, DeserializeVariantContent::Omitted)) .map(|variant| variant.name) .collect::>(); @@ -402,24 +404,36 @@ impl EnvCollector<'_> { ); for variant in &shape.variants { - if variant.skip { - continue; - } - let variant_condition = format!("{}={}", tag_path.join("."), variant.name); let variant_condition = Some(merge_conditions( condition.as_deref(), variant_condition.as_str(), )); - for field in &variant.fields { - self.visit_field_wire_shape( - field.name, - &field.wire_shape, - path, - optional, - variant_condition.clone(), - ); + match &variant.content { + DeserializeVariantContent::Omitted => {} + DeserializeVariantContent::Fields(fields) => { + for field in fields { + self.visit_field_wire_shape( + field.name, + &field.wire_shape, + path, + optional, + variant_condition.clone(), + ); + } + } + DeserializeVariantContent::Custom(opaque) => { + self.push_leaf( + path, + &format!("opaque({:?})", opaque.reason), + optional, + variant_condition, + ); + } + _ => { + self.push_leaf(path, "unsupported", optional, variant_condition); + } } } return; @@ -451,14 +465,12 @@ impl EnvCollector<'_> { FieldWireShape::Flatten(shape_ref) => { self.visit_shape_ref(shape_ref, path, optional, condition); } - FieldWireShape::Custom(opaque) => { + FieldWireShape::Inline(shape_ref) => { + self.visit_shape_ref(shape_ref, path, optional, condition); + } + _ => { path.push(field_name.to_owned()); - self.push_leaf( - path, - &format!("opaque({:?})", opaque.reason), - optional, - condition, - ); + self.push_leaf(path, "unsupported", optional, condition); path.pop(); } } @@ -473,17 +485,85 @@ impl EnvCollector<'_> { ) { match wire_shape { FieldWireShape::Omitted => {} - FieldWireShape::Value(shape_ref) | FieldWireShape::Flatten(shape_ref) => { + FieldWireShape::Value(shape_ref) + | FieldWireShape::Flatten(shape_ref) + | FieldWireShape::Inline(shape_ref) => { self.visit_shape_ref(shape_ref, path, optional, condition); } - FieldWireShape::Custom(opaque) => { - self.push_leaf( - path, - &format!("opaque({:?})", opaque.reason), - optional, - condition, - ); + _ => { + self.push_leaf(path, "unsupported", optional, condition); + } + } + } + + fn union_kind(&self, union: &UnionShape) -> String { + let alternatives = union.alternatives(); + if alternatives.iter().all(ShapeRef::is_integer) { + return "integer".to_owned(); + } + if alternatives.iter().all(ShapeRef::is_float) { + return "float".to_owned(); + } + if alternatives.iter().all(ShapeRef::is_number) { + return "number".to_owned(); + } + + alternatives + .iter() + .fold(Vec::::new(), |mut kinds, alternative| { + let kind = self.union_alternative_kind(alternative); + if !kinds.contains(&kind) { + kinds.push(kind); + } + kinds + }) + .join("|") + } + + fn union_alternative_kind(&self, shape_ref: &ShapeRef) -> String { + match shape_ref { + ShapeRef::Option(inner) => self.union_alternative_kind(inner), + ShapeRef::Seq(_) | ShapeRef::Array { .. } | ShapeRef::Tuple(_) => "array".to_owned(), + ShapeRef::Map { .. } => "object".to_owned(), + ShapeRef::Union(union) => self.union_kind(union), + ShapeRef::Definition(id) => { + let definition = self.shape.definition(*id).expect("shape definition exists"); + match &definition.kind { + DeserializeDefinitionKind::Struct(shape) if shape.attributes.transparent => { + shape + .fields + .iter() + .find_map(|field| match &field.wire_shape { + FieldWireShape::Inline(inner) => { + Some(self.union_alternative_kind(inner)) + } + FieldWireShape::Omitted + | FieldWireShape::Value(_) + | FieldWireShape::Flatten(_) => None, + _ => None, + }) + .unwrap_or_else(|| "unit".to_owned()) + } + DeserializeDefinitionKind::Struct(shape) + if shape.style == FieldsStyle::Newtype && shape.fields.len() == 1 => + { + match &shape.fields[0].wire_shape { + FieldWireShape::Omitted => "unit".to_owned(), + FieldWireShape::Value(inner) + | FieldWireShape::Flatten(inner) + | FieldWireShape::Inline(inner) => self.union_alternative_kind(inner), + _ => "unknown".to_owned(), + } + } + DeserializeDefinitionKind::Struct(_) => "object".to_owned(), + DeserializeDefinitionKind::Enum(_) => "enum".to_owned(), + DeserializeDefinitionKind::Opaque(opaque) => { + format!("opaque({:?})", opaque.reason) + } + } } + ShapeRef::Opaque(opaque) => format!("opaque({:?})", opaque.reason), + shape_ref => primitive_kind(shape_ref).to_owned(), } } @@ -538,7 +618,7 @@ fn primitive_kind(shape_ref: &ShapeRef) -> &'static str { | ShapeRef::Array { .. } | ShapeRef::Map { .. } | ShapeRef::Tuple(_) - | ShapeRef::OneOf(_) + | ShapeRef::Union(_) | ShapeRef::Definition(_) | ShapeRef::Opaque(_) => { unreachable!("compound shapes are handled before leaf mapping") @@ -548,40 +628,6 @@ fn primitive_kind(shape_ref: &ShapeRef) -> &'static str { } } -fn one_of_alternative_kind(shape_ref: &ShapeRef) -> String { - match shape_ref { - ShapeRef::Option(inner) => one_of_alternative_kind(inner), - ShapeRef::Seq(_) | ShapeRef::Array { .. } | ShapeRef::Tuple(_) => "array".to_owned(), - ShapeRef::Map { .. } | ShapeRef::Definition(_) => "object".to_owned(), - ShapeRef::OneOf(alternatives) => one_of_kind(alternatives), - ShapeRef::Opaque(opaque) => format!("opaque({:?})", opaque.reason), - shape_ref => primitive_kind(shape_ref).to_owned(), - } -} - -fn one_of_kind(alternatives: &[ShapeRef]) -> String { - if !alternatives.is_empty() && alternatives.iter().all(ShapeRef::is_integer) { - return "integer".to_owned(); - } - if !alternatives.is_empty() && alternatives.iter().all(ShapeRef::is_float) { - return "float".to_owned(); - } - if !alternatives.is_empty() && alternatives.iter().all(ShapeRef::is_number) { - return "number".to_owned(); - } - - alternatives - .iter() - .fold(Vec::::new(), |mut kinds, alternative| { - let kind = one_of_alternative_kind(alternative); - if !kinds.contains(&kind) { - kinds.push(kind); - } - kinds - }) - .join("|") -} - fn env_name(prefix: &str, path: &[String]) -> String { let path = path .iter() diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap index 2305dc1..c13d4b8 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap @@ -37,10 +37,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -58,10 +54,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -79,10 +71,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -123,10 +111,6 @@ DeserializeShapeGraph { default: Path( "default_dir", ), - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -142,10 +126,6 @@ DeserializeShapeGraph { default: Path( "default_listen_data_addr", ), - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -161,10 +141,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -180,10 +156,6 @@ DeserializeShapeGraph { ), ), default: Default, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -199,10 +171,6 @@ DeserializeShapeGraph { default: Path( "default_cluster_id", ), - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -245,10 +213,6 @@ DeserializeShapeGraph { ), ), default: Default, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -259,9 +223,8 @@ DeserializeShapeGraph { "disk_capacity", ], wire_shape: Value( - OneOf( + Union( [ - String, I8, I16, I32, @@ -274,16 +237,13 @@ DeserializeShapeGraph { U64, U128, Usize, + String, ], ), ), default: Path( "default_disk_capacity", ), - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -294,9 +254,8 @@ DeserializeShapeGraph { "memory_capacity", ], wire_shape: Value( - OneOf( + Union( [ - String, I8, I16, I32, @@ -309,16 +268,13 @@ DeserializeShapeGraph { U64, U128, Usize, + String, ], ), ), default: Path( "default_memory_capacity", ), - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -338,10 +294,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -377,27 +329,23 @@ DeserializeShapeGraph { "local", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "data_dir", - ), - name: "data_dir", - aliases: [ - "data_dir", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "data_dir", + ), + name: "data_dir", + aliases: [ + "data_dir", + ], + wire_shape: Value( + String, + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -408,44 +356,36 @@ DeserializeShapeGraph { "s3", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "bucket", - ), - name: "bucket", - aliases: [ - "bucket", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - DeserializeFieldShape { - member: Named( - "region", - ), - name: "region", - aliases: [ - "region", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "bucket", + ), + name: "bucket", + aliases: [ + "bucket", + ], + wire_shape: Value( + String, + ), + default: None, + }, + DeserializeFieldShape { + member: Named( + "region", + ), + name: "region", + aliases: [ + "region", + ], + wire_shape: Value( + String, + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -488,10 +428,6 @@ DeserializeShapeGraph { U64, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -505,10 +441,6 @@ DeserializeShapeGraph { U64, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -526,10 +458,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -572,10 +500,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -589,10 +513,6 @@ DeserializeShapeGraph { Usize, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -626,9 +546,9 @@ DeserializeShapeGraph { "window", ], style: Unit, - fields: [], - skip: false, - custom_deserializer: false, + content: Fields( + [], + ), other: false, untagged: false, }, @@ -639,9 +559,9 @@ DeserializeShapeGraph { "leaky_bucket", ], style: Unit, - fields: [], - skip: false, - custom_deserializer: false, + content: Fields( + [], + ), other: false, untagged: false, }, @@ -686,10 +606,6 @@ DeserializeShapeGraph { ), ), default: Default, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -709,10 +625,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -732,10 +644,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -778,10 +686,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: true, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -795,10 +699,6 @@ DeserializeShapeGraph { String, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -834,46 +734,38 @@ DeserializeShapeGraph { "file", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "dir", - ), - name: "dir", - aliases: [ - "dir", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - DeserializeFieldShape { - member: Named( - "max_files", - ), - name: "max_files", - aliases: [ - "max_files", - ], - wire_shape: Value( - Option( - Usize, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "dir", ), - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + name: "dir", + aliases: [ + "dir", + ], + wire_shape: Value( + String, + ), + default: None, + }, + DeserializeFieldShape { + member: Named( + "max_files", + ), + name: "max_files", + aliases: [ + "max_files", + ], + wire_shape: Value( + Option( + Usize, + ), + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -884,9 +776,9 @@ DeserializeShapeGraph { "stderr", ], style: Unit, - fields: [], - skip: false, - custom_deserializer: false, + content: Fields( + [], + ), other: false, untagged: false, }, @@ -897,27 +789,23 @@ DeserializeShapeGraph { "opentelemetry", ], style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "otlp_endpoint", - ), - name: "otlp_endpoint", - aliases: [ - "otlp_endpoint", - ], - wire_shape: Value( - String, - ), - default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, - }, - ], - skip: false, - custom_deserializer: false, + content: Fields( + [ + DeserializeFieldShape { + member: Named( + "otlp_endpoint", + ), + name: "otlp_endpoint", + aliases: [ + "otlp_endpoint", + ], + wire_shape: Value( + String, + ), + default: None, + }, + ], + ), other: false, untagged: false, }, @@ -960,10 +848,6 @@ DeserializeShapeGraph { String, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -983,10 +867,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -1025,10 +905,6 @@ DeserializeShapeGraph { String, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -1073,10 +949,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { @@ -1115,10 +987,6 @@ DeserializeShapeGraph { String, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -1129,9 +997,8 @@ DeserializeShapeGraph { "push_interval", ], wire_shape: Value( - OneOf( + Union( [ - String, I8, I16, I32, @@ -1144,16 +1011,13 @@ DeserializeShapeGraph { U64, U128, Usize, + String, ], ), ), default: Path( "default_metrics_push_interval", ), - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap index 12913a5..245f332 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap @@ -75,7 +75,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_CAPACITY", config_path: "storage.disk_capacity", - value_kind: "string|integer", + value_kind: "integer|string", optional: true, condition: None, }, @@ -110,7 +110,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_MEMORY_CAPACITY", config_path: "storage.memory_capacity", - value_kind: "string|integer", + value_kind: "integer|string", optional: true, condition: None, }, @@ -165,7 +165,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_METRICS_OPENTELEMETRY_PUSH_INTERVAL", config_path: "telemetry.metrics.opentelemetry.push_interval", - value_kind: "string|integer", + value_kind: "integer|string", optional: true, condition: None, }, diff --git a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap index 4f74e98..e4ea5ba 100644 --- a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap +++ b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap @@ -33,10 +33,6 @@ DeserializeShapeGraph { String, ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -54,10 +50,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, DeserializeFieldShape { member: Named( @@ -77,10 +69,6 @@ DeserializeShapeGraph { ), ), default: None, - flatten: false, - skip: false, - custom_deserializer: false, - transparent: false, }, ], attributes: DeserializeContainerAttributes { diff --git a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap index 28e3f54..f58ca3c 100644 --- a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap +++ b/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap @@ -29,11 +29,7 @@ SerializeShapeGraph { wire_shape: Value( String, ), - flatten: false, - skip: false, skip_if: None, - custom_serializer: false, - transparent: false, }, SerializeFieldShape { member: Named( @@ -47,11 +43,7 @@ SerializeShapeGraph { ), ), ), - flatten: false, - skip: false, skip_if: None, - custom_serializer: false, - transparent: false, }, SerializeFieldShape { member: Named( @@ -67,11 +59,7 @@ SerializeShapeGraph { ), ), ), - flatten: false, - skip: false, skip_if: None, - custom_serializer: false, - transparent: false, }, ], attributes: SerializeContainerAttributes { From 7209901a1677a2d46825cdc3e14f0f63be437fb4 Mon Sep 17 00:00:00 2001 From: tison Date: Tue, 28 Jul 2026 09:24:50 +0800 Subject: [PATCH 4/8] refactor: keep shape ordering private Signed-off-by: tison --- serde-shape/src/lib.rs | 129 +++++++++++++++++++++++++++++++++++++-- serde-shape/src/tests.rs | 57 +++++++++++++++++ 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 579bc73..816c706 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -197,6 +197,7 @@ extern crate std; use alloc::boxed::Box; use alloc::collections::BTreeMap; use alloc::vec::Vec; +use core::cmp::Ordering; use core::fmt; /// Private exports used by generated derive code. @@ -476,7 +477,7 @@ pub struct DeserializeTypeName { } /// A reference to a shape node. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum ShapeRef { /// Unit shape. @@ -581,7 +582,7 @@ impl ShapeRef { } } let mut alternatives = normalized; - alternatives.sort(); + alternatives.sort_by(compare_shape_refs); alternatives.dedup(); match alternatives.len() { @@ -635,12 +636,130 @@ impl ShapeRef { } } +fn compare_shape_refs(left: &ShapeRef, right: &ShapeRef) -> Ordering { + shape_ref_rank(left) + .cmp(&shape_ref_rank(right)) + .then_with(|| match (left, right) { + (ShapeRef::Unit, ShapeRef::Unit) + | (ShapeRef::Bool, ShapeRef::Bool) + | (ShapeRef::Char, ShapeRef::Char) + | (ShapeRef::I8, ShapeRef::I8) + | (ShapeRef::I16, ShapeRef::I16) + | (ShapeRef::I32, ShapeRef::I32) + | (ShapeRef::I64, ShapeRef::I64) + | (ShapeRef::I128, ShapeRef::I128) + | (ShapeRef::Isize, ShapeRef::Isize) + | (ShapeRef::U8, ShapeRef::U8) + | (ShapeRef::U16, ShapeRef::U16) + | (ShapeRef::U32, ShapeRef::U32) + | (ShapeRef::U64, ShapeRef::U64) + | (ShapeRef::U128, ShapeRef::U128) + | (ShapeRef::Usize, ShapeRef::Usize) + | (ShapeRef::F32, ShapeRef::F32) + | (ShapeRef::F64, ShapeRef::F64) + | (ShapeRef::String, ShapeRef::String) + | (ShapeRef::Bytes, ShapeRef::Bytes) => Ordering::Equal, + (ShapeRef::Option(left), ShapeRef::Option(right)) + | (ShapeRef::Seq(left), ShapeRef::Seq(right)) => compare_shape_refs(left, right), + ( + ShapeRef::Array { + item: left_item, + len: left_len, + }, + ShapeRef::Array { + item: right_item, + len: right_len, + }, + ) => compare_shape_refs(left_item, right_item).then_with(|| left_len.cmp(right_len)), + ( + ShapeRef::Map { + key: left_key, + value: left_value, + }, + ShapeRef::Map { + key: right_key, + value: right_value, + }, + ) => compare_shape_refs(left_key, right_key) + .then_with(|| compare_shape_refs(left_value, right_value)), + (ShapeRef::Tuple(left), ShapeRef::Tuple(right)) => { + compare_shape_ref_slices(left, right) + } + (ShapeRef::Union(left), ShapeRef::Union(right)) => { + compare_shape_ref_slices(&left.alternatives, &right.alternatives) + } + (ShapeRef::Definition(left), ShapeRef::Definition(right)) => left.0.cmp(&right.0), + (ShapeRef::Opaque(left), ShapeRef::Opaque(right)) => left + .type_name + .cmp(right.type_name) + .then_with(|| { + opaque_reason_rank(left.reason).cmp(&opaque_reason_rank(right.reason)) + }) + .then_with(|| left.detail.cmp(&right.detail)), + _ => unreachable!("equal shape ranks must identify the same variant"), + }) +} + +fn compare_shape_ref_slices(left: &[ShapeRef], right: &[ShapeRef]) -> Ordering { + left.iter() + .zip(right) + .find_map(|(left, right)| { + let ordering = compare_shape_refs(left, right); + (ordering != Ordering::Equal).then_some(ordering) + }) + .unwrap_or_else(|| left.len().cmp(&right.len())) +} + +fn shape_ref_rank(shape: &ShapeRef) -> u8 { + match shape { + ShapeRef::Unit => 0, + ShapeRef::Bool => 1, + ShapeRef::Char => 2, + ShapeRef::I8 => 3, + ShapeRef::I16 => 4, + ShapeRef::I32 => 5, + ShapeRef::I64 => 6, + ShapeRef::I128 => 7, + ShapeRef::Isize => 8, + ShapeRef::U8 => 9, + ShapeRef::U16 => 10, + ShapeRef::U32 => 11, + ShapeRef::U64 => 12, + ShapeRef::U128 => 13, + ShapeRef::Usize => 14, + ShapeRef::F32 => 15, + ShapeRef::F64 => 16, + ShapeRef::String => 17, + ShapeRef::Bytes => 18, + ShapeRef::Option(_) => 19, + ShapeRef::Seq(_) => 20, + ShapeRef::Array { .. } => 21, + ShapeRef::Map { .. } => 22, + ShapeRef::Tuple(_) => 23, + ShapeRef::Union(_) => 24, + ShapeRef::Definition(_) => 25, + ShapeRef::Opaque(_) => 26, + } +} + +fn opaque_reason_rank(reason: OpaqueReason) -> u8 { + match reason { + OpaqueReason::FromType => 0, + OpaqueReason::TryFromType => 1, + OpaqueReason::IntoType => 2, + OpaqueReason::Remote => 3, + OpaqueReason::CustomSerializer => 4, + OpaqueReason::CustomDeserializer => 5, + OpaqueReason::Unsupported => 6, + } +} + /// The normalized alternatives contained by [`ShapeRef::Union`]. /// /// A union always contains at least two distinct alternatives in canonical order. Use /// [`ShapeRef::union`] or [`ShapeRef::try_union`] to construct one. Alternatives may overlap; a /// union means that any alternative is possible, not that exactly one alternative must match. -#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Eq, PartialEq)] pub struct UnionShape { alternatives: Vec, } @@ -940,7 +1059,7 @@ impl DefaultShape { } /// Shape intentionally left opaque. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct OpaqueShape { /// The Rust type or Serde item that is opaque. pub type_name: &'static str, @@ -951,7 +1070,7 @@ pub struct OpaqueShape { } /// Reason a shape cannot be represented precisely. -#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OpaqueReason { /// The type uses `#[serde(from = "...")]`. FromType, diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index fd743e5..a513ff5 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -24,7 +24,10 @@ use core::cmp::Reverse; use core::num::Wrapping; use crate::DeserializeShapeGraph; +use crate::OpaqueReason; +use crate::OpaqueShape; use crate::SerializeShapeGraph; +use crate::ShapeId; use crate::ShapeRef; #[test] @@ -70,6 +73,60 @@ fn normalizes_union_shapes() { ); } +#[test] +fn normalizes_compound_union_shapes_independent_of_input_order() { + let alternatives = [ + ShapeRef::Option(Box::new(ShapeRef::union([ShapeRef::String, ShapeRef::I8]))), + ShapeRef::Option(Box::new(ShapeRef::union([ShapeRef::U64, ShapeRef::I8]))), + ShapeRef::Seq(Box::new(ShapeRef::String)), + ShapeRef::Seq(Box::new(ShapeRef::I8)), + ShapeRef::Array { + item: Box::new(ShapeRef::I8), + len: 2, + }, + ShapeRef::Array { + item: Box::new(ShapeRef::I8), + len: 1, + }, + ShapeRef::Array { + item: Box::new(ShapeRef::String), + len: 1, + }, + ShapeRef::Map { + key: Box::new(ShapeRef::I8), + value: Box::new(ShapeRef::String), + }, + ShapeRef::Map { + key: Box::new(ShapeRef::I8), + value: Box::new(ShapeRef::U64), + }, + ShapeRef::Tuple([ShapeRef::I8].into()), + ShapeRef::Tuple([ShapeRef::I8, ShapeRef::String].into()), + ShapeRef::Definition(ShapeId(2)), + ShapeRef::Definition(ShapeId(1)), + ShapeRef::Opaque(OpaqueShape { + type_name: "opaque", + reason: OpaqueReason::Unsupported, + detail: None, + }), + ShapeRef::Opaque(OpaqueShape { + type_name: "opaque", + reason: OpaqueReason::CustomDeserializer, + detail: Some("z"), + }), + ShapeRef::Opaque(OpaqueShape { + type_name: "opaque", + reason: OpaqueReason::CustomDeserializer, + detail: Some("handler"), + }), + ]; + + assert_eq!( + ShapeRef::union(alternatives.clone()), + ShapeRef::union(alternatives.into_iter().rev()) + ); +} + #[cfg(target_has_atomic = "ptr")] #[test] fn maps_atomic_shapes() { From b5e06f9bca42442600b917d7f6152d039d82b15c Mon Sep 17 00:00:00 2001 From: tison Date: Tue, 28 Jul 2026 09:44:16 +0800 Subject: [PATCH 5/8] refactor: preserve union alternative order Signed-off-by: tison --- README.md | 2 +- serde-shape/src/lib.rs | 158 +++--------------- serde-shape/src/tests.rs | 63 +------ .../env_vars__snapshots_config_shape.snap | 6 +- .../env_vars__snapshots_env_options.snap | 6 +- 5 files changed, 32 insertions(+), 203 deletions(-) diff --git a/README.md b/README.md index 8e9693a..0e6ac18 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Typical use cases: - checking how a serialized or deserialized shape changes across releases; - building schema exporters that start from Serde metadata. -`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::union` for format-native alternatives that do not fit one Rust shape. Union alternatives may overlap; they are flattened, deduplicated, and stored in canonical order. +`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::union` for format-native alternatives that do not fit one Rust shape. Union alternatives may overlap; nested unions are flattened and duplicates are removed while retaining their first-occurrence order. Field shapes expose `wire_shape` as the source of truth for regular values, flattened fields, inline transparent fields, and omitted fields. Custom serializer/deserializer boundaries are represented by `ShapeRef::Opaque`, including when they are flattened or inline. diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 816c706..ebe6beb 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -197,7 +197,6 @@ extern crate std; use alloc::boxed::Box; use alloc::collections::BTreeMap; use alloc::vec::Vec; -use core::cmp::Ordering; use core::fmt; /// Private exports used by generated derive code. @@ -538,7 +537,7 @@ pub enum ShapeRef { }, /// Tuple shape. Tuple(Vec), - /// A normalized union of two or more possible value shapes. + /// A union of two or more distinct possible value shapes. /// /// Construct unions with [`ShapeRef::union`] or [`ShapeRef::try_union`]. Union(UnionShape), @@ -549,10 +548,10 @@ pub enum ShapeRef { } impl ShapeRef { - /// Build a normalized union from one or more possible value shapes. + /// Build a union from one or more possible value shapes. /// - /// Nested unions are flattened, duplicate alternatives are removed, and alternatives are - /// sorted into a canonical order. A single distinct alternative is returned directly. + /// Nested unions are flattened and duplicate alternatives are removed while retaining their + /// first-occurrence order. A single distinct alternative is returned directly. /// /// # Panics /// @@ -565,30 +564,33 @@ impl ShapeRef { Self::try_union(alternatives).expect("shape union requires at least one alternative") } - /// Try to build a normalized union from possible value shapes. + /// Try to build a union from possible value shapes. /// /// Returns `None` when `alternatives` is empty. Nested unions are flattened, duplicate - /// alternatives are removed, and alternatives are sorted into a canonical order. A single + /// alternatives are removed while retaining their first-occurrence order, and a single /// distinct alternative is returned directly. pub fn try_union(alternatives: I) -> Option where I: IntoIterator, { - let mut normalized = Vec::new(); + let mut unique_alternatives = Vec::new(); for alternative in alternatives { match alternative { - Self::Union(union) => normalized.extend(union.alternatives), - alternative => normalized.push(alternative), + Self::Union(union) => { + for alternative in union.alternatives { + push_unique(&mut unique_alternatives, alternative); + } + } + alternative => push_unique(&mut unique_alternatives, alternative), } } - let mut alternatives = normalized; - alternatives.sort_by(compare_shape_refs); - alternatives.dedup(); - match alternatives.len() { + match unique_alternatives.len() { 0 => None, - 1 => alternatives.pop(), - _ => Some(Self::Union(UnionShape { alternatives })), + 1 => unique_alternatives.pop(), + _ => Some(Self::Union(UnionShape { + alternatives: unique_alternatives, + })), } } @@ -636,127 +638,15 @@ impl ShapeRef { } } -fn compare_shape_refs(left: &ShapeRef, right: &ShapeRef) -> Ordering { - shape_ref_rank(left) - .cmp(&shape_ref_rank(right)) - .then_with(|| match (left, right) { - (ShapeRef::Unit, ShapeRef::Unit) - | (ShapeRef::Bool, ShapeRef::Bool) - | (ShapeRef::Char, ShapeRef::Char) - | (ShapeRef::I8, ShapeRef::I8) - | (ShapeRef::I16, ShapeRef::I16) - | (ShapeRef::I32, ShapeRef::I32) - | (ShapeRef::I64, ShapeRef::I64) - | (ShapeRef::I128, ShapeRef::I128) - | (ShapeRef::Isize, ShapeRef::Isize) - | (ShapeRef::U8, ShapeRef::U8) - | (ShapeRef::U16, ShapeRef::U16) - | (ShapeRef::U32, ShapeRef::U32) - | (ShapeRef::U64, ShapeRef::U64) - | (ShapeRef::U128, ShapeRef::U128) - | (ShapeRef::Usize, ShapeRef::Usize) - | (ShapeRef::F32, ShapeRef::F32) - | (ShapeRef::F64, ShapeRef::F64) - | (ShapeRef::String, ShapeRef::String) - | (ShapeRef::Bytes, ShapeRef::Bytes) => Ordering::Equal, - (ShapeRef::Option(left), ShapeRef::Option(right)) - | (ShapeRef::Seq(left), ShapeRef::Seq(right)) => compare_shape_refs(left, right), - ( - ShapeRef::Array { - item: left_item, - len: left_len, - }, - ShapeRef::Array { - item: right_item, - len: right_len, - }, - ) => compare_shape_refs(left_item, right_item).then_with(|| left_len.cmp(right_len)), - ( - ShapeRef::Map { - key: left_key, - value: left_value, - }, - ShapeRef::Map { - key: right_key, - value: right_value, - }, - ) => compare_shape_refs(left_key, right_key) - .then_with(|| compare_shape_refs(left_value, right_value)), - (ShapeRef::Tuple(left), ShapeRef::Tuple(right)) => { - compare_shape_ref_slices(left, right) - } - (ShapeRef::Union(left), ShapeRef::Union(right)) => { - compare_shape_ref_slices(&left.alternatives, &right.alternatives) - } - (ShapeRef::Definition(left), ShapeRef::Definition(right)) => left.0.cmp(&right.0), - (ShapeRef::Opaque(left), ShapeRef::Opaque(right)) => left - .type_name - .cmp(right.type_name) - .then_with(|| { - opaque_reason_rank(left.reason).cmp(&opaque_reason_rank(right.reason)) - }) - .then_with(|| left.detail.cmp(&right.detail)), - _ => unreachable!("equal shape ranks must identify the same variant"), - }) -} - -fn compare_shape_ref_slices(left: &[ShapeRef], right: &[ShapeRef]) -> Ordering { - left.iter() - .zip(right) - .find_map(|(left, right)| { - let ordering = compare_shape_refs(left, right); - (ordering != Ordering::Equal).then_some(ordering) - }) - .unwrap_or_else(|| left.len().cmp(&right.len())) -} - -fn shape_ref_rank(shape: &ShapeRef) -> u8 { - match shape { - ShapeRef::Unit => 0, - ShapeRef::Bool => 1, - ShapeRef::Char => 2, - ShapeRef::I8 => 3, - ShapeRef::I16 => 4, - ShapeRef::I32 => 5, - ShapeRef::I64 => 6, - ShapeRef::I128 => 7, - ShapeRef::Isize => 8, - ShapeRef::U8 => 9, - ShapeRef::U16 => 10, - ShapeRef::U32 => 11, - ShapeRef::U64 => 12, - ShapeRef::U128 => 13, - ShapeRef::Usize => 14, - ShapeRef::F32 => 15, - ShapeRef::F64 => 16, - ShapeRef::String => 17, - ShapeRef::Bytes => 18, - ShapeRef::Option(_) => 19, - ShapeRef::Seq(_) => 20, - ShapeRef::Array { .. } => 21, - ShapeRef::Map { .. } => 22, - ShapeRef::Tuple(_) => 23, - ShapeRef::Union(_) => 24, - ShapeRef::Definition(_) => 25, - ShapeRef::Opaque(_) => 26, - } -} - -fn opaque_reason_rank(reason: OpaqueReason) -> u8 { - match reason { - OpaqueReason::FromType => 0, - OpaqueReason::TryFromType => 1, - OpaqueReason::IntoType => 2, - OpaqueReason::Remote => 3, - OpaqueReason::CustomSerializer => 4, - OpaqueReason::CustomDeserializer => 5, - OpaqueReason::Unsupported => 6, +fn push_unique(alternatives: &mut Vec, alternative: ShapeRef) { + if !alternatives.contains(&alternative) { + alternatives.push(alternative); } } -/// The normalized alternatives contained by [`ShapeRef::Union`]. +/// The distinct alternatives contained by [`ShapeRef::Union`]. /// -/// A union always contains at least two distinct alternatives in canonical order. Use +/// A union always contains at least two distinct alternatives in first-occurrence order. Use /// [`ShapeRef::union`] or [`ShapeRef::try_union`] to construct one. Alternatives may overlap; a /// union means that any alternative is possible, not that exactly one alternative must match. #[derive(Clone, Eq, PartialEq)] @@ -765,7 +655,7 @@ pub struct UnionShape { } impl UnionShape { - /// Return the canonical union alternatives. + /// Return the union alternatives in first-occurrence order. pub fn alternatives(&self) -> &[ShapeRef] { &self.alternatives } diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index a513ff5..6c4e0c7 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -24,10 +24,7 @@ use core::cmp::Reverse; use core::num::Wrapping; use crate::DeserializeShapeGraph; -use crate::OpaqueReason; -use crate::OpaqueShape; use crate::SerializeShapeGraph; -use crate::ShapeId; use crate::ShapeRef; #[test] @@ -53,10 +50,6 @@ fn classifies_union_numeric_shapes() { fn normalizes_union_shapes() { assert_eq!(ShapeRef::try_union([]), None); assert_eq!(ShapeRef::union([ShapeRef::String]), ShapeRef::String); - assert_eq!( - ShapeRef::union([ShapeRef::String, ShapeRef::I8]), - ShapeRef::union([ShapeRef::I8, ShapeRef::String]) - ); let union = ShapeRef::union([ ShapeRef::String, @@ -69,61 +62,7 @@ fn normalizes_union_shapes() { }; assert_eq!( union.alternatives(), - &[ShapeRef::I8, ShapeRef::U64, ShapeRef::String] - ); -} - -#[test] -fn normalizes_compound_union_shapes_independent_of_input_order() { - let alternatives = [ - ShapeRef::Option(Box::new(ShapeRef::union([ShapeRef::String, ShapeRef::I8]))), - ShapeRef::Option(Box::new(ShapeRef::union([ShapeRef::U64, ShapeRef::I8]))), - ShapeRef::Seq(Box::new(ShapeRef::String)), - ShapeRef::Seq(Box::new(ShapeRef::I8)), - ShapeRef::Array { - item: Box::new(ShapeRef::I8), - len: 2, - }, - ShapeRef::Array { - item: Box::new(ShapeRef::I8), - len: 1, - }, - ShapeRef::Array { - item: Box::new(ShapeRef::String), - len: 1, - }, - ShapeRef::Map { - key: Box::new(ShapeRef::I8), - value: Box::new(ShapeRef::String), - }, - ShapeRef::Map { - key: Box::new(ShapeRef::I8), - value: Box::new(ShapeRef::U64), - }, - ShapeRef::Tuple([ShapeRef::I8].into()), - ShapeRef::Tuple([ShapeRef::I8, ShapeRef::String].into()), - ShapeRef::Definition(ShapeId(2)), - ShapeRef::Definition(ShapeId(1)), - ShapeRef::Opaque(OpaqueShape { - type_name: "opaque", - reason: OpaqueReason::Unsupported, - detail: None, - }), - ShapeRef::Opaque(OpaqueShape { - type_name: "opaque", - reason: OpaqueReason::CustomDeserializer, - detail: Some("z"), - }), - ShapeRef::Opaque(OpaqueShape { - type_name: "opaque", - reason: OpaqueReason::CustomDeserializer, - detail: Some("handler"), - }), - ]; - - assert_eq!( - ShapeRef::union(alternatives.clone()), - ShapeRef::union(alternatives.into_iter().rev()) + &[ShapeRef::String, ShapeRef::I8, ShapeRef::U64] ); } diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap index c13d4b8..f08f01c 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap @@ -225,6 +225,7 @@ DeserializeShapeGraph { wire_shape: Value( Union( [ + String, I8, I16, I32, @@ -237,7 +238,6 @@ DeserializeShapeGraph { U64, U128, Usize, - String, ], ), ), @@ -256,6 +256,7 @@ DeserializeShapeGraph { wire_shape: Value( Union( [ + String, I8, I16, I32, @@ -268,7 +269,6 @@ DeserializeShapeGraph { U64, U128, Usize, - String, ], ), ), @@ -999,6 +999,7 @@ DeserializeShapeGraph { wire_shape: Value( Union( [ + String, I8, I16, I32, @@ -1011,7 +1012,6 @@ DeserializeShapeGraph { U64, U128, Usize, - String, ], ), ), diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap index 245f332..12913a5 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap @@ -75,7 +75,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_CAPACITY", config_path: "storage.disk_capacity", - value_kind: "integer|string", + value_kind: "string|integer", optional: true, condition: None, }, @@ -110,7 +110,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_MEMORY_CAPACITY", config_path: "storage.memory_capacity", - value_kind: "integer|string", + value_kind: "string|integer", optional: true, condition: None, }, @@ -165,7 +165,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_METRICS_OPENTELEMETRY_PUSH_INTERVAL", config_path: "telemetry.metrics.opentelemetry.push_interval", - value_kind: "integer|string", + value_kind: "string|integer", optional: true, condition: None, }, From c5932553251932b9182e6c833e7fd5a43038eca4 Mon Sep 17 00:00:00 2001 From: tison Date: Tue, 28 Jul 2026 12:25:18 +0800 Subject: [PATCH 6/8] refactor: retain canonical union ordering Signed-off-by: tison --- README.md | 2 +- serde-shape/src/lib.rs | 53 ++++++++----------- serde-shape/src/tests.rs | 6 ++- .../env_vars__snapshots_config_shape.snap | 6 +-- .../env_vars__snapshots_env_options.snap | 6 +-- 5 files changed, 34 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 0e6ac18..8e9693a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Typical use cases: - checking how a serialized or deserialized shape changes across releases; - building schema exporters that start from Serde metadata. -`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::union` for format-native alternatives that do not fit one Rust shape. Union alternatives may overlap; nested unions are flattened and duplicates are removed while retaining their first-occurrence order. +`serde-shape` is intentionally not a full validation schema. It reflects the Serde data model shape and relevant Serde attributes; it does not infer value ranges, regexes, business rules, or runtime behavior hidden inside custom serializer/deserializer functions. Use `ShapeRef::union` for format-native alternatives that do not fit one Rust shape. Union alternatives may overlap; they are flattened, deduplicated, and stored in canonical order. Field shapes expose `wire_shape` as the source of truth for regular values, flattened fields, inline transparent fields, and omitted fields. Custom serializer/deserializer boundaries are represented by `ShapeRef::Opaque`, including when they are flattened or inline. diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index ebe6beb..b47b478 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -476,7 +476,7 @@ pub struct DeserializeTypeName { } /// A reference to a shape node. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] #[non_exhaustive] pub enum ShapeRef { /// Unit shape. @@ -537,7 +537,7 @@ pub enum ShapeRef { }, /// Tuple shape. Tuple(Vec), - /// A union of two or more distinct possible value shapes. + /// A normalized union of two or more possible value shapes. /// /// Construct unions with [`ShapeRef::union`] or [`ShapeRef::try_union`]. Union(UnionShape), @@ -548,10 +548,10 @@ pub enum ShapeRef { } impl ShapeRef { - /// Build a union from one or more possible value shapes. + /// Build a normalized union from one or more possible value shapes. /// - /// Nested unions are flattened and duplicate alternatives are removed while retaining their - /// first-occurrence order. A single distinct alternative is returned directly. + /// Nested unions are flattened, duplicate alternatives are removed, and alternatives are + /// sorted into a canonical order. A single distinct alternative is returned directly. /// /// # Panics /// @@ -564,33 +564,30 @@ impl ShapeRef { Self::try_union(alternatives).expect("shape union requires at least one alternative") } - /// Try to build a union from possible value shapes. + /// Try to build a normalized union from possible value shapes. /// /// Returns `None` when `alternatives` is empty. Nested unions are flattened, duplicate - /// alternatives are removed while retaining their first-occurrence order, and a single + /// alternatives are removed, and alternatives are sorted into a canonical order. A single /// distinct alternative is returned directly. pub fn try_union(alternatives: I) -> Option where I: IntoIterator, { - let mut unique_alternatives = Vec::new(); + let mut normalized = Vec::new(); for alternative in alternatives { match alternative { - Self::Union(union) => { - for alternative in union.alternatives { - push_unique(&mut unique_alternatives, alternative); - } - } - alternative => push_unique(&mut unique_alternatives, alternative), + Self::Union(union) => normalized.extend(union.alternatives), + alternative => normalized.push(alternative), } } + let mut alternatives = normalized; + alternatives.sort(); + alternatives.dedup(); - match unique_alternatives.len() { + match alternatives.len() { 0 => None, - 1 => unique_alternatives.pop(), - _ => Some(Self::Union(UnionShape { - alternatives: unique_alternatives, - })), + 1 => alternatives.pop(), + _ => Some(Self::Union(UnionShape { alternatives })), } } @@ -638,24 +635,18 @@ impl ShapeRef { } } -fn push_unique(alternatives: &mut Vec, alternative: ShapeRef) { - if !alternatives.contains(&alternative) { - alternatives.push(alternative); - } -} - -/// The distinct alternatives contained by [`ShapeRef::Union`]. +/// The normalized alternatives contained by [`ShapeRef::Union`]. /// -/// A union always contains at least two distinct alternatives in first-occurrence order. Use +/// A union always contains at least two distinct alternatives in canonical order. Use /// [`ShapeRef::union`] or [`ShapeRef::try_union`] to construct one. Alternatives may overlap; a /// union means that any alternative is possible, not that exactly one alternative must match. -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] pub struct UnionShape { alternatives: Vec, } impl UnionShape { - /// Return the union alternatives in first-occurrence order. + /// Return the canonical union alternatives. pub fn alternatives(&self) -> &[ShapeRef] { &self.alternatives } @@ -949,7 +940,7 @@ impl DefaultShape { } /// Shape intentionally left opaque. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct OpaqueShape { /// The Rust type or Serde item that is opaque. pub type_name: &'static str, @@ -960,7 +951,7 @@ pub struct OpaqueShape { } /// Reason a shape cannot be represented precisely. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum OpaqueReason { /// The type uses `#[serde(from = "...")]`. FromType, diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index 6c4e0c7..fd743e5 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -50,6 +50,10 @@ fn classifies_union_numeric_shapes() { fn normalizes_union_shapes() { assert_eq!(ShapeRef::try_union([]), None); assert_eq!(ShapeRef::union([ShapeRef::String]), ShapeRef::String); + assert_eq!( + ShapeRef::union([ShapeRef::String, ShapeRef::I8]), + ShapeRef::union([ShapeRef::I8, ShapeRef::String]) + ); let union = ShapeRef::union([ ShapeRef::String, @@ -62,7 +66,7 @@ fn normalizes_union_shapes() { }; assert_eq!( union.alternatives(), - &[ShapeRef::String, ShapeRef::I8, ShapeRef::U64] + &[ShapeRef::I8, ShapeRef::U64, ShapeRef::String] ); } diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap index f08f01c..c13d4b8 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap @@ -225,7 +225,6 @@ DeserializeShapeGraph { wire_shape: Value( Union( [ - String, I8, I16, I32, @@ -238,6 +237,7 @@ DeserializeShapeGraph { U64, U128, Usize, + String, ], ), ), @@ -256,7 +256,6 @@ DeserializeShapeGraph { wire_shape: Value( Union( [ - String, I8, I16, I32, @@ -269,6 +268,7 @@ DeserializeShapeGraph { U64, U128, Usize, + String, ], ), ), @@ -999,7 +999,6 @@ DeserializeShapeGraph { wire_shape: Value( Union( [ - String, I8, I16, I32, @@ -1012,6 +1011,7 @@ DeserializeShapeGraph { U64, U128, Usize, + String, ], ), ), diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap index 12913a5..245f332 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap +++ b/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap @@ -75,7 +75,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_CAPACITY", config_path: "storage.disk_capacity", - value_kind: "string|integer", + value_kind: "integer|string", optional: true, condition: None, }, @@ -110,7 +110,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_STORAGE_MEMORY_CAPACITY", config_path: "storage.memory_capacity", - value_kind: "string|integer", + value_kind: "integer|string", optional: true, condition: None, }, @@ -165,7 +165,7 @@ expression: "env_options::(\"PERCAS_CONFIG\")" EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_METRICS_OPENTELEMETRY_PUSH_INTERVAL", config_path: "telemetry.metrics.opentelemetry.push_interval", - value_kind: "string|integer", + value_kind: "integer|string", optional: true, condition: None, }, From 457054ee3575fe4bd10450ec07b056b87e3c7511 Mon Sep 17 00:00:00 2001 From: tison Date: Tue, 28 Jul 2026 14:47:18 +0800 Subject: [PATCH 7/8] fix: traverse tagged newtype config variants --- Cargo.lock | 81 ++++++++++ Cargo.toml | 1 + tests/integration/Cargo.toml | 2 + .../tests/{env_vars.rs => configenv.rs} | 105 ++++++++++++- ...=> configenv__snapshots_config_shape.snap} | 30 ++-- ... => configenv__snapshots_env_options.snap} | 144 +++++++++++++++--- 6 files changed, 317 insertions(+), 46 deletions(-) rename tests/integration/tests/{env_vars.rs => configenv.rs} (86%) rename tests/integration/tests/snapshots/{env_vars__snapshots_config_shape.snap => configenv__snapshots_config_shape.snap} (97%) rename tests/integration/tests/snapshots/{env_vars__snapshots_env_options.snap => configenv__snapshots_env_options.snap} (64%) diff --git a/Cargo.lock b/Cargo.lock index a471cf5..3322e23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,6 +127,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -154,12 +160,28 @@ dependencies = [ "r-efi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "insta" version = "1.48.0" @@ -293,7 +315,9 @@ name = "serde-shape-test-integration" version = "0.0.0" dependencies = [ "insta", + "serde", "serde-shape", + "toml_edit", ] [[package]] @@ -348,6 +372,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "similar" version = "2.7.0" @@ -395,6 +428,45 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -431,6 +503,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "x" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index c65c2df..8a658db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ serde = { version = "1.0.229", features = ["derive"] } serde_derive_internals = { version = "0.29.1" } serde_json = { version = "1.0.151" } syn = { version = "2.0.104" } +toml_edit = { version = "0.25.13", features = ["serde"] } which = { version = "8.0.4" } [workspace.lints.rust] diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 3a01ace..b2adfaa 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -27,6 +27,8 @@ serde-shape = { workspace = true, features = ["derive", "std"] } [dev-dependencies] insta = { workspace = true } +serde = { workspace = true } +toml_edit = { workspace = true } [lints] workspace = true diff --git a/tests/integration/tests/env_vars.rs b/tests/integration/tests/configenv.rs similarity index 86% rename from tests/integration/tests/env_vars.rs rename to tests/integration/tests/configenv.rs index 948d5f5..fc29898 100644 --- a/tests/integration/tests/env_vars.rs +++ b/tests/integration/tests/configenv.rs @@ -19,6 +19,8 @@ use std::net::SocketAddr; use std::num::NonZeroUsize; use std::path::PathBuf; +use serde::Deserialize; +use serde::de::IntoDeserializer; use serde_shape::DeserializeDefinitionKind; use serde_shape::DeserializeEnumShape; use serde_shape::DeserializeShape; @@ -32,11 +34,12 @@ use serde_shape::ShapeId; use serde_shape::ShapeRef; use serde_shape::Tagging; use serde_shape::UnionShape; +use toml_edit::DocumentMut; #[derive(Clone, Debug, Eq, PartialEq)] struct EnvOption { env_name: String, - config_path: String, + path: Vec, value_kind: String, optional: bool, condition: Option, @@ -177,6 +180,25 @@ struct OpentelemetryMetricsConfig { push_interval: HumanDuration, } +#[derive(Debug, Deserialize, DeserializeShape, PartialEq)] +#[serde(deny_unknown_fields)] +struct ClientConfig { + transport: Transport, +} + +#[derive(Debug, Deserialize, DeserializeShape, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum Transport { + Tcp(TcpTransport), +} + +#[derive(Debug, Deserialize, DeserializeShape, PartialEq)] +#[serde(deny_unknown_fields)] +struct TcpTransport { + host: String, + port: u16, +} + #[derive(Clone, Copy, Debug)] struct ByteSize(u64); @@ -247,6 +269,58 @@ fn snapshots_env_options() { insta::assert_debug_snapshot!(env_options::("PERCAS_CONFIG")); } +#[test] +fn edits_an_internally_tagged_newtype_variant_through_generated_paths() { + let options = env_options::("APP_CONFIG"); + let mut document = r#" + [transport] + kind = "tcp" + host = "localhost" + port = 8080 + "# + .parse::() + .expect("config should be valid TOML"); + + let overrides = [ + ( + "APP_CONFIG_TRANSPORT_KIND", + toml_edit::value("tcp"), + ["transport", "kind"], + ), + ( + "APP_CONFIG_TRANSPORT_HOST", + toml_edit::value("example.com"), + ["transport", "host"], + ), + ( + "APP_CONFIG_TRANSPORT_PORT", + toml_edit::value(443), + ["transport", "port"], + ), + ]; + + for (env_name, value, expected_path) in overrides { + let option = options + .iter() + .find(|option| option.env_name == env_name) + .expect("generated environment option should exist"); + assert_eq!(option.path, expected_path); + set_toml_path(&mut document, &option.path, value); + } + + let config = ClientConfig::deserialize(document.into_deserializer()) + .expect("edited TOML should deserialize"); + assert_eq!( + config, + ClientConfig { + transport: Transport::Tcp(TcpTransport { + host: "example.com".to_owned(), + port: 443, + }), + } + ); +} + fn env_options(env_prefix: &str) -> Vec { let shape = DeserializeShapeGraph::for_type::(); let mut collector = EnvCollector { @@ -261,7 +335,7 @@ fn env_options(env_prefix: &str) -> Vec { struct EnvCollector<'a> { shape: &'a DeserializeShapeGraph, env_prefix: &'a str, - options: BTreeMap, + options: BTreeMap, EnvOption>, } impl EnvCollector<'_> { @@ -412,6 +486,16 @@ impl EnvCollector<'_> { match &variant.content { DeserializeVariantContent::Omitted => {} + DeserializeVariantContent::Fields(fields) + if variant.style == FieldsStyle::Newtype && fields.len() == 1 => + { + self.visit_newtype_wire_shape( + &fields[0].wire_shape, + path, + optional, + variant_condition, + ); + } DeserializeVariantContent::Fields(fields) => { for field in fields { self.visit_field_wire_shape( @@ -578,12 +662,12 @@ impl EnvCollector<'_> { return; } - let config_path = path.join("."); + let path = path.to_vec(); self.options - .entry(config_path.clone()) + .entry(path.clone()) .or_insert_with(|| EnvOption { - env_name: env_name(self.env_prefix, path), - config_path, + env_name: env_name(self.env_prefix, &path), + path, value_kind: value_kind.to_owned(), optional, condition, @@ -597,6 +681,15 @@ fn appended_path(path: &[String], segment: &str) -> Vec { path } +fn set_toml_path(document: &mut DocumentMut, path: &[String], value: toml_edit::Item) { + let (key, parents) = path.split_last().expect("config path should not be empty"); + let mut current = document.as_item_mut(); + for parent in parents { + current = &mut current[parent.as_str()]; + } + current[key.as_str()] = value; +} + fn merge_conditions(existing: Option<&str>, new: &str) -> String { existing.map_or_else(|| new.to_owned(), |existing| format!("{existing}; {new}")) } diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap b/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap similarity index 97% rename from tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap rename to tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap index c13d4b8..33c3a98 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_config_shape.snap +++ b/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap @@ -1,5 +1,5 @@ --- -source: tests/integration/tests/env_vars.rs +source: tests/integration/tests/configenv.rs expression: "Config::deserialize_shape()" --- DeserializeShapeGraph { @@ -14,7 +14,7 @@ DeserializeShapeGraph { 0, ), type_name: DeserializeTypeName { - rust_name: "env_vars::Config", + rust_name: "configenv::Config", name: "Config", }, kind: Struct( @@ -90,7 +90,7 @@ DeserializeShapeGraph { 1, ), type_name: DeserializeTypeName { - rust_name: "env_vars::ServerConfig", + rust_name: "configenv::ServerConfig", name: "ServerConfig", }, kind: Struct( @@ -190,7 +190,7 @@ DeserializeShapeGraph { 2, ), type_name: DeserializeTypeName { - rust_name: "env_vars::StorageConfig", + rust_name: "configenv::StorageConfig", name: "StorageConfig", }, kind: Struct( @@ -313,7 +313,7 @@ DeserializeShapeGraph { 3, ), type_name: DeserializeTypeName { - rust_name: "env_vars::StorageBackend", + rust_name: "configenv::StorageBackend", name: "StorageBackend", }, kind: Enum( @@ -409,7 +409,7 @@ DeserializeShapeGraph { 4, ), type_name: DeserializeTypeName { - rust_name: "env_vars::DiskThrottle", + rust_name: "configenv::DiskThrottle", name: "DiskThrottle", }, kind: Struct( @@ -477,7 +477,7 @@ DeserializeShapeGraph { 5, ), type_name: DeserializeTypeName { - rust_name: "env_vars::CounterConfig", + rust_name: "configenv::CounterConfig", name: "CounterConfig", }, kind: Struct( @@ -532,7 +532,7 @@ DeserializeShapeGraph { 6, ), type_name: DeserializeTypeName { - rust_name: "env_vars::CounterMode", + rust_name: "configenv::CounterMode", name: "CounterMode", }, kind: Enum( @@ -583,7 +583,7 @@ DeserializeShapeGraph { 7, ), type_name: DeserializeTypeName { - rust_name: "env_vars::TelemetryConfig", + rust_name: "configenv::TelemetryConfig", name: "TelemetryConfig", }, kind: Struct( @@ -663,7 +663,7 @@ DeserializeShapeGraph { 8, ), type_name: DeserializeTypeName { - rust_name: "env_vars::LogsConfig", + rust_name: "configenv::LogsConfig", name: "LogsConfig", }, kind: Struct( @@ -718,7 +718,7 @@ DeserializeShapeGraph { 9, ), type_name: DeserializeTypeName { - rust_name: "env_vars::LogSink", + rust_name: "configenv::LogSink", name: "LogSink", }, kind: Enum( @@ -829,7 +829,7 @@ DeserializeShapeGraph { 10, ), type_name: DeserializeTypeName { - rust_name: "env_vars::TracesConfig", + rust_name: "configenv::TracesConfig", name: "TracesConfig", }, kind: Struct( @@ -886,7 +886,7 @@ DeserializeShapeGraph { 11, ), type_name: DeserializeTypeName { - rust_name: "env_vars::OpentelemetryTracesConfig", + rust_name: "configenv::OpentelemetryTracesConfig", name: "OpentelemetryTracesConfig", }, kind: Struct( @@ -924,7 +924,7 @@ DeserializeShapeGraph { 12, ), type_name: DeserializeTypeName { - rust_name: "env_vars::MetricsConfig", + rust_name: "configenv::MetricsConfig", name: "MetricsConfig", }, kind: Struct( @@ -968,7 +968,7 @@ DeserializeShapeGraph { 13, ), type_name: DeserializeTypeName { - rust_name: "env_vars::OpentelemetryMetricsConfig", + rust_name: "configenv::OpentelemetryMetricsConfig", name: "OpentelemetryMetricsConfig", }, kind: Struct( diff --git a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap b/tests/integration/tests/snapshots/configenv__snapshots_env_options.snap similarity index 64% rename from tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap rename to tests/integration/tests/snapshots/configenv__snapshots_env_options.snap index 245f332..b5b8b75 100644 --- a/tests/integration/tests/snapshots/env_vars__snapshots_env_options.snap +++ b/tests/integration/tests/snapshots/configenv__snapshots_env_options.snap @@ -1,46 +1,65 @@ --- -source: tests/integration/tests/env_vars.rs +source: tests/integration/tests/configenv.rs expression: "env_options::(\"PERCAS_CONFIG\")" --- [ EnvOption { env_name: "PERCAS_CONFIG_SERVER_ADVERTISE_DATA_ADDR", - config_path: "server.advertise_data_addr", + path: [ + "server", + "advertise_data_addr", + ], value_kind: "string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_SERVER_CLUSTER_ID", - config_path: "server.cluster_id", + path: [ + "server", + "cluster_id", + ], value_kind: "string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_SERVER_DIR", - config_path: "server.dir", + path: [ + "server", + "dir", + ], value_kind: "string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_SERVER_INITIAL_PEERS", - config_path: "server.initial_peers", + path: [ + "server", + "initial_peers", + ], value_kind: "array", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_SERVER_LISTEN_DATA_ADDR", - config_path: "server.listen_data_addr", + path: [ + "server", + "listen_data_addr", + ], value_kind: "string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_BACKEND_BUCKET", - config_path: "storage.backend.bucket", + path: [ + "storage", + "backend", + "bucket", + ], value_kind: "string", optional: true, condition: Some( @@ -49,7 +68,11 @@ expression: "env_options::(\"PERCAS_CONFIG\")" }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_BACKEND_DATA_DIR", - config_path: "storage.backend.data_dir", + path: [ + "storage", + "backend", + "data_dir", + ], value_kind: "string", optional: true, condition: Some( @@ -58,14 +81,22 @@ expression: "env_options::(\"PERCAS_CONFIG\")" }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_BACKEND_KIND", - config_path: "storage.backend.kind", + path: [ + "storage", + "backend", + "kind", + ], value_kind: "enum[local|s3]", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_BACKEND_REGION", - config_path: "storage.backend.region", + path: [ + "storage", + "backend", + "region", + ], value_kind: "string", optional: true, condition: Some( @@ -74,49 +105,77 @@ expression: "env_options::(\"PERCAS_CONFIG\")" }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_CAPACITY", - config_path: "storage.disk_capacity", + path: [ + "storage", + "disk_capacity", + ], value_kind: "integer|string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_THROTTLE_IOPS_COUNTER_MODE", - config_path: "storage.disk_throttle.iops_counter.mode", + path: [ + "storage", + "disk_throttle", + "iops_counter", + "mode", + ], value_kind: "enum[window|leaky_bucket]", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_THROTTLE_IOPS_COUNTER_SIZE", - config_path: "storage.disk_throttle.iops_counter.size", + path: [ + "storage", + "disk_throttle", + "iops_counter", + "size", + ], value_kind: "integer", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_THROTTLE_READ_IOPS", - config_path: "storage.disk_throttle.read_iops", + path: [ + "storage", + "disk_throttle", + "read_iops", + ], value_kind: "integer", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_DISK_THROTTLE_WRITE_IOPS", - config_path: "storage.disk_throttle.write_iops", + path: [ + "storage", + "disk_throttle", + "write_iops", + ], value_kind: "integer", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_STORAGE_MEMORY_CAPACITY", - config_path: "storage.memory_capacity", + path: [ + "storage", + "memory_capacity", + ], value_kind: "integer|string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_DIR", - config_path: "telemetry.logs.dir", + path: [ + "telemetry", + "logs", + "dir", + ], value_kind: "string", optional: true, condition: Some( @@ -125,21 +184,33 @@ expression: "env_options::(\"PERCAS_CONFIG\")" }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_FILTER", - config_path: "telemetry.logs.filter", + path: [ + "telemetry", + "logs", + "filter", + ], value_kind: "string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_KIND", - config_path: "telemetry.logs.kind", + path: [ + "telemetry", + "logs", + "kind", + ], value_kind: "enum[file|stderr|opentelemetry]", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_MAX_FILES", - config_path: "telemetry.logs.max_files", + path: [ + "telemetry", + "logs", + "max_files", + ], value_kind: "integer", optional: true, condition: Some( @@ -148,7 +219,11 @@ expression: "env_options::(\"PERCAS_CONFIG\")" }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_OTLP_ENDPOINT", - config_path: "telemetry.logs.otlp_endpoint", + path: [ + "telemetry", + "logs", + "otlp_endpoint", + ], value_kind: "string", optional: true, condition: Some( @@ -157,28 +232,47 @@ expression: "env_options::(\"PERCAS_CONFIG\")" }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_METRICS_OPENTELEMETRY_OTLP_ENDPOINT", - config_path: "telemetry.metrics.opentelemetry.otlp_endpoint", + path: [ + "telemetry", + "metrics", + "opentelemetry", + "otlp_endpoint", + ], value_kind: "string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_METRICS_OPENTELEMETRY_PUSH_INTERVAL", - config_path: "telemetry.metrics.opentelemetry.push_interval", + path: [ + "telemetry", + "metrics", + "opentelemetry", + "push_interval", + ], value_kind: "integer|string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_TRACES_CAPTURE_LOG_FILTER", - config_path: "telemetry.traces.capture_log_filter", + path: [ + "telemetry", + "traces", + "capture_log_filter", + ], value_kind: "string", optional: true, condition: None, }, EnvOption { env_name: "PERCAS_CONFIG_TELEMETRY_TRACES_OPENTELEMETRY_OTLP_ENDPOINT", - config_path: "telemetry.traces.opentelemetry.otlp_endpoint", + path: [ + "telemetry", + "traces", + "opentelemetry", + "otlp_endpoint", + ], value_kind: "string", optional: true, condition: None, From 4f65d3f157b7d51cc3ec65b0e1fd025ee3942dd6 Mon Sep 17 00:00:00 2001 From: tison Date: Wed, 29 Jul 2026 09:51:49 +0800 Subject: [PATCH 8/8] test: cover tagged config path edge cases --- tests/integration/tests/configenv.rs | 58 ++++++++++++++++++- .../configenv__snapshots_config_shape.snap | 2 +- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/tests/integration/tests/configenv.rs b/tests/integration/tests/configenv.rs index fc29898..5aa79cc 100644 --- a/tests/integration/tests/configenv.rs +++ b/tests/integration/tests/configenv.rs @@ -126,7 +126,6 @@ struct TelemetryConfig { } #[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] struct LogsConfig { #[serde(flatten)] sink: LogSink, @@ -197,6 +196,21 @@ enum Transport { struct TcpTransport { host: String, port: u16, + #[serde(rename = "tls.version")] + tls_version: String, +} + +#[derive(Debug, Deserialize, DeserializeShape, PartialEq)] +#[serde(deny_unknown_fields)] +struct ModeConfig { + mode: ExecutionMode, +} + +#[derive(Debug, Deserialize, DeserializeShape, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ExecutionMode { + Fast, + Safe, } #[derive(Clone, Copy, Debug)] @@ -277,6 +291,7 @@ fn edits_an_internally_tagged_newtype_variant_through_generated_paths() { kind = "tcp" host = "localhost" port = 8080 + "tls.version" = "1.2" "# .parse::() .expect("config should be valid TOML"); @@ -297,6 +312,11 @@ fn edits_an_internally_tagged_newtype_variant_through_generated_paths() { toml_edit::value(443), ["transport", "port"], ), + ( + "APP_CONFIG_TRANSPORT_TLS_VERSION", + toml_edit::value("1.3"), + ["transport", "tls.version"], + ), ]; for (env_name, value, expected_path) in overrides { @@ -316,11 +336,40 @@ fn edits_an_internally_tagged_newtype_variant_through_generated_paths() { transport: Transport::Tcp(TcpTransport { host: "example.com".to_owned(), port: 443, + tls_version: "1.3".to_owned(), }), } ); } +#[test] +fn edits_an_internally_tagged_unit_enum_through_its_tag_path() { + let options = env_options::("APP_CONFIG"); + let option = options + .iter() + .find(|option| option.env_name == "APP_CONFIG_MODE_KIND") + .expect("generated tag option should exist"); + assert_eq!(option.path, ["mode", "kind"]); + assert_eq!(option.value_kind, "enum[fast|safe]"); + + let mut document = r#" + [mode] + kind = "fast" + "# + .parse::() + .expect("config should be valid TOML"); + set_toml_path(&mut document, &option.path, toml_edit::value("safe")); + + let config = ModeConfig::deserialize(document.into_deserializer()) + .expect("edited TOML should deserialize"); + assert_eq!( + config, + ModeConfig { + mode: ExecutionMode::Safe, + } + ); +} + fn env_options(env_prefix: &str) -> Vec { let shape = DeserializeShapeGraph::for_type::(); let mut collector = EnvCollector { @@ -454,11 +503,14 @@ impl EnvCollector<'_> { .map(|variant| variant.name) .collect::>(); - if shape + let all_variants_are_unit = shape .variants .iter() + .filter(|variant| !matches!(&variant.content, DeserializeVariantContent::Omitted)) .all(|variant| variant.style == FieldsStyle::Unit) - { + && !variants.is_empty(); + + if matches!(&shape.repr, Tagging::External) && all_variants_are_unit { self.push_leaf( path, &format!("enum[{}]", variants.join("|")), diff --git a/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap b/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap index 33c3a98..8dd1715 100644 --- a/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap +++ b/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap @@ -703,7 +703,7 @@ DeserializeShapeGraph { ], attributes: DeserializeContainerAttributes { tagging: External, - deny_unknown_fields: true, + deny_unknown_fields: false, default: None, has_flatten: true, transparent: false,