diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4361419 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +### Breaking changes + +* Make shape graph roots and definition lists read-only. Use `root()`, `definitions()`, and `definition(id)` instead of accessing fields directly. +* Make the `ShapeId` tuple field private. Use `ShapeId::index()` when the graph-local numeric index is needed. +* Add `description` to definition, field, and variant metadata. Manual struct literals must initialize the new field. +* Remove `OpaqueReason::{FromType, TryFromType, IntoType}` because Serde conversion attributes now use the conversion type's shape instead of an opaque boundary. + +### New features + +* Add `#[serde_shape(serialize_with = "path", deserialize_with = "path")]` hooks for custom Serde functions and foreign representations. +* Preserve Rust doc comments on derived containers, variants, and fields as user-facing descriptions. + +### Bug fixes + +* Reflect the proxy type used by Serde `from`, `try_from`, and `into` container attributes. +* Follow Serde's directional bounds for `Cow`: serialization reflects the borrowed type and deserialization reflects the owned type. + +### Improvements + +* Document runtime graph construction, format-dependent unions, graph-local identifiers, built-in coverage, and custom representation boundaries. +* Replace broad debug snapshots with focused behavior assertions and remove the snapshot-testing dependency. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6c4a2b2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +Thank you for helping improve `serde-shape`. + +## Development workflow + +Repository tasks are defined by `cargo x`: + +```console +cargo x build --locked +cargo x test +cargo x lint +``` + +Run `cargo x --help` or `cargo x --help` for command-specific options. `cargo x lint --fix` applies supported formatting and lint fixes. + +The main crate is `no_std` by default. Changes to core shape construction must continue to compile without the `std` feature; the test task exercises the supported feature combinations. + +## Tests + +Add tests at the observable behavior boundary: + +* Put shape context, built-in type, and graph behavior tests in `serde-shape/src/tests.rs`. +* Put derive behavior and Serde attribute compatibility tests in `tests/derive`. +* Put end-to-end consumer scenarios and comparisons with actual Serde calls in `tests/integration`. +* Put regressions that specifically exercise `no_std` derive output in `tests/no_std`. + +Prefer focused assertions for the contract under test. Avoid snapshots of entire debug graphs: they obscure the behavior being protected and make unrelated metadata additions expensive to review. + +## Public API changes + +Serialization and deserialization may have different shapes, bounds, and names. Check both directions when changing a shared implementation, and compare built-in types with Serde's actual data-model calls when behavior depends on the format or human-readable mode. + +Document user-visible changes in `CHANGELOG.md`. Update the README or crate-level documentation when a change affects setup, feature flags, supported representations, model boundaries, or migration steps. + +Keep pull requests and commits focused enough to review independently. Explain the concrete user problem and avoid adding generic abstractions without a current consumer. diff --git a/Cargo.lock b/Cargo.lock index 75f02d7..fbfd3c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,18 +52,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - [[package]] name = "clap" version = "4.6.1" @@ -110,56 +98,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "console" -version = "0.16.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "windows-sys", -] - -[[package]] -name = "encode_unicode" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -182,18 +126,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "insta" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" -dependencies = [ - "console", - "once_cell", - "similar", - "tempfile", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -212,24 +144,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "linux-raw-sys" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -263,25 +183,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - [[package]] name = "serde" version = "1.0.229" @@ -314,7 +215,6 @@ dependencies = [ name = "serde-shape-test-derive" version = "0.0.0" dependencies = [ - "insta", "serde", "serde-shape", "serde_json", @@ -324,7 +224,6 @@ dependencies = [ name = "serde-shape-test-integration" version = "0.0.0" dependencies = [ - "insta", "serde", "serde-shape", "serde_test", @@ -335,7 +234,6 @@ dependencies = [ name = "serde-shape-test-no-std" version = "0.0.0" dependencies = [ - "insta", "serde-shape", ] @@ -401,12 +299,6 @@ dependencies = [ "serde", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "strsim" version = "0.11.1" @@ -435,19 +327,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6811622..3a6950b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,6 @@ serde-shape-derive = { version = "=0.0.1", path = "serde-shape-derive" } # crates.io dependencies clap = { version = "4.6.1" } -insta = { version = "1.48.0" } proc-macro-crate = { version = "3.5.0" } proc-macro2 = { version = "1.0.95" } quote = { version = "1.0.40" } diff --git a/README.md b/README.md index fe56db0..ff94a8d 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ [docs-url]: https://docs.rs/serde-shape [msrv-badge]: https://img.shields.io/badge/MSRV-1.85-green?logo=rust [license-badge]: https://img.shields.io/crates/l/serde-shape -[license-url]: LICENSE +[license-url]: https://www.apache.org/licenses/LICENSE-2.0 [actions-badge]: https://github.com/fast/serde-shape/workflows/CI/badge.svg [actions-url]: https://github.com/fast/serde-shape/actions?query=workflow%3ACI -`serde-shape` reflects the shape of Serde serialization and deserialization at compile time. +`serde-shape` builds inspectable graphs of Serde serialization and deserialization shapes. Derive macros generate the metadata code at compile time; calling `serialize_shape()` or `deserialize_shape()` constructs the graph at runtime without serializing or deserializing a value. 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. @@ -65,7 +65,9 @@ use serde_shape::{ #[derive(DeserializeShape)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] +/// Application configuration. struct Config { + /// Port used by the HTTP server. http_port: u16, peers: Vec, tls: Option, @@ -79,25 +81,67 @@ struct TlsConfig { } let graph = Config::deserialize_shape(); -let ShapeRef::Definition(config_id) = graph.root else { +let ShapeRef::Definition(config_id) = graph.root() else { panic!("Config should produce a named definition"); }; -let definition = graph.definition(config_id).unwrap(); +let definition = graph.definition(*config_id).unwrap(); let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("Config should produce a struct shape"); }; assert_eq!(definition.type_name.name, "Config"); +assert_eq!(definition.description, Some("Application configuration.")); assert_eq!(shape.style, FieldsStyle::Struct); assert!(shape.attributes.deny_unknown_fields); assert_eq!(shape.fields[0].name, "http-port"); +assert_eq!(shape.fields[0].description, Some("Port used by the HTTP server.")); assert_eq!(shape.fields[1].name, "peers"); assert_eq!(shape.fields[2].name, "tls"); ``` See the [crate documentation][docs-url] for the full shape graph model, derive behavior, and manual implementation examples. +Rust doc comments on derived containers, variants, and fields are preserved as descriptions. Consumers can use the same comments for generated configuration references, CLI help, or diagnostics. + +## Custom representations + +Custom Serde functions and foreign types do not expose enough information for `serde-shape` to infer their wire representation. Provide functions that build the serialization and deserialization shapes explicitly: + +```rust +use serde_shape::{ + DeserializeShape, DeserializeShapeContext, SerializeShape, SerializeShapeContext, ShapeRef, +}; + +struct ForeignDuration; + +fn serialize_duration(context: &mut SerializeShapeContext) -> ShapeRef { + String::serialize_shape_in(context) +} + +fn deserialize_duration(_context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::union([ShapeRef::String, ShapeRef::U64]) +} + +#[derive(SerializeShape, DeserializeShape)] +struct Config { + #[serde(with = "duration_format")] + #[serde_shape( + serialize_with = "serialize_duration", + deserialize_with = "deserialize_duration" + )] + timeout: ForeignDuration, +} +``` + +Each function receives the current graph context and returns a `ShapeRef`. It may delegate to another type's shape implementation or construct a custom shape directly. A custom shape function is an assertion about the Serde behavior; `serde-shape` cannot verify that the declared shape matches the serializer or deserializer implementation. + +## Model boundaries + +Shape graphs are an inspection API, not a stable interchange format. `ShapeId` values are local to one graph, and definition ordering and `Debug` output are not persistence contracts. + +Types that branch on `Serializer::is_human_readable()` or `Deserializer::is_human_readable()` may expose a union of their known representations. The graph describes the possible Serde calls across formats; it is not specialized for one serializer format. + ## Feature flags `serde-shape` enables no features by default. @@ -133,6 +177,10 @@ This crate's minimum supported `rustc` version is `1.85.0`. The current policy is that the minimum Rust version required to use this crate can be increased in minor version updates. For example, if `crate 1.0` requires Rust 1.85.0, then `crate 1.0.z` for all values of `z` will also require Rust 1.85.0 or newer. However, `crate 1.y` for `y > 0` may require a newer minimum version of Rust. +## Contributing + +See the [contributor guide](CONTRIBUTING.md) for the development workflow and test conventions. + ## License -This project is licensed under [Apache License, Version 2.0](LICENSE). +This project is licensed under the [Apache License, Version 2.0](https://github.com/fast/serde-shape/blob/main/LICENSE). diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index 72fa3f3..e3078ba 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -39,8 +39,13 @@ use syn::TypeParamBound; use syn::parse_macro_input; use syn::parse_quote; +mod shape_attr; + +use shape_attr::ShapeAttrs; +use shape_attr::description; + /// Derive `serde_shape::SerializeShape` from Serde serialize metadata. -#[proc_macro_derive(SerializeShape, attributes(serde))] +#[proc_macro_derive(SerializeShape, attributes(serde, serde_shape))] pub fn derive_serialize_shape(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); @@ -51,7 +56,7 @@ pub fn derive_serialize_shape(input: TokenStream) -> TokenStream { } /// Derive `serde_shape::DeserializeShape` from Serde deserialize metadata. -#[proc_macro_derive(DeserializeShape, attributes(serde))] +#[proc_macro_derive(DeserializeShape, attributes(serde, serde_shape))] pub fn derive_deserialize_shape(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); @@ -64,11 +69,13 @@ pub fn derive_deserialize_shape(input: TokenStream) -> TokenStream { fn expand_serialize_shape(input: &DeriveInput) -> syn::Result { let serde_shape = serde_shape_crate()?; let container = parse_container(input, Derive::Serialize)?; + let shape_attrs = ShapeAttrs::parse(&input.attrs)?; + validate_shape_attrs(&container)?; let ident = &input.ident; let mut generics = input.generics.clone(); - add_serialize_shape_bounds(&mut generics, &container); + add_serialize_shape_bounds(&mut generics, &container, &shape_attrs)?; let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); - let body = serialize_shape_body(&container); + let body = serialize_shape_body(&container, &shape_attrs)?; Ok(quote! { const _: () = { @@ -88,11 +95,13 @@ fn expand_serialize_shape(input: &DeriveInput) -> syn::Result { fn expand_deserialize_shape(input: &DeriveInput) -> syn::Result { let serde_shape = serde_shape_crate()?; let container = parse_container(input, Derive::Deserialize)?; + let shape_attrs = ShapeAttrs::parse(&input.attrs)?; + validate_shape_attrs(&container)?; let ident = &input.ident; let mut generics = input.generics.clone(); - add_deserialize_shape_bounds(&mut generics, &container); + add_deserialize_shape_bounds(&mut generics, &container, &shape_attrs)?; let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); - let body = deserialize_shape_body(&container); + let body = deserialize_shape_body(&container, &shape_attrs)?; Ok(quote! { const _: () = { @@ -136,20 +145,61 @@ fn parse_container<'a>(input: &'a DeriveInput, derive: Derive) -> syn::Result) { - if container.attrs.type_into().is_some() || container.attrs.remote().is_some() { - return; +fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> { + match &container.data { + ast::Data::Enum(variants) => { + for variant in variants { + let attrs = ShapeAttrs::parse(&variant.original.attrs)?; + if !attrs.is_empty() { + return Err(syn::Error::new_spanned( + variant.original, + "serde_shape custom functions are supported on containers and fields, not variants", + )); + } + for field in &variant.fields { + ShapeAttrs::parse(&field.original.attrs)?; + } + } + } + ast::Data::Struct(_, fields) => { + for field in fields { + ShapeAttrs::parse(&field.original.attrs)?; + } + } } + Ok(()) +} +fn add_serialize_shape_bounds( + generics: &mut syn::Generics, + container: &ast::Container<'_>, + shape_attrs: &ShapeAttrs, +) -> syn::Result<()> { let type_params: BTreeSet<_> = generics .type_params() .map(|param| param.ident.to_string()) .collect(); + if shape_attrs.serialize_with().is_some() { + return Ok(()); + } + if container.attrs.remote().is_some() { + return Ok(()); + } + if let Some(ty) = container.attrs.type_into() { + if type_uses_params(ty, &type_params) { + generics + .make_where_clause() + .predicates + .push(parse_quote!(#ty: __serde_shape::SerializeShape)); + } + return Ok(()); + } + let mut field_bound_types = Vec::new(); match &container.data { ast::Data::Struct(_, fields) => { - collect_serialize_field_bound_types(fields, &type_params, &mut field_bound_types); + collect_serialize_field_bound_types(fields, &type_params, &mut field_bound_types)?; } ast::Data::Enum(variants) => { for variant in variants { @@ -160,7 +210,7 @@ fn add_serialize_shape_bounds(generics: &mut syn::Generics, container: &ast::Con &variant.fields, &type_params, &mut field_bound_types, - ); + )?; } } } @@ -171,25 +221,43 @@ fn add_serialize_shape_bounds(generics: &mut syn::Generics, container: &ast::Con .predicates .push(parse_quote!(#ty: __serde_shape::SerializeShape)); } + Ok(()) } -fn add_deserialize_shape_bounds(generics: &mut syn::Generics, container: &ast::Container<'_>) { - if container.attrs.type_from().is_some() - || container.attrs.type_try_from().is_some() - || container.attrs.remote().is_some() - { - return; - } - +fn add_deserialize_shape_bounds( + generics: &mut syn::Generics, + container: &ast::Container<'_>, + shape_attrs: &ShapeAttrs, +) -> syn::Result<()> { let type_params: BTreeSet<_> = generics .type_params() .map(|param| param.ident.to_string()) .collect(); + if shape_attrs.deserialize_with().is_some() { + return Ok(()); + } + if container.attrs.remote().is_some() { + return Ok(()); + } + if let Some(ty) = container + .attrs + .type_from() + .or_else(|| container.attrs.type_try_from()) + { + if type_uses_params(ty, &type_params) { + generics + .make_where_clause() + .predicates + .push(parse_quote!(#ty: __serde_shape::DeserializeShape)); + } + return Ok(()); + } + let mut field_bound_types = Vec::new(); match &container.data { ast::Data::Struct(_, fields) => { - collect_deserialize_field_bound_types(fields, &type_params, &mut field_bound_types); + collect_deserialize_field_bound_types(fields, &type_params, &mut field_bound_types)?; } ast::Data::Enum(variants) => { for variant in variants { @@ -201,7 +269,7 @@ fn add_deserialize_shape_bounds(generics: &mut syn::Generics, container: &ast::C &variant.fields, &type_params, &mut field_bound_types, - ); + )?; } } } @@ -212,40 +280,41 @@ fn add_deserialize_shape_bounds(generics: &mut syn::Generics, container: &ast::C .predicates .push(parse_quote!(#ty: __serde_shape::DeserializeShape)); } + Ok(()) } fn collect_serialize_field_bound_types( fields: &[ast::Field<'_>], type_params: &BTreeSet, field_bound_types: &mut Vec, -) { +) -> syn::Result<()> { for field in fields { - if field.attrs.skip_serializing() || field.attrs.serialize_with().is_some() { + let shape_attrs = ShapeAttrs::parse(&field.original.attrs)?; + if field.attrs.skip_serializing() { continue; } - collect_field_bound_type(field, type_params, field_bound_types); + if shape_attrs.serialize_with().is_none() && field.attrs.serialize_with().is_none() { + collect_shape_bound_types(field.ty, type_params, field_bound_types); + } } + Ok(()) } fn collect_deserialize_field_bound_types( fields: &[ast::Field<'_>], type_params: &BTreeSet, field_bound_types: &mut Vec, -) { +) -> syn::Result<()> { for field in fields { - if field.attrs.skip_deserializing() || field.attrs.deserialize_with().is_some() { + let shape_attrs = ShapeAttrs::parse(&field.original.attrs)?; + if field.attrs.skip_deserializing() { continue; } - collect_field_bound_type(field, type_params, field_bound_types); + if shape_attrs.deserialize_with().is_none() && field.attrs.deserialize_with().is_none() { + collect_shape_bound_types(field.ty, type_params, field_bound_types); + } } -} - -fn collect_field_bound_type( - field: &ast::Field<'_>, - type_params: &BTreeSet, - field_bound_types: &mut Vec, -) { - collect_shape_bound_types(field.ty, type_params, field_bound_types); + Ok(()) } fn collect_shape_bound_types( @@ -330,6 +399,12 @@ fn collect_shape_bound_types( } } +fn type_uses_params(ty: &Type, type_params: &BTreeSet) -> bool { + let mut bound_types = Vec::new(); + collect_shape_bound_types(ty, type_params, &mut bound_types); + !bound_types.is_empty() +} + fn collect_path_arguments( arguments: &PathArguments, type_params: &BTreeSet, @@ -403,53 +478,84 @@ fn push_bound_type(field_bound_types: &mut Vec, ty: Type) { } } -fn serialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { +fn serialize_shape_body( + container: &ast::Container<'_>, + shape_attrs: &ShapeAttrs, +) -> syn::Result { + if let Some(function) = shape_attrs.serialize_with() { + return Ok(quote!(#function(context))); + } + if let Some(ty) = container.attrs.type_into() { + return Ok(quote!(<#ty as __serde_shape::SerializeShape>::serialize_shape_in(context))); + } + let name = lit(container.attrs.name().serialize_name()); - let kind = serialize_definition_kind(container); + let description = description(&container.original.attrs); + let description = option_lit(description.as_deref()); + let kind = serialize_definition_kind(container)?; - quote! { - context.define_named_type( + Ok(quote! { + context.define_named_type_with_description( __serde_shape::SerializeTypeName { rust_name: ::core::any::type_name::(), name: #name, }, + #description, |context| { #kind }, ) - } + }) } -fn deserialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { +fn deserialize_shape_body( + container: &ast::Container<'_>, + shape_attrs: &ShapeAttrs, +) -> syn::Result { + if let Some(function) = shape_attrs.deserialize_with() { + return Ok(quote!(#function(context))); + } + if let Some(ty) = container + .attrs + .type_from() + .or_else(|| container.attrs.type_try_from()) + { + return Ok(quote!(<#ty as __serde_shape::DeserializeShape>::deserialize_shape_in(context))); + } + let name = lit(container.attrs.name().deserialize_name()); - let kind = deserialize_definition_kind(container); + let description = description(&container.original.attrs); + let description = option_lit(description.as_deref()); + let kind = deserialize_definition_kind(container)?; - quote! { - context.define_named_type( + Ok(quote! { + context.define_named_type_with_description( __serde_shape::DeserializeTypeName { rust_name: ::core::any::type_name::(), name: #name, }, + #description, |context| { #kind }, ) - } + }) } -fn serialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { - if let Some(ty) = container.attrs.type_into() { - return serialize_opaque_definition("IntoType", ty); - } +fn serialize_definition_kind(container: &ast::Container<'_>) -> syn::Result { if let Some(path) = container.attrs.remote() { - return serialize_opaque_definition("Remote", path); + let opaque = remote_opaque_shape(path); + return Ok(quote!(__serde_shape::SerializeDefinitionKind::Opaque(#opaque))); } let attributes = serialize_container_attributes(&container.attrs); - match &container.data { + Ok(match &container.data { ast::Data::Struct(style, fields) => { let style = fields_style(*style); - let fields = fields.iter().map(serialize_field_shape); + let fields = fields + .iter() + .map(serialize_field_shape) + .collect::>>()?; quote! { __serde_shape::SerializeDefinitionKind::Struct(__serde_shape::SerializeStructShape { style: #style, @@ -460,7 +566,10 @@ fn serialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { } ast::Data::Enum(variants) => { let repr = tagging(container.attrs.tag()); - let variants = variants.iter().map(serialize_variant_shape); + let variants = variants + .iter() + .map(serialize_variant_shape) + .collect::>>()?; quote! { __serde_shape::SerializeDefinitionKind::Enum(__serde_shape::SerializeEnumShape { repr: #repr, @@ -469,25 +578,23 @@ fn serialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { }) } } - } + }) } -fn deserialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { - if let Some(ty) = container.attrs.type_from() { - return deserialize_opaque_definition("FromType", ty); - } - if let Some(ty) = container.attrs.type_try_from() { - return deserialize_opaque_definition("TryFromType", ty); - } +fn deserialize_definition_kind(container: &ast::Container<'_>) -> syn::Result { if let Some(path) = container.attrs.remote() { - return deserialize_opaque_definition("Remote", path); + let opaque = remote_opaque_shape(path); + return Ok(quote!(__serde_shape::DeserializeDefinitionKind::Opaque(#opaque))); } let attributes = deserialize_container_attributes(&container.attrs); - match &container.data { + Ok(match &container.data { ast::Data::Struct(style, fields) => { let style = fields_style(*style); - let fields = fields.iter().map(deserialize_field_shape); + let fields = fields + .iter() + .map(deserialize_field_shape) + .collect::>>()?; quote! { __serde_shape::DeserializeDefinitionKind::Struct(__serde_shape::DeserializeStructShape { style: #style, @@ -498,7 +605,10 @@ fn deserialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { } ast::Data::Enum(variants) => { let repr = tagging(container.attrs.tag()); - let variants = variants.iter().map(deserialize_variant_shape); + let variants = variants + .iter() + .map(deserialize_variant_shape) + .collect::>>()?; quote! { __serde_shape::DeserializeDefinitionKind::Enum(__serde_shape::DeserializeEnumShape { repr: #repr, @@ -507,38 +617,21 @@ fn deserialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { }) } } - } -} - -fn serialize_opaque_definition(reason: &str, detail: T) -> TokenStream2 -where - T: ToTokens, -{ - let reason = opaque_reason(reason); - let detail = lit(detail.to_token_stream().to_string()); - - quote! { - __serde_shape::SerializeDefinitionKind::Opaque(__serde_shape::OpaqueShape { - type_name: ::core::any::type_name::(), - reason: #reason, - detail: ::core::option::Option::Some(#detail), - }) - } + }) } -fn deserialize_opaque_definition(reason: &str, detail: T) -> TokenStream2 +fn remote_opaque_shape(detail: T) -> TokenStream2 where T: ToTokens, { - let reason = opaque_reason(reason); let detail = lit(detail.to_token_stream().to_string()); quote! { - __serde_shape::DeserializeDefinitionKind::Opaque(__serde_shape::OpaqueShape { + __serde_shape::OpaqueShape { type_name: ::core::any::type_name::(), - reason: #reason, + reason: __serde_shape::OpaqueReason::Remote, detail: ::core::option::Option::Some(#detail), - }) + } } } @@ -580,9 +673,11 @@ fn deserialize_container_attributes(attrs: &attr::Container) -> TokenStream2 { } } -fn serialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { +fn serialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result { let rust_name = lit(variant.ident.to_string()); let name = lit(variant.attrs.name().serialize_name()); + let description = description(&variant.original.attrs); + let description = option_lit(description.as_deref()); let style = fields_style(variant.style); let skip = variant.attrs.skip_serializing(); let untagged = variant.attrs.untagged(); @@ -598,7 +693,11 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { }) } } else { - let fields = variant.fields.iter().map(serialize_field_shape); + let fields = variant + .fields + .iter() + .map(serialize_field_shape) + .collect::>>()?; quote! { __serde_shape::SerializeVariantContent::Fields( __serde_shape::__private::vec![#(#fields),*], @@ -606,21 +705,24 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::SerializeVariantShape { rust_name: #rust_name, name: #name, + description: #description, style: #style, content: #content, untagged: #untagged, } - } + }) } -fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { +fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result { let rust_name = lit(variant.ident.to_string()); let name = lit(variant.attrs.name().deserialize_name()); let aliases = aliases(variant.attrs.aliases()); + let description = description(&variant.original.attrs); + let description = option_lit(description.as_deref()); let style = fields_style(variant.style); let skip = variant.attrs.skip_deserializing(); let other = variant.attrs.other(); @@ -637,7 +739,11 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { }) } } else { - let fields = variant.fields.iter().map(deserialize_field_shape); + let fields = variant + .fields + .iter() + .map(deserialize_field_shape) + .collect::>>()?; quote! { __serde_shape::DeserializeVariantContent::Fields( __serde_shape::__private::vec![#(#fields),*], @@ -645,22 +751,26 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::DeserializeVariantShape { rust_name: #rust_name, name: #name, aliases: #aliases, + description: #description, style: #style, content: #content, other: #other, untagged: #untagged, } - } + }) } -fn serialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { +fn serialize_field_shape(field: &ast::Field<'_>) -> syn::Result { + let shape_attrs = ShapeAttrs::parse(&field.original.attrs)?; let member = field_member(&field.member); let name = lit(field.attrs.name().serialize_name()); + let description = description(&field.original.attrs); + let description = option_lit(description.as_deref()); let skip = field.attrs.skip_serializing(); let skip_if = option_path(field.attrs.skip_serializing_if()); let flatten = field.attrs.flatten(); @@ -669,7 +779,9 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { let wire_shape = if skip { quote!(__serde_shape::FieldWireShape::Omitted) } else { - let value_shape = if let Some(custom_serializer) = field.attrs.serialize_with() { + let value_shape = if let Some(function) = shape_attrs.serialize_with() { + quote!(#function(context)) + } else if let Some(custom_serializer) = field.attrs.serialize_with() { let detail = option_path(Some(custom_serializer)); quote! { __serde_shape::ShapeRef::Opaque(__serde_shape::OpaqueShape { @@ -691,20 +803,24 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::SerializeFieldShape { member: #member, name: #name, + description: #description, wire_shape: #wire_shape, skip_if: #skip_if, } - } + }) } -fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { +fn deserialize_field_shape(field: &ast::Field<'_>) -> syn::Result { + let shape_attrs = ShapeAttrs::parse(&field.original.attrs)?; let member = field_member(&field.member); let name = lit(field.attrs.name().deserialize_name()); let aliases = aliases(field.attrs.aliases()); + let description = description(&field.original.attrs); + let description = option_lit(description.as_deref()); let skip = field.attrs.skip_deserializing(); let default = default_shape(field.attrs.default()); let flatten = field.attrs.flatten(); @@ -713,7 +829,9 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { let wire_shape = if skip { quote!(__serde_shape::FieldWireShape::Omitted) } else { - let value_shape = if let Some(custom_deserializer) = field.attrs.deserialize_with() { + let value_shape = if let Some(function) = shape_attrs.deserialize_with() { + quote!(#function(context)) + } else if let Some(custom_deserializer) = field.attrs.deserialize_with() { let detail = option_path(Some(custom_deserializer)); quote! { __serde_shape::ShapeRef::Opaque(__serde_shape::OpaqueShape { @@ -735,15 +853,16 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::DeserializeFieldShape { member: #member, name: #name, aliases: #aliases, + description: #description, wire_shape: #wire_shape, default: #default, } - } + }) } fn field_member(member: &Member) -> TokenStream2 { @@ -798,16 +917,6 @@ fn default_shape(default: &attr::Default) -> TokenStream2 { } } -fn opaque_reason(reason: &str) -> TokenStream2 { - match reason { - "FromType" => quote!(__serde_shape::OpaqueReason::FromType), - "TryFromType" => quote!(__serde_shape::OpaqueReason::TryFromType), - "IntoType" => quote!(__serde_shape::OpaqueReason::IntoType), - "Remote" => quote!(__serde_shape::OpaqueReason::Remote), - _ => quote!(__serde_shape::OpaqueReason::Unsupported), - } -} - fn aliases(aliases: &BTreeSet) -> TokenStream2 { let aliases = aliases.iter().map(lit); quote!(__serde_shape::__private::vec![#(#aliases),*]) diff --git a/serde-shape-derive/src/shape_attr.rs b/serde-shape-derive/src/shape_attr.rs new file mode 100644 index 0000000..629a333 --- /dev/null +++ b/serde-shape-derive/src/shape_attr.rs @@ -0,0 +1,122 @@ +// 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 proc_macro2::Span; +use syn::Attribute; +use syn::Expr; +use syn::ExprPath; +use syn::Lit; +use syn::LitStr; +use syn::meta::ParseNestedMeta; +use syn::spanned::Spanned; + +#[derive(Default)] +pub struct ShapeAttrs { + serialize_with: Option<(ExprPath, Span)>, + deserialize_with: Option<(ExprPath, Span)>, +} + +impl ShapeAttrs { + pub fn parse(attrs: &[Attribute]) -> syn::Result { + let mut parsed = Self::default(); + + for attr in attrs { + if !attr.path().is_ident("serde_shape") { + continue; + } + + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("serialize_with") { + set_once( + &mut parsed.serialize_with, + parse_path(&meta)?, + meta.path.span(), + ) + } else if meta.path.is_ident("deserialize_with") { + set_once( + &mut parsed.deserialize_with, + parse_path(&meta)?, + meta.path.span(), + ) + } else { + Err(meta.error( + "unknown serde_shape attribute; expected `serialize_with` or `deserialize_with`", + )) + } + })?; + } + + Ok(parsed) + } + + pub fn serialize_with(&self) -> Option<&ExprPath> { + self.serialize_with.as_ref().map(|(path, _)| path) + } + + pub fn deserialize_with(&self) -> Option<&ExprPath> { + self.deserialize_with.as_ref().map(|(path, _)| path) + } + + pub fn is_empty(&self) -> bool { + self.serialize_with.is_none() && self.deserialize_with.is_none() + } +} + +pub fn description(attrs: &[Attribute]) -> Option { + let mut lines = attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .filter_map(|attr| match &attr.meta { + syn::Meta::NameValue(meta) => match &meta.value { + Expr::Lit(expr) => match &expr.lit { + Lit::Str(line) => { + let line = line.value(); + Some( + line.strip_prefix(' ') + .unwrap_or(&line) + .trim_end() + .to_owned(), + ) + } + _ => None, + }, + _ => None, + }, + _ => None, + }) + .collect::>(); + + while lines.first().is_some_and(String::is_empty) { + lines.remove(0); + } + while lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + + (!lines.is_empty()).then(|| lines.join("\n")) +} + +fn parse_path(meta: &ParseNestedMeta<'_>) -> syn::Result { + let value = meta.value()?; + let value: LitStr = value.parse()?; + value.parse() +} + +fn set_once(slot: &mut Option<(T, Span)>, value: T, span: Span) -> syn::Result<()> { + if slot.is_some() { + return Err(syn::Error::new(span, "duplicate serde_shape attribute")); + } + *slot = Some((value, span)); + Ok(()) +} diff --git a/serde-shape/Cargo.toml b/serde-shape/Cargo.toml index c357d57..76f0cf1 100644 --- a/serde-shape/Cargo.toml +++ b/serde-shape/Cargo.toml @@ -17,7 +17,7 @@ name = "serde-shape" version = "0.0.1" categories = ["development-tools", "encoding"] -description = "Reflect Serde serialization and deserialization shapes at compile time." +description = "Build inspectable graphs of Serde serialization and deserialization shapes." documentation = "https://docs.rs/serde-shape" keywords = ["serde", "derive", "reflection", "schema", "shape"] diff --git a/serde-shape/src/impls/net.rs b/serde-shape/src/impls/net.rs index acb8b95..9168d32 100644 --- a/serde-shape/src/impls/net.rs +++ b/serde-shape/src/impls/net.rs @@ -182,10 +182,12 @@ fn serialize_newtype_variant(name: &'static str, shape: ShapeRef) -> SerializeVa SerializeVariantShape { rust_name: name, name, + description: None, style: FieldsStyle::Newtype, content: SerializeVariantContent::Fields(vec![SerializeFieldShape { member: FieldMember::Unnamed(0), name: "0", + description: None, wire_shape: FieldWireShape::Value(shape), skip_if: None, }]), @@ -198,11 +200,13 @@ fn deserialize_newtype_variant(name: &'static str, shape: ShapeRef) -> Deseriali rust_name: name, name, aliases: vec![name], + description: None, style: FieldsStyle::Newtype, content: DeserializeVariantContent::Fields(vec![DeserializeFieldShape { member: FieldMember::Unnamed(0), name: "0", aliases: vec!["0"], + description: None, wire_shape: FieldWireShape::Value(shape), default: DefaultShape::None, }]), diff --git a/serde-shape/src/impls/result.rs b/serde-shape/src/impls/result.rs index b97146b..8499934 100644 --- a/serde-shape/src/impls/result.rs +++ b/serde-shape/src/impls/result.rs @@ -107,10 +107,12 @@ fn serialize_result_variant(name: &'static str, shape: ShapeRef) -> SerializeVar SerializeVariantShape { rust_name: name, name, + description: None, style: FieldsStyle::Newtype, content: SerializeVariantContent::Fields(vec![SerializeFieldShape { member: FieldMember::Unnamed(0), name: "0", + description: None, wire_shape: FieldWireShape::Value(shape), skip_if: None, }]), @@ -123,11 +125,13 @@ fn deserialize_result_variant(name: &'static str, shape: ShapeRef) -> Deserializ rust_name: name, name, aliases: vec![name], + description: None, style: FieldsStyle::Newtype, content: DeserializeVariantContent::Fields(vec![DeserializeFieldShape { member: FieldMember::Unnamed(0), name: "0", aliases: vec!["0"], + description: None, wire_shape: FieldWireShape::Value(shape), default: DefaultShape::None, }]), diff --git a/serde-shape/src/impls/time.rs b/serde-shape/src/impls/time.rs index c30e9f1..aafc639 100644 --- a/serde-shape/src/impls/time.rs +++ b/serde-shape/src/impls/time.rs @@ -51,12 +51,14 @@ impl SerializeShape for Duration { SerializeFieldShape { member: FieldMember::Named("secs"), name: "secs", + description: None, wire_shape: FieldWireShape::Value(ShapeRef::U64), skip_if: None, }, SerializeFieldShape { member: FieldMember::Named("nanos"), name: "nanos", + description: None, wire_shape: FieldWireShape::Value(ShapeRef::U32), skip_if: None, }, @@ -88,6 +90,7 @@ impl DeserializeShape for Duration { member: FieldMember::Named("secs"), name: "secs", aliases: vec!["secs"], + description: None, wire_shape: FieldWireShape::Value(ShapeRef::U64), default: DefaultShape::None, }, @@ -95,6 +98,7 @@ impl DeserializeShape for Duration { member: FieldMember::Named("nanos"), name: "nanos", aliases: vec!["nanos"], + description: None, wire_shape: FieldWireShape::Value(ShapeRef::U32), default: DefaultShape::None, }, diff --git a/serde-shape/src/impls/wrapper.rs b/serde-shape/src/impls/wrapper.rs index 00725a5..74a24c5 100644 --- a/serde-shape/src/impls/wrapper.rs +++ b/serde-shape/src/impls/wrapper.rs @@ -78,18 +78,6 @@ transparent_shape! { deserialize { T: DeserializeShape + ?Sized } => T; - ('a, T) Cow<'a, T> - where - serialize { - T: ToOwned + ?Sized, - ::Owned: SerializeShape - } - deserialize { - T: ToOwned + ?Sized, - ::Owned: DeserializeShape - } - => ::Owned; - (T) Cell where serialize { T: Copy + SerializeShape } @@ -130,6 +118,25 @@ transparent_shape! { => T; } +impl SerializeShape for Cow<'_, T> +where + T: ToOwned + SerializeShape + ?Sized, +{ + fn serialize_shape_in(context: &mut SerializeShapeContext) -> ShapeRef { + T::serialize_shape_in(context) + } +} + +impl DeserializeShape for Cow<'_, T> +where + T: ToOwned + ?Sized, + T::Owned: DeserializeShape, +{ + fn deserialize_shape_in(context: &mut DeserializeShapeContext) -> ShapeRef { + T::Owned::deserialize_shape_in(context) + } +} + impl SerializeShape for PhantomData { fn serialize_shape_in(_context: &mut SerializeShapeContext) -> ShapeRef { ShapeRef::Unit diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index a47e265..ec5ae50 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -71,10 +71,10 @@ //! } //! //! let graph = Config::deserialize_shape(); -//! let ShapeRef::Definition(config_id) = graph.root else { +//! let ShapeRef::Definition(config_id) = graph.root() else { //! panic!("Config should produce a named definition"); //! }; -//! let config = graph.definition(config_id).unwrap(); +//! let config = graph.definition(*config_id).unwrap(); //! //! let DeserializeDefinitionKind::Struct(shape) = &config.kind else { //! panic!("Config should produce a struct shape"); @@ -111,15 +111,15 @@ //! let serialize_graph = Message::serialize_shape(); //! let deserialize_graph = Message::deserialize_shape(); //! -//! let ShapeRef::Definition(serialize_id) = serialize_graph.root else { +//! let ShapeRef::Definition(serialize_id) = serialize_graph.root() else { //! panic!("Message should produce a named serialization definition"); //! }; -//! let ShapeRef::Definition(deserialize_id) = deserialize_graph.root else { +//! let ShapeRef::Definition(deserialize_id) = deserialize_graph.root() else { //! panic!("Message should produce a named deserialization definition"); //! }; //! -//! let serialize_definition = serialize_graph.definition(serialize_id).unwrap(); -//! let deserialize_definition = deserialize_graph.definition(deserialize_id).unwrap(); +//! let serialize_definition = serialize_graph.definition(*serialize_id).unwrap(); +//! let deserialize_definition = deserialize_graph.definition(*deserialize_id).unwrap(); //! //! assert_eq!(serialize_definition.type_name.name, "wire-output"); //! assert_eq!(deserialize_definition.type_name.name, "wire-input"); @@ -143,7 +143,12 @@ //! stored as named definitions and referenced by [`ShapeId`]. //! //! Definition IDs are local to one graph. Use [`SerializeShapeGraph::definition`] or -//! [`DeserializeShapeGraph::definition`] to resolve them. +//! [`DeserializeShapeGraph::definition`] to resolve them. Definition ordering and debug output +//! are not stable persistence formats. +//! +//! Types that branch on Serde's human-readable mode may expose a union of their known +//! representations. Shape graphs describe possible Serde data-model calls across formats rather +//! than specializing themselves for one serializer. //! //! # Derive behavior //! @@ -151,12 +156,20 @@ //! follows the metadata Serde derives for each direction. //! //! A custom serializer or deserializer has no inferable inner shape, so the affected field or -//! variant content is represented by an opaque boundary. Whole-container conversion and -//! remote-derive attributes are represented as opaque definitions. +//! variant content is represented by an opaque boundary. Whole-container conversion attributes +//! use the conversion type's shape, while remote-derive attributes remain opaque. //! 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. //! +//! Use `#[serde_shape(serialize_with = "path")]` or +//! `#[serde_shape(deserialize_with = "path")]` to declare the representation of a container or +//! field that cannot be inferred. Each function receives the current graph context and returns a +//! [`ShapeRef`], so it can delegate to another type or build a custom shape directly. +//! +//! Rust doc comments on derived containers, variants, and fields are preserved in their +//! `description` fields for documentation and diagnostic consumers. +//! //! # Manual implementations //! //! Implement [`trait@SerializeShape`] or [`trait@DeserializeShape`] manually when a type's Serde @@ -177,8 +190,8 @@ //! } //! //! assert_eq!( -//! ByteSize::deserialize_shape().root, -//! ShapeRef::union([ShapeRef::String, ShapeRef::U64]) +//! ByteSize::deserialize_shape().root(), +//! &ShapeRef::union([ShapeRef::String, ShapeRef::U64]) //! ); //! ``` //! @@ -216,6 +229,10 @@ pub mod __private { /// metadata that Serde uses for deserialization. The generated implementation records the /// deserialization-side names, shape graph, and Serde field/container metadata. /// +/// Use `#[serde_shape(deserialize_with = "path")]` on a container or field to override an +/// opaque or foreign representation. The function must accept `&mut DeserializeShapeContext` +/// and return a [`ShapeRef`]. +/// /// # Example /// /// ```rust @@ -233,10 +250,10 @@ pub mod __private { /// } /// /// let graph = Config::deserialize_shape(); -/// let ShapeRef::Definition(id) = graph.root else { +/// let ShapeRef::Definition(id) = graph.root() else { /// panic!("Config should produce a named definition"); /// }; -/// let definition = graph.definition(id).unwrap(); +/// let definition = graph.definition(*id).unwrap(); /// /// let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { /// panic!("Config should produce a struct shape"); @@ -255,6 +272,10 @@ pub use serde_shape_derive::DeserializeShape; /// metadata that Serde uses for serialization. The generated implementation records the /// serialization-side names, shape graph, and Serde field/container metadata. /// +/// Use `#[serde_shape(serialize_with = "path")]` on a container or field to override an opaque +/// or foreign representation. The function must accept `&mut SerializeShapeContext` and return +/// a [`ShapeRef`]. +/// /// # Example /// /// ```rust @@ -271,10 +292,10 @@ pub use serde_shape_derive::DeserializeShape; /// } /// /// let graph = Response::serialize_shape(); -/// let ShapeRef::Definition(id) = graph.root else { +/// let ShapeRef::Definition(id) = graph.root() else { /// panic!("Response should produce a named definition"); /// }; -/// let definition = graph.definition(id).unwrap(); +/// let definition = graph.definition(*id).unwrap(); /// /// let SerializeDefinitionKind::Struct(shape) = &definition.kind else { /// panic!("Response should produce a struct shape"); @@ -323,9 +344,9 @@ pub trait DeserializeShape { #[derive(Clone, Debug, Eq, PartialEq)] pub struct SerializeShapeGraph { /// The root shape reference. - pub root: ShapeRef, + root: ShapeRef, /// Named type definitions reachable from the root. - pub definitions: Vec, + definitions: Vec, } impl SerializeShapeGraph { @@ -342,6 +363,16 @@ impl SerializeShapeGraph { } } + /// Return the root shape reference. + pub fn root(&self) -> &ShapeRef { + &self.root + } + + /// Return the named definitions reachable from the root. + pub fn definitions(&self) -> &[SerializeDefinitionShape] { + &self.definitions + } + /// Return a definition by id. pub fn definition(&self, id: ShapeId) -> Option<&SerializeDefinitionShape> { self.definitions.get(id.0) @@ -352,9 +383,9 @@ impl SerializeShapeGraph { #[derive(Clone, Debug, Eq, PartialEq)] pub struct DeserializeShapeGraph { /// The root shape reference. - pub root: ShapeRef, + root: ShapeRef, /// Named type definitions reachable from the root. - pub definitions: Vec, + definitions: Vec, } impl DeserializeShapeGraph { @@ -371,6 +402,16 @@ impl DeserializeShapeGraph { } } + /// Return the root shape reference. + pub fn root(&self) -> &ShapeRef { + &self.root + } + + /// Return the named definitions reachable from the root. + pub fn definitions(&self) -> &[DeserializeDefinitionShape] { + &self.definitions + } + /// Return a definition by id. pub fn definition(&self, id: ShapeId) -> Option<&DeserializeDefinitionShape> { self.definitions.get(id.0) @@ -390,6 +431,22 @@ impl SerializeShapeContext { /// The concrete builder type and diagnostic Rust name form the graph-local identity. Call this /// method from one stable closure expression for every occurrence of the same named type. pub fn define_named_type(&mut self, type_name: SerializeTypeName, build: F) -> ShapeRef + where + F: FnOnce(&mut Self) -> SerializeDefinitionKind + 'static, + { + self.define_named_type_with_description(type_name, None, build) + } + + /// Define a named type with user-facing documentation. + /// + /// This behaves like [`Self::define_named_type`] and stores `description` on the resulting + /// definition. + pub fn define_named_type_with_description( + &mut self, + type_name: SerializeTypeName, + description: Option<&'static str>, + build: F, + ) -> ShapeRef where F: FnOnce(&mut Self) -> SerializeDefinitionKind + 'static, { @@ -406,6 +463,7 @@ impl SerializeShapeContext { self.definitions[id.0] = Some(SerializeDefinitionShape { id, type_name, + description, kind, }); ShapeRef::Definition(id) @@ -432,6 +490,22 @@ impl DeserializeShapeContext { /// The concrete builder type and diagnostic Rust name form the graph-local identity. Call this /// method from one stable closure expression for every occurrence of the same named type. pub fn define_named_type(&mut self, type_name: DeserializeTypeName, build: F) -> ShapeRef + where + F: FnOnce(&mut Self) -> DeserializeDefinitionKind + 'static, + { + self.define_named_type_with_description(type_name, None, build) + } + + /// Define a named type with user-facing documentation. + /// + /// This behaves like [`Self::define_named_type`] and stores `description` on the resulting + /// definition. + pub fn define_named_type_with_description( + &mut self, + type_name: DeserializeTypeName, + description: Option<&'static str>, + build: F, + ) -> ShapeRef where F: FnOnce(&mut Self) -> DeserializeDefinitionKind + 'static, { @@ -448,6 +522,7 @@ impl DeserializeShapeContext { self.definitions[id.0] = Some(DeserializeDefinitionShape { id, type_name, + description, kind, }); ShapeRef::Definition(id) @@ -463,7 +538,14 @@ impl DeserializeShapeContext { /// Identifies a named shape definition. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct ShapeId(pub usize); +pub struct ShapeId(usize); + +impl ShapeId { + /// Return this id's graph-local definition index. + pub const fn index(self) -> usize { + self.0 + } +} /// Names associated with a Rust type and its Serde serializer. #[derive(Clone, Debug, Eq, PartialEq)] @@ -673,6 +755,8 @@ pub struct SerializeDefinitionShape { pub id: ShapeId, /// The Rust and Serde names for this definition. pub type_name: SerializeTypeName, + /// User-facing documentation for this definition, if available. + pub description: Option<&'static str>, /// The definition body. pub kind: SerializeDefinitionKind, } @@ -684,6 +768,8 @@ pub struct DeserializeDefinitionShape { pub id: ShapeId, /// The Rust and Serde names for this definition. pub type_name: DeserializeTypeName, + /// User-facing documentation for this definition, if available. + pub description: Option<&'static str>, /// The definition body. pub kind: DeserializeDefinitionKind, } @@ -827,6 +913,8 @@ pub struct SerializeFieldShape { pub member: FieldMember, /// The primary Serde serialize name. pub name: &'static str, + /// User-facing documentation for this field, if available. + pub description: Option<&'static str>, /// How this field contributes to the serialized wire shape. pub wire_shape: FieldWireShape, /// The predicate used to skip this field during serialization. @@ -842,6 +930,8 @@ pub struct DeserializeFieldShape { pub name: &'static str, /// All accepted Serde deserialize names, including the primary name. pub aliases: Vec<&'static str>, + /// User-facing documentation for this field, if available. + pub description: Option<&'static str>, /// How this field contributes to the deserialized wire shape. pub wire_shape: FieldWireShape, /// The default used if this field is missing. @@ -878,6 +968,8 @@ pub struct SerializeVariantShape { pub rust_name: &'static str, /// The primary Serde serialize name. pub name: &'static str, + /// User-facing documentation for this variant, if available. + pub description: Option<&'static str>, /// The variant field style. pub style: FieldsStyle, /// How the variant contributes its serialized content. @@ -907,6 +999,8 @@ pub struct DeserializeVariantShape { pub name: &'static str, /// All accepted Serde deserialize names, including the primary name. pub aliases: Vec<&'static str>, + /// User-facing documentation for this variant, if available. + pub description: Option<&'static str>, /// The variant field style. pub style: FieldsStyle, /// How the variant contributes its deserialized content. @@ -961,12 +1055,6 @@ pub struct OpaqueShape { /// Reason a shape cannot be represented precisely. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum OpaqueReason { - /// The type uses `#[serde(from = "...")]`. - FromType, - /// The type uses `#[serde(try_from = "...")]`. - TryFromType, - /// The type uses `#[serde(into = "...")]`. - IntoType, /// The type uses `#[serde(remote = "...")]`. Remote, /// A custom serializer controls the output. diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index b975949..3941839 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -13,6 +13,7 @@ // limitations under the License. use alloc::borrow::Cow; +use alloc::borrow::ToOwned; use alloc::boxed::Box; use alloc::collections::BTreeMap; use alloc::collections::BinaryHeap; @@ -21,11 +22,13 @@ use alloc::collections::VecDeque; use alloc::string::String; use alloc::vec; use alloc::vec::Vec; +use core::borrow::Borrow; use core::cell::Cell; use core::cmp::Reverse; use core::num::Wrapping; use crate::DeserializeDefinitionKind; +use crate::DeserializeShape; use crate::DeserializeShapeContext; use crate::DeserializeShapeGraph; use crate::DeserializeTypeName; @@ -34,12 +37,43 @@ use crate::FieldsStyle; use crate::OpaqueReason; use crate::OpaqueShape; use crate::SerializeDefinitionKind; +use crate::SerializeShape; use crate::SerializeShapeContext; use crate::SerializeShapeGraph; use crate::SerializeTypeName; use crate::ShapeRef; use crate::Tagging; +struct BorrowedShape; + +struct OwnedShape(BorrowedShape); + +impl ToOwned for BorrowedShape { + type Owned = OwnedShape; + + fn to_owned(&self) -> Self::Owned { + OwnedShape(BorrowedShape) + } +} + +impl Borrow for OwnedShape { + fn borrow(&self) -> &BorrowedShape { + &self.0 + } +} + +impl SerializeShape for BorrowedShape { + fn serialize_shape_in(_context: &mut SerializeShapeContext) -> ShapeRef { + ShapeRef::U8 + } +} + +impl DeserializeShape for OwnedShape { + fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::String + } +} + #[test] fn classifies_flat_numeric_shapes() { assert!(ShapeRef::I8.is_signed_integer()); @@ -152,12 +186,12 @@ fn keeps_distinct_definition_builders_with_the_same_type_name() { #[test] fn maps_atomic_shapes() { assert_eq!( - SerializeShapeGraph::for_type::().root, - ShapeRef::Usize + SerializeShapeGraph::for_type::().root(), + &ShapeRef::Usize ); assert_eq!( - DeserializeShapeGraph::for_type::().root, - ShapeRef::Usize + DeserializeShapeGraph::for_type::().root(), + &ShapeRef::Usize ); } @@ -170,35 +204,35 @@ fn builds_map_shape() { value: Box::new(ShapeRef::Option(Box::new(ShapeRef::U16))), }; - assert_eq!(serialize_shape.root, expected); - assert!(serialize_shape.definitions.is_empty()); - assert_eq!(deserialize_shape.root, expected); - assert!(deserialize_shape.definitions.is_empty()); + assert_eq!(serialize_shape.root(), &expected); + assert!(serialize_shape.definitions().is_empty()); + assert_eq!(deserialize_shape.root(), &expected); + assert!(deserialize_shape.definitions().is_empty()); } #[test] fn distinguishes_byte_sequences_from_borrowed_byte_input() { assert_eq!( - SerializeShapeGraph::for_type::<[u8]>().root, - ShapeRef::Seq(Box::new(ShapeRef::U8)) + SerializeShapeGraph::for_type::<[u8]>().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U8)) ); assert_eq!( - SerializeShapeGraph::for_type::>().root, - ShapeRef::Seq(Box::new(ShapeRef::U8)) + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U8)) ); assert_eq!( - DeserializeShapeGraph::for_type::<[u8]>().root, - ShapeRef::Bytes + DeserializeShapeGraph::for_type::<[u8]>().root(), + &ShapeRef::Bytes ); } #[test] fn maps_result_as_an_externally_tagged_enum() { let serialize = SerializeShapeGraph::for_type::>(); - let ShapeRef::Definition(id) = serialize.root else { + let ShapeRef::Definition(id) = serialize.root() else { panic!("result should produce a named definition"); }; - let SerializeDefinitionKind::Enum(shape) = &serialize.definition(id).unwrap().kind else { + let SerializeDefinitionKind::Enum(shape) = &serialize.definition(*id).unwrap().kind else { panic!("result definition should be an enum"); }; @@ -212,10 +246,10 @@ fn maps_result_as_an_externally_tagged_enum() { assert_eq!(fields[0].wire_shape, FieldWireShape::Value(ShapeRef::U8)); let deserialize = DeserializeShapeGraph::for_type::>(); - let ShapeRef::Definition(id) = deserialize.root else { + let ShapeRef::Definition(id) = deserialize.root() else { panic!("result should produce a named definition"); }; - let DeserializeDefinitionKind::Enum(shape) = &deserialize.definition(id).unwrap().kind else { + let DeserializeDefinitionKind::Enum(shape) = &deserialize.definition(*id).unwrap().kind else { panic!("result definition should be an enum"); }; assert_eq!(shape.variants[1].name, "Err"); @@ -231,10 +265,11 @@ fn maps_result_as_an_externally_tagged_enum() { #[test] fn maps_duration_as_serde_struct_fields() { let deserialize = DeserializeShapeGraph::for_type::(); - let ShapeRef::Definition(id) = deserialize.root else { + let ShapeRef::Definition(id) = deserialize.root() else { panic!("duration should produce a named definition"); }; - let DeserializeDefinitionKind::Struct(shape) = &deserialize.definition(id).unwrap().kind else { + let DeserializeDefinitionKind::Struct(shape) = &deserialize.definition(*id).unwrap().kind + else { panic!("duration definition should be a struct"); }; @@ -272,41 +307,54 @@ fn supports_serde_tuple_arity() { u8, ); - let ShapeRef::Tuple(items) = SerializeShapeGraph::for_type::().root else { + let graph = SerializeShapeGraph::for_type::(); + let ShapeRef::Tuple(items) = graph.root() else { panic!("16-element tuple should produce a tuple shape"); }; - assert_eq!(items, vec![ShapeRef::U8; 16]); + assert_eq!(items, &vec![ShapeRef::U8; 16]); } #[test] fn maps_common_core_and_alloc_shapes() { assert_eq!( - DeserializeShapeGraph::for_type::>().root, - ShapeRef::String + DeserializeShapeGraph::for_type::>().root(), + &ShapeRef::String ); assert_eq!( - SerializeShapeGraph::for_type::>().root, - ShapeRef::U8 + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::U8 ); assert_eq!( - DeserializeShapeGraph::for_type::>().root, - ShapeRef::I16 + DeserializeShapeGraph::for_type::>().root(), + &ShapeRef::I16 ); assert_eq!( - SerializeShapeGraph::for_type::>().root, - ShapeRef::U32 + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::U32 ); assert_eq!( - DeserializeShapeGraph::for_type::>().root, - ShapeRef::Seq(Box::new(ShapeRef::U8)) + DeserializeShapeGraph::for_type::>().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U8)) ); assert_eq!( - SerializeShapeGraph::for_type::>().root, - ShapeRef::Seq(Box::new(ShapeRef::I32)) + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::Seq(Box::new(ShapeRef::I32)) ); assert_eq!( - DeserializeShapeGraph::for_type::>().root, - ShapeRef::Seq(Box::new(ShapeRef::U16)) + DeserializeShapeGraph::for_type::>().root(), + &ShapeRef::Seq(Box::new(ShapeRef::U16)) + ); +} + +#[test] +fn follows_cow_directional_serde_bounds() { + assert_eq!( + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::U8 + ); + assert_eq!( + DeserializeShapeGraph::for_type::>().root(), + &ShapeRef::String ); } @@ -314,28 +362,28 @@ fn maps_common_core_and_alloc_shapes() { #[test] fn maps_common_std_shapes() { assert_eq!( - SerializeShapeGraph::for_type::().root, - ShapeRef::String + SerializeShapeGraph::for_type::().root(), + &ShapeRef::String ); assert_eq!( - DeserializeShapeGraph::for_type::().root, - ShapeRef::String + DeserializeShapeGraph::for_type::().root(), + &ShapeRef::String ); assert_eq!( - SerializeShapeGraph::for_type::().root, - ShapeRef::String + SerializeShapeGraph::for_type::().root(), + &ShapeRef::String ); let ipv4_binary = ShapeRef::Array { item: Box::new(ShapeRef::U8), len: 4, }; assert_eq!( - SerializeShapeGraph::for_type::().root, - ShapeRef::union([ShapeRef::String, ipv4_binary.clone()]) + SerializeShapeGraph::for_type::().root(), + &ShapeRef::union([ShapeRef::String, ipv4_binary.clone()]) ); let socket = DeserializeShapeGraph::for_type::(); - let ShapeRef::Union(root) = &socket.root else { + let ShapeRef::Union(root) = socket.root() else { panic!("socket address should reflect human-readable and binary shapes"); }; assert!(root.alternatives().contains(&ShapeRef::String)); diff --git a/tests/derive/Cargo.toml b/tests/derive/Cargo.toml index ab66558..141cad2 100644 --- a/tests/derive/Cargo.toml +++ b/tests/derive/Cargo.toml @@ -28,7 +28,6 @@ renamed-shape = { package = "serde-shape", path = "../../serde-shape", features ] } [dev-dependencies] -insta = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/tests/derive/tests/common/mod.rs b/tests/derive/tests/common/mod.rs new file mode 100644 index 0000000..3c83f50 --- /dev/null +++ b/tests/derive/tests/common/mod.rs @@ -0,0 +1,47 @@ +// 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 renamed_shape::DeserializeDefinitionShape; +use renamed_shape::DeserializeShape; +use renamed_shape::SerializeDefinitionShape; +use renamed_shape::SerializeShape; +use renamed_shape::ShapeRef; + +pub(super) fn deserialize_root_definition() -> DeserializeDefinitionShape +where + T: DeserializeShape, +{ + let graph = T::deserialize_shape(); + let ShapeRef::Definition(id) = graph.root() else { + panic!("deserialization root shape should be a definition"); + }; + graph + .definition(*id) + .expect("deserialization root definition should exist") + .clone() +} + +pub(super) fn serialize_root_definition() -> SerializeDefinitionShape +where + T: SerializeShape, +{ + let graph = T::serialize_shape(); + let ShapeRef::Definition(id) = graph.root() else { + panic!("serialization root shape should be a definition"); + }; + graph + .definition(*id) + .expect("serialization root definition should exist") + .clone() +} diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index 8075bc1..1ee7aa0 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -14,16 +14,24 @@ #![allow(dead_code)] +mod common; + +use common::deserialize_root_definition; +use common::serialize_root_definition; +use renamed_shape::DefaultShape; use renamed_shape::DeserializeDefinitionKind; use renamed_shape::DeserializeShape; +use renamed_shape::DeserializeShapeContext; use renamed_shape::DeserializeVariantContent; use renamed_shape::FieldMember; use renamed_shape::FieldWireShape; use renamed_shape::OpaqueReason; use renamed_shape::SerializeDefinitionKind; use renamed_shape::SerializeShape; +use renamed_shape::SerializeShapeContext; use renamed_shape::SerializeVariantContent; use renamed_shape::ShapeRef; +use renamed_shape::Tagging; #[derive(DeserializeShape)] #[serde( @@ -73,6 +81,54 @@ struct UserId(u64); #[serde(from = "String")] struct FromString(String); +#[derive(DeserializeShape)] +#[serde(try_from = "u16")] +struct TryFromU16(u16); + +#[derive(SerializeShape)] +#[serde(into = "String")] +struct IntoString(String); + +#[derive(DeserializeShape)] +#[serde(from = "T")] +struct FromGeneric(T); + +#[derive(SerializeShape, DeserializeShape)] +#[serde_shape( + serialize_with = "serialize_number_or_string_shape", + deserialize_with = "deserialize_string_shape" +)] +struct ContainerShapeOverride(NotShape); + +#[derive(SerializeShape, DeserializeShape)] +struct FieldShapeOverrides { + #[serde(with = "custom_representation")] + #[serde_shape( + serialize_with = "serialize_string_shape", + deserialize_with = "deserialize_string_shape" + )] + custom: NotShape, + #[serde_shape( + serialize_with = "serialize_u8_shape", + deserialize_with = "deserialize_bool_shape" + )] + directional: NotShape, +} + +/// Selects the retry policy. +/// +/// This text is available to configuration tooling. +#[derive(SerializeShape, DeserializeShape)] +enum DocumentedSetting { + /// Uses the built-in retry policy. + Default, + /// Uses a fixed retry limit. + Fixed { + /// Maximum number of retry attempts. + retries: u8, + }, +} + #[derive(DeserializeShape)] struct SkipsGeneric { #[serde(skip)] @@ -166,43 +222,199 @@ struct DeserializeOnly { struct NotShape; +fn serialize_number_or_string_shape(_context: &mut SerializeShapeContext) -> ShapeRef { + ShapeRef::union([ShapeRef::U16, ShapeRef::String]) +} + +fn serialize_string_shape(context: &mut SerializeShapeContext) -> ShapeRef { + String::serialize_shape_in(context) +} + +fn deserialize_string_shape(context: &mut DeserializeShapeContext) -> ShapeRef { + String::deserialize_shape_in(context) +} + +fn serialize_u8_shape(_context: &mut SerializeShapeContext) -> ShapeRef { + ShapeRef::U8 +} + +fn deserialize_bool_shape(_context: &mut DeserializeShapeContext) -> ShapeRef { + ShapeRef::Bool +} + fn default_retries() -> u8 { 3 } #[test] -fn snapshots_struct_shape_from_container_and_field_attrs() { - insta::assert_debug_snapshot!(Config::deserialize_shape()); +fn exposes_deserialize_container_attributes() { + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("definition should be a struct"); + }; + + assert_eq!(shape.attributes.default, DefaultShape::Default); + assert!(shape.attributes.deny_unknown_fields); + assert_eq!(shape.attributes.expecting, Some("config object")); + + let [http_port, api_url, retries, storage, skipped, secret] = shape.fields.as_slice() else { + panic!("config should expose all fields"); + }; + assert_eq!(http_port.name, "http-port"); + assert_eq!(api_url.aliases, ["api-url", "endpoint"]); + assert_eq!(retries.default, DefaultShape::Path("default_retries")); + assert!(matches!(storage.wire_shape, FieldWireShape::Flatten(_))); + assert_eq!(skipped.wire_shape, FieldWireShape::Omitted); + let FieldWireShape::Value(ShapeRef::Opaque(opaque)) = &secret.wire_shape else { + panic!("custom deserializer should be opaque"); + }; + assert_eq!(opaque.reason, OpaqueReason::CustomDeserializer); } #[test] -fn snapshots_internally_tagged_enum_shape_from_variant_attrs() { - insta::assert_debug_snapshot!(Storage::deserialize_shape()); +fn exposes_deserialize_enum_attributes() { + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Enum(shape) = &definition.kind else { + panic!("definition should be an enum"); + }; + + assert_eq!(shape.repr, Tagging::Internal { tag: "type" }); + assert!(shape.attributes.non_exhaustive); + assert_eq!(shape.variants[0].aliases, ["s3", "s3-compatible"]); + assert!(shape.variants[2].other); } #[test] -fn snapshots_transparent_struct_shape() { - insta::assert_debug_snapshot!(UserId::deserialize_shape()); +fn exposes_transparent_shape() { + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("transparent definition should be a struct"); + }; + assert!(shape.attributes.transparent); + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Inline(ShapeRef::U64) + ); } #[test] -fn snapshots_conversion_based_opaque_shape() { - insta::assert_debug_snapshot!(FromString::deserialize_shape()); +fn follows_serde_conversion_shapes() { + assert_eq!(FromString::deserialize_shape().root(), &ShapeRef::String); + assert_eq!(TryFromU16::deserialize_shape().root(), &ShapeRef::U16); + assert_eq!(IntoString::serialize_shape().root(), &ShapeRef::String); + assert_eq!(FromGeneric::::deserialize_shape().root(), &ShapeRef::U8); } #[test] -fn snapshots_skipped_generic_field_without_shape_bound() { - insta::assert_debug_snapshot!(SkipsGeneric::::deserialize_shape()); +fn applies_container_and_field_custom_shape_functions() { + assert_eq!( + ContainerShapeOverride::serialize_shape().root(), + &ShapeRef::union([ShapeRef::U16, ShapeRef::String]) + ); + assert_eq!( + ContainerShapeOverride::deserialize_shape().root(), + &ShapeRef::String + ); + + let definition = serialize_root_definition::(); + let SerializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("serialize definition should be a struct"); + }; + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Value(ShapeRef::String) + ); + assert_eq!( + shape.fields[1].wire_shape, + FieldWireShape::Value(ShapeRef::U8) + ); + + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("deserialize definition should be a struct"); + }; + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Value(ShapeRef::String) + ); + assert_eq!( + shape.fields[1].wire_shape, + FieldWireShape::Value(ShapeRef::Bool) + ); } #[test] -fn snapshots_phantom_data_generic_field_without_shape_bound() { - insta::assert_debug_snapshot!(Marker::::deserialize_shape()); +fn preserves_rust_documentation() { + let definition = serialize_root_definition::(); + assert_eq!( + definition.description, + Some("Selects the retry policy.\n\nThis text is available to configuration tooling.") + ); + let SerializeDefinitionKind::Enum(shape) = &definition.kind else { + panic!("serialize definition should be an enum"); + }; + assert_eq!( + shape.variants[1].description, + Some("Uses a fixed retry limit.") + ); + let SerializeVariantContent::Fields(fields) = &shape.variants[1].content else { + panic!("fixed variant should expose fields"); + }; + assert_eq!( + fields[0].description, + Some("Maximum number of retry attempts.") + ); + + let definition = deserialize_root_definition::(); + assert_eq!( + definition.description, + Some("Selects the retry policy.\n\nThis text is available to configuration tooling.") + ); + let DeserializeDefinitionKind::Enum(shape) = &definition.kind else { + panic!("deserialize definition should be an enum"); + }; + assert_eq!( + shape.variants[1].description, + Some("Uses a fixed retry limit.") + ); + let DeserializeVariantContent::Fields(fields) = &shape.variants[1].content else { + panic!("fixed variant should expose fields"); + }; + assert_eq!( + fields[0].description, + Some("Maximum number of retry attempts.") + ); } #[test] -fn snapshots_recursive_type_reusing_the_same_definition() { - insta::assert_debug_snapshot!(Recursive::deserialize_shape()); +fn omits_shape_bounds_for_skipped_and_marker_fields() { + assert_eq!( + SkipsGeneric::::deserialize_shape() + .definitions() + .len(), + 1 + ); + assert_eq!( + Marker::::deserialize_shape().definitions().len(), + 1 + ); +} + +#[test] +fn reuses_recursive_definition() { + let graph = Recursive::deserialize_shape(); + let ShapeRef::Definition(id) = graph.root() else { + panic!("recursive root should be a definition"); + }; + let id = *id; + let DeserializeDefinitionKind::Struct(shape) = &graph.definition(id).unwrap().kind else { + panic!("recursive definition should be a struct"); + }; + assert_eq!(graph.definitions().len(), 1); + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Value(ShapeRef::Option(Box::new(ShapeRef::Definition(id)))) + ); } #[test] @@ -210,8 +422,8 @@ fn derives_recursive_generic_shapes_without_cyclic_bounds() { let serialize = RecursiveGeneric::::serialize_shape(); let deserialize = RecursiveGeneric::::deserialize_shape(); - assert_eq!(serialize.definitions.len(), 1); - assert_eq!(deserialize.definitions.len(), 1); + assert_eq!(serialize.definitions().len(), 1); + assert_eq!(deserialize.definitions().len(), 1); } #[test] @@ -219,17 +431,13 @@ fn derives_shape_bounds_for_associated_values() { let serialize = AssociatedValue::::serialize_shape(); let deserialize = AssociatedValue::::deserialize_shape(); - assert_eq!(serialize.definitions.len(), 1); - assert_eq!(deserialize.definitions.len(), 1); + assert_eq!(serialize.definitions().len(), 1); + assert_eq!(deserialize.definitions().len(), 1); } #[test] fn exposes_deserialize_field_metadata() { - let shape = SplitIo::deserialize_shape(); - let renamed_shape::ShapeRef::Definition(id) = shape.root else { - panic!("root shape should be a definition"); - }; - let definition = shape.definition(id).expect("definition exists"); + let definition = deserialize_root_definition::(); let DeserializeDefinitionKind::Struct(struct_shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -257,11 +465,7 @@ fn exposes_deserialize_field_metadata() { #[test] fn exposes_serialize_field_metadata() { - let shape = SplitIo::serialize_shape(); - let renamed_shape::ShapeRef::Definition(id) = shape.root else { - panic!("root shape should be a definition"); - }; - let definition = shape.definition(id).expect("definition exists"); + let definition = serialize_root_definition::(); let SerializeDefinitionKind::Struct(struct_shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -293,11 +497,7 @@ fn exposes_serialize_field_metadata() { #[test] fn exposes_deserialize_variant_metadata() { - let shape = SplitEnum::deserialize_shape(); - let renamed_shape::ShapeRef::Definition(id) = shape.root else { - panic!("root shape should be a definition"); - }; - let definition = shape.definition(id).expect("definition exists"); + let definition = deserialize_root_definition::(); let DeserializeDefinitionKind::Enum(enum_shape) = &definition.kind else { panic!("definition should be an enum"); }; @@ -332,11 +532,7 @@ fn exposes_deserialize_variant_metadata() { #[test] fn exposes_serialize_variant_metadata() { - let shape = SplitEnum::serialize_shape(); - let renamed_shape::ShapeRef::Definition(id) = shape.root else { - panic!("root shape should be a definition"); - }; - let definition = shape.definition(id).expect("definition exists"); + let definition = serialize_root_definition::(); let SerializeDefinitionKind::Enum(enum_shape) = &definition.kind else { panic!("definition should be an enum"); }; @@ -371,21 +567,12 @@ fn exposes_serialize_variant_metadata() { #[test] fn derives_one_direction_without_requiring_the_other_direction() { - let serialize_shape = SerializeOnly::::serialize_shape(); - let deserialize_shape = DeserializeOnly::::deserialize_shape(); - assert!(matches!( - serialize_shape.definition(match serialize_shape.root { - renamed_shape::ShapeRef::Definition(id) => id, - _ => panic!("serialize root shape should be a definition"), - }), - Some(renamed_shape::SerializeDefinitionShape { .. }) + serialize_root_definition::>().kind, + SerializeDefinitionKind::Struct(_) )); assert!(matches!( - deserialize_shape.definition(match deserialize_shape.root { - renamed_shape::ShapeRef::Definition(id) => id, - _ => panic!("deserialize root shape should be a definition"), - }), - Some(renamed_shape::DeserializeDefinitionShape { .. }) + deserialize_root_definition::>().kind, + DeserializeDefinitionKind::Struct(_) )); } diff --git a/tests/derive/tests/serde_compat.rs b/tests/derive/tests/serde_compat.rs index f2dba8f..ec7d79a 100644 --- a/tests/derive/tests/serde_compat.rs +++ b/tests/derive/tests/serde_compat.rs @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +mod common; + +use common::deserialize_root_definition; +use common::serialize_root_definition; use renamed_shape::DeserializeDefinitionKind; use renamed_shape::DeserializeFieldShape; use renamed_shape::DeserializeShape; @@ -157,68 +161,52 @@ 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 { + let definition = serialize_root_definition::(); + let SerializeDefinitionKind::Struct(shape) = definition.kind else { panic!("definition should be a struct"); }; - shape.fields.first().expect("field should exist").clone() + shape.fields.into_iter().next().expect("field should exist") } 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 { + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Struct(shape) = definition.kind else { panic!("definition should be a struct"); }; - shape.fields.first().expect("field should exist").clone() + shape.fields.into_iter().next().expect("field should exist") } 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 { + let definition = serialize_root_definition::(); + let SerializeDefinitionKind::Enum(shape) = definition.kind else { panic!("definition should be an enum"); }; shape .variants - .first() + .into_iter() + .next() .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 { + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Enum(shape) = definition.kind else { panic!("definition should be an enum"); }; shape .variants - .first() + .into_iter() + .next() .expect("variant should exist") - .clone() } mod flat_value { diff --git a/tests/derive/tests/snapshots/derive__snapshots_conversion_based_opaque_shape.snap b/tests/derive/tests/snapshots/derive__snapshots_conversion_based_opaque_shape.snap deleted file mode 100644 index 25ebdfc..0000000 --- a/tests/derive/tests/snapshots/derive__snapshots_conversion_based_opaque_shape.snap +++ /dev/null @@ -1,31 +0,0 @@ ---- -source: tests/derive/tests/derive.rs -expression: "FromString::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "derive::FromString", - name: "FromString", - }, - kind: Opaque( - OpaqueShape { - type_name: "derive::FromString", - reason: FromType, - detail: Some( - "String", - ), - }, - ), - }, - ], -} 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 deleted file mode 100644 index 46c821a..0000000 --- a/tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap +++ /dev/null @@ -1,110 +0,0 @@ ---- -source: tests/derive/tests/derive.rs -expression: "Storage::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "derive::Storage", - name: "Storage", - }, - kind: Enum( - DeserializeEnumShape { - repr: Internal { - tag: "type", - }, - variants: [ - DeserializeVariantShape { - rust_name: "S3", - name: "s3", - aliases: [ - "s3", - "s3-compatible", - ], - style: Struct, - content: Fields( - [ - DeserializeFieldShape { - member: Named( - "bucket_name", - ), - name: "bucket-name", - aliases: [ - "bucket-name", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "AzBlob", - name: "az-blob", - aliases: [ - "az-blob", - ], - style: Struct, - content: Fields( - [ - DeserializeFieldShape { - member: Named( - "container_name", - ), - name: "container-name", - aliases: [ - "container-name", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "Other", - name: "other", - aliases: [ - "other", - ], - style: Unit, - content: Fields( - [], - ), - other: true, - untagged: false, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: Internal { - tag: "type", - }, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: true, - }, - }, - ), - }, - ], -} 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 deleted file mode 100644 index 174416f..0000000 --- a/tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: tests/derive/tests/derive.rs -expression: "Marker::::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "derive::Marker", - name: "Marker", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "marker", - ), - name: "marker", - aliases: [ - "marker", - ], - wire_shape: Value( - Unit, - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - ], -} 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 deleted file mode 100644 index 02a8c0e..0000000 --- a/tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap +++ /dev/null @@ -1,57 +0,0 @@ ---- -source: tests/derive/tests/derive.rs -expression: "Recursive::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "derive::Recursive", - name: "Recursive", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "child", - ), - name: "child", - aliases: [ - "child", - ], - wire_shape: Value( - Option( - Definition( - ShapeId( - 0, - ), - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - ], -} 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 deleted file mode 100644 index e0f81c4..0000000 --- a/tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap +++ /dev/null @@ -1,49 +0,0 @@ ---- -source: tests/derive/tests/derive.rs -expression: "SkipsGeneric::::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "derive::SkipsGeneric", - name: "SkipsGeneric", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "value", - ), - name: "value", - aliases: [ - "value", - ], - wire_shape: Omitted, - default: Default, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - ], -} 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 deleted file mode 100644 index e2e80a5..0000000 --- a/tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap +++ /dev/null @@ -1,230 +0,0 @@ ---- -source: tests/derive/tests/derive.rs -expression: "Config::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "derive::Config", - name: "Config", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "http_port", - ), - name: "http-port", - aliases: [ - "http-port", - ], - wire_shape: Value( - U16, - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "api_url", - ), - name: "api-url", - aliases: [ - "api-url", - "endpoint", - ], - wire_shape: Value( - Option( - String, - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "retries", - ), - name: "retries", - aliases: [ - "retries", - ], - wire_shape: Value( - U8, - ), - default: Path( - "default_retries", - ), - }, - DeserializeFieldShape { - member: Named( - "storage", - ), - name: "storage", - aliases: [ - "storage", - ], - wire_shape: Flatten( - Definition( - ShapeId( - 1, - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "skipped", - ), - name: "skipped", - aliases: [ - "skipped", - ], - wire_shape: Omitted, - default: None, - }, - DeserializeFieldShape { - member: Named( - "secret", - ), - name: "secret", - aliases: [ - "secret", - ], - wire_shape: Value( - Opaque( - OpaqueShape { - type_name: "derive::NotShape", - reason: CustomDeserializer, - detail: Some( - "custom_secret", - ), - }, - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: Default, - has_flatten: true, - transparent: false, - expecting: Some( - "config object", - ), - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 1, - ), - type_name: DeserializeTypeName { - rust_name: "derive::Storage", - name: "Storage", - }, - kind: Enum( - DeserializeEnumShape { - repr: Internal { - tag: "type", - }, - variants: [ - DeserializeVariantShape { - rust_name: "S3", - name: "s3", - aliases: [ - "s3", - "s3-compatible", - ], - style: Struct, - content: Fields( - [ - DeserializeFieldShape { - member: Named( - "bucket_name", - ), - name: "bucket-name", - aliases: [ - "bucket-name", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "AzBlob", - name: "az-blob", - aliases: [ - "az-blob", - ], - style: Struct, - content: Fields( - [ - DeserializeFieldShape { - member: Named( - "container_name", - ), - name: "container-name", - aliases: [ - "container-name", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "Other", - name: "other", - aliases: [ - "other", - ], - style: Unit, - content: Fields( - [], - ), - other: true, - untagged: false, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: Internal { - tag: "type", - }, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: true, - }, - }, - ), - }, - ], -} diff --git a/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap b/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap deleted file mode 100644 index 8dcc77b..0000000 --- a/tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap +++ /dev/null @@ -1,51 +0,0 @@ ---- -source: tests/derive/tests/derive.rs -expression: "UserId::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "derive::UserId", - name: "UserId", - }, - kind: Struct( - DeserializeStructShape { - style: Newtype, - fields: [ - DeserializeFieldShape { - member: Unnamed( - 0, - ), - name: "0", - aliases: [ - "0", - ], - wire_shape: Inline( - U64, - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: true, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - ], -} diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 6182945..940f331 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -26,7 +26,6 @@ release = false serde-shape = { workspace = true, features = ["derive", "std"] } [dev-dependencies] -insta = { workspace = true } serde = { workspace = true } serde_test = { workspace = true } toml_edit = { workspace = true } diff --git a/tests/integration/tests/configenv.rs b/tests/integration/tests/configenv.rs index 5aa79cc..cd3d338 100644 --- a/tests/integration/tests/configenv.rs +++ b/tests/integration/tests/configenv.rs @@ -15,16 +15,12 @@ #![allow(dead_code)] use std::collections::BTreeMap; -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; -use serde_shape::DeserializeShapeContext; use serde_shape::DeserializeShapeGraph; use serde_shape::DeserializeStructShape; use serde_shape::DeserializeVariantContent; @@ -45,140 +41,6 @@ struct EnvOption { condition: Option, } -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct Config { - server: ServerConfig, - storage: StorageConfig, - telemetry: TelemetryConfig, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct ServerConfig { - #[serde(default = "default_dir")] - dir: PathBuf, - #[serde(default = "default_listen_data_addr")] - listen_data_addr: SocketAddr, - #[serde(skip_serializing_if = "Option::is_none")] - advertise_data_addr: Option, - #[serde(default)] - initial_peers: Vec, - #[serde(default = "default_cluster_id")] - cluster_id: String, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct StorageConfig { - #[serde(default)] - backend: StorageBackend, - #[serde(default = "default_disk_capacity")] - disk_capacity: ByteSize, - #[serde(default = "default_memory_capacity")] - memory_capacity: ByteSize, - #[serde(skip_serializing_if = "Option::is_none")] - disk_throttle: Option, -} - -#[derive(DeserializeShape)] -#[serde( - tag = "kind", - rename_all = "snake_case", - rename_all_fields = "snake_case" -)] -enum StorageBackend { - Local { data_dir: PathBuf }, - S3 { bucket: String, region: String }, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct DiskThrottle { - read_iops: u64, - write_iops: u64, - iops_counter: CounterConfig, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct CounterConfig { - mode: CounterMode, - size: NonZeroUsize, -} - -#[derive(DeserializeShape)] -#[serde(rename_all = "snake_case")] -enum CounterMode { - Window, - LeakyBucket, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct TelemetryConfig { - #[serde(default)] - logs: LogsConfig, - #[serde(skip_serializing_if = "Option::is_none")] - traces: Option, - #[serde(skip_serializing_if = "Option::is_none")] - metrics: Option, -} - -#[derive(DeserializeShape)] -struct LogsConfig { - #[serde(flatten)] - sink: LogSink, - filter: String, -} - -#[derive(DeserializeShape)] -#[serde( - tag = "kind", - rename_all = "snake_case", - rename_all_fields = "snake_case" -)] -enum LogSink { - File { - dir: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - max_files: Option, - }, - Stderr, - Opentelemetry { - otlp_endpoint: String, - }, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct TracesConfig { - capture_log_filter: String, - #[serde(skip_serializing_if = "Option::is_none")] - opentelemetry: Option, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct OpentelemetryTracesConfig { - otlp_endpoint: String, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct MetricsConfig { - #[serde(skip_serializing_if = "Option::is_none")] - opentelemetry: Option, -} - -#[derive(DeserializeShape)] -#[serde(deny_unknown_fields)] -struct OpentelemetryMetricsConfig { - otlp_endpoint: String, - #[serde(default = "default_metrics_push_interval")] - push_interval: HumanDuration, -} - #[derive(Debug, Deserialize, DeserializeShape, PartialEq)] #[serde(deny_unknown_fields)] struct ClientConfig { @@ -213,76 +75,6 @@ enum ExecutionMode { Safe, } -#[derive(Clone, Copy, Debug)] -struct ByteSize(u64); - -impl DeserializeShape for ByteSize { - fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { - string_or_integer_shape() - } -} - -#[derive(Clone, Copy, Debug)] -struct HumanDuration(u64); - -impl DeserializeShape for HumanDuration { - fn deserialize_shape_in(_context: &mut DeserializeShapeContext) -> ShapeRef { - string_or_integer_shape() - } -} - -fn string_or_integer_shape() -> ShapeRef { - ShapeRef::union([ - 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") -} - -fn default_listen_data_addr() -> SocketAddr { - SocketAddr::from(([0, 0, 0, 0], 7654)) -} - -fn default_cluster_id() -> String { - "percas-cluster".to_string() -} - -fn default_disk_capacity() -> ByteSize { - ByteSize(512 * 1024 * 1024) -} - -fn default_memory_capacity() -> ByteSize { - ByteSize(1024 * 1024 * 1024) -} - -fn default_metrics_push_interval() -> HumanDuration { - HumanDuration(30) -} - -#[test] -fn snapshots_config_shape() { - insta::assert_debug_snapshot!(Config::deserialize_shape()); -} - -#[test] -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"); @@ -377,7 +169,7 @@ fn env_options(env_prefix: &str) -> Vec { env_prefix, options: BTreeMap::new(), }; - collector.visit_shape_ref(&shape.root, &mut Vec::new(), false, None); + collector.visit_shape_ref(shape.root(), &mut Vec::new(), false, None); collector.options.into_values().collect() } diff --git a/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap b/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap deleted file mode 100644 index a7be5b3..0000000 --- a/tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap +++ /dev/null @@ -1,1149 +0,0 @@ ---- -source: tests/integration/tests/configenv.rs -expression: "Config::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::Config", - name: "Config", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "server", - ), - name: "server", - aliases: [ - "server", - ], - wire_shape: Value( - Definition( - ShapeId( - 1, - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "storage", - ), - name: "storage", - aliases: [ - "storage", - ], - wire_shape: Value( - Definition( - ShapeId( - 3, - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "telemetry", - ), - name: "telemetry", - aliases: [ - "telemetry", - ], - wire_shape: Value( - Definition( - ShapeId( - 8, - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 1, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::ServerConfig", - name: "ServerConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "dir", - ), - name: "dir", - aliases: [ - "dir", - ], - wire_shape: Value( - String, - ), - default: Path( - "default_dir", - ), - }, - DeserializeFieldShape { - member: Named( - "listen_data_addr", - ), - name: "listen_data_addr", - aliases: [ - "listen_data_addr", - ], - wire_shape: Value( - Union( - [ - String, - Definition( - ShapeId( - 2, - ), - ), - ], - ), - ), - default: Path( - "default_listen_data_addr", - ), - }, - DeserializeFieldShape { - member: Named( - "advertise_data_addr", - ), - name: "advertise_data_addr", - aliases: [ - "advertise_data_addr", - ], - wire_shape: Value( - Option( - Union( - [ - String, - Definition( - ShapeId( - 2, - ), - ), - ], - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "initial_peers", - ), - name: "initial_peers", - aliases: [ - "initial_peers", - ], - wire_shape: Value( - Seq( - String, - ), - ), - default: Default, - }, - DeserializeFieldShape { - member: Named( - "cluster_id", - ), - name: "cluster_id", - aliases: [ - "cluster_id", - ], - wire_shape: Value( - String, - ), - default: Path( - "default_cluster_id", - ), - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 2, - ), - type_name: DeserializeTypeName { - rust_name: "core::net::socket_addr::SocketAddr", - name: "SocketAddr", - }, - kind: Enum( - DeserializeEnumShape { - repr: External, - variants: [ - DeserializeVariantShape { - rust_name: "V4", - name: "V4", - aliases: [ - "V4", - ], - style: Newtype, - content: Fields( - [ - DeserializeFieldShape { - member: Unnamed( - 0, - ), - name: "0", - aliases: [ - "0", - ], - wire_shape: Value( - Tuple( - [ - Array { - item: U8, - len: 4, - }, - U16, - ], - ), - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "V6", - name: "V6", - aliases: [ - "V6", - ], - style: Newtype, - content: Fields( - [ - DeserializeFieldShape { - member: Unnamed( - 0, - ), - name: "0", - aliases: [ - "0", - ], - wire_shape: Value( - Tuple( - [ - Array { - item: U8, - len: 16, - }, - U16, - ], - ), - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 3, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::StorageConfig", - name: "StorageConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "backend", - ), - name: "backend", - aliases: [ - "backend", - ], - wire_shape: Value( - Definition( - ShapeId( - 4, - ), - ), - ), - default: Default, - }, - DeserializeFieldShape { - member: Named( - "disk_capacity", - ), - name: "disk_capacity", - aliases: [ - "disk_capacity", - ], - wire_shape: Value( - Union( - [ - I8, - I16, - I32, - I64, - I128, - Isize, - U8, - U16, - U32, - U64, - U128, - Usize, - String, - ], - ), - ), - default: Path( - "default_disk_capacity", - ), - }, - DeserializeFieldShape { - member: Named( - "memory_capacity", - ), - name: "memory_capacity", - aliases: [ - "memory_capacity", - ], - wire_shape: Value( - Union( - [ - I8, - I16, - I32, - I64, - I128, - Isize, - U8, - U16, - U32, - U64, - U128, - Usize, - String, - ], - ), - ), - default: Path( - "default_memory_capacity", - ), - }, - DeserializeFieldShape { - member: Named( - "disk_throttle", - ), - name: "disk_throttle", - aliases: [ - "disk_throttle", - ], - wire_shape: Value( - Option( - Definition( - ShapeId( - 5, - ), - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 4, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::StorageBackend", - name: "StorageBackend", - }, - kind: Enum( - DeserializeEnumShape { - repr: Internal { - tag: "kind", - }, - variants: [ - DeserializeVariantShape { - rust_name: "Local", - name: "local", - aliases: [ - "local", - ], - style: Struct, - content: Fields( - [ - DeserializeFieldShape { - member: Named( - "data_dir", - ), - name: "data_dir", - aliases: [ - "data_dir", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "S3", - name: "s3", - aliases: [ - "s3", - ], - style: Struct, - 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, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: Internal { - tag: "kind", - }, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 5, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::DiskThrottle", - name: "DiskThrottle", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "read_iops", - ), - name: "read_iops", - aliases: [ - "read_iops", - ], - wire_shape: Value( - U64, - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "write_iops", - ), - name: "write_iops", - aliases: [ - "write_iops", - ], - wire_shape: Value( - U64, - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "iops_counter", - ), - name: "iops_counter", - aliases: [ - "iops_counter", - ], - wire_shape: Value( - Definition( - ShapeId( - 6, - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 6, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::CounterConfig", - name: "CounterConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "mode", - ), - name: "mode", - aliases: [ - "mode", - ], - wire_shape: Value( - Definition( - ShapeId( - 7, - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "size", - ), - name: "size", - aliases: [ - "size", - ], - wire_shape: Value( - Usize, - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 7, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::CounterMode", - name: "CounterMode", - }, - kind: Enum( - DeserializeEnumShape { - repr: External, - variants: [ - DeserializeVariantShape { - rust_name: "Window", - name: "window", - aliases: [ - "window", - ], - style: Unit, - content: Fields( - [], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "LeakyBucket", - name: "leaky_bucket", - aliases: [ - "leaky_bucket", - ], - style: Unit, - content: Fields( - [], - ), - other: false, - untagged: false, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 8, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::TelemetryConfig", - name: "TelemetryConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "logs", - ), - name: "logs", - aliases: [ - "logs", - ], - wire_shape: Value( - Definition( - ShapeId( - 9, - ), - ), - ), - default: Default, - }, - DeserializeFieldShape { - member: Named( - "traces", - ), - name: "traces", - aliases: [ - "traces", - ], - wire_shape: Value( - Option( - Definition( - ShapeId( - 11, - ), - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "metrics", - ), - name: "metrics", - aliases: [ - "metrics", - ], - wire_shape: Value( - Option( - Definition( - ShapeId( - 13, - ), - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 9, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::LogsConfig", - name: "LogsConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "sink", - ), - name: "sink", - aliases: [ - "sink", - ], - wire_shape: Flatten( - Definition( - ShapeId( - 10, - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "filter", - ), - name: "filter", - aliases: [ - "filter", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: true, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 10, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::LogSink", - name: "LogSink", - }, - kind: Enum( - DeserializeEnumShape { - repr: Internal { - tag: "kind", - }, - variants: [ - DeserializeVariantShape { - rust_name: "File", - name: "file", - aliases: [ - "file", - ], - style: Struct, - content: Fields( - [ - DeserializeFieldShape { - member: Named( - "dir", - ), - 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, - }, - DeserializeVariantShape { - rust_name: "Stderr", - name: "stderr", - aliases: [ - "stderr", - ], - style: Unit, - content: Fields( - [], - ), - other: false, - untagged: false, - }, - DeserializeVariantShape { - rust_name: "Opentelemetry", - name: "opentelemetry", - aliases: [ - "opentelemetry", - ], - style: Struct, - content: Fields( - [ - DeserializeFieldShape { - member: Named( - "otlp_endpoint", - ), - name: "otlp_endpoint", - aliases: [ - "otlp_endpoint", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - ), - other: false, - untagged: false, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: Internal { - tag: "kind", - }, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 11, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::TracesConfig", - name: "TracesConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "capture_log_filter", - ), - name: "capture_log_filter", - aliases: [ - "capture_log_filter", - ], - wire_shape: Value( - String, - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "opentelemetry", - ), - name: "opentelemetry", - aliases: [ - "opentelemetry", - ], - wire_shape: Value( - Option( - Definition( - ShapeId( - 12, - ), - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 12, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::OpentelemetryTracesConfig", - name: "OpentelemetryTracesConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "otlp_endpoint", - ), - name: "otlp_endpoint", - aliases: [ - "otlp_endpoint", - ], - wire_shape: Value( - String, - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 13, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::MetricsConfig", - name: "MetricsConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "opentelemetry", - ), - name: "opentelemetry", - aliases: [ - "opentelemetry", - ], - wire_shape: Value( - Option( - Definition( - ShapeId( - 14, - ), - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - DeserializeDefinitionShape { - id: ShapeId( - 14, - ), - type_name: DeserializeTypeName { - rust_name: "configenv::OpentelemetryMetricsConfig", - name: "OpentelemetryMetricsConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "otlp_endpoint", - ), - name: "otlp_endpoint", - aliases: [ - "otlp_endpoint", - ], - wire_shape: Value( - String, - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "push_interval", - ), - name: "push_interval", - aliases: [ - "push_interval", - ], - wire_shape: Value( - Union( - [ - I8, - I16, - I32, - I64, - I128, - Isize, - U8, - U16, - U32, - U64, - U128, - Usize, - String, - ], - ), - ), - default: Path( - "default_metrics_push_interval", - ), - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: true, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - ], -} diff --git a/tests/integration/tests/snapshots/configenv__snapshots_env_options.snap b/tests/integration/tests/snapshots/configenv__snapshots_env_options.snap deleted file mode 100644 index e9950fd..0000000 --- a/tests/integration/tests/snapshots/configenv__snapshots_env_options.snap +++ /dev/null @@ -1,280 +0,0 @@ ---- -source: tests/integration/tests/configenv.rs -expression: "env_options::(\"PERCAS_CONFIG\")" ---- -[ - EnvOption { - env_name: "PERCAS_CONFIG_SERVER_ADVERTISE_DATA_ADDR", - path: [ - "server", - "advertise_data_addr", - ], - value_kind: "string|enum", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_SERVER_CLUSTER_ID", - path: [ - "server", - "cluster_id", - ], - value_kind: "string", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_SERVER_DIR", - path: [ - "server", - "dir", - ], - value_kind: "string", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_SERVER_INITIAL_PEERS", - path: [ - "server", - "initial_peers", - ], - value_kind: "array", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_SERVER_LISTEN_DATA_ADDR", - path: [ - "server", - "listen_data_addr", - ], - value_kind: "string|enum", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_STORAGE_BACKEND_BUCKET", - path: [ - "storage", - "backend", - "bucket", - ], - value_kind: "string", - optional: true, - condition: Some( - "storage.backend.kind=s3", - ), - }, - EnvOption { - env_name: "PERCAS_CONFIG_STORAGE_BACKEND_DATA_DIR", - path: [ - "storage", - "backend", - "data_dir", - ], - value_kind: "string", - optional: true, - condition: Some( - "storage.backend.kind=local", - ), - }, - EnvOption { - env_name: "PERCAS_CONFIG_STORAGE_BACKEND_KIND", - path: [ - "storage", - "backend", - "kind", - ], - value_kind: "enum[local|s3]", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_STORAGE_BACKEND_REGION", - path: [ - "storage", - "backend", - "region", - ], - value_kind: "string", - optional: true, - condition: Some( - "storage.backend.kind=s3", - ), - }, - EnvOption { - env_name: "PERCAS_CONFIG_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", - 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", - path: [ - "storage", - "disk_throttle", - "iops_counter", - "size", - ], - value_kind: "integer", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_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", - path: [ - "storage", - "disk_throttle", - "write_iops", - ], - value_kind: "integer", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_STORAGE_MEMORY_CAPACITY", - path: [ - "storage", - "memory_capacity", - ], - value_kind: "integer|string", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_DIR", - path: [ - "telemetry", - "logs", - "dir", - ], - value_kind: "string", - optional: true, - condition: Some( - "telemetry.logs.kind=file", - ), - }, - EnvOption { - env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_FILTER", - path: [ - "telemetry", - "logs", - "filter", - ], - value_kind: "string", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_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", - path: [ - "telemetry", - "logs", - "max_files", - ], - value_kind: "integer", - optional: true, - condition: Some( - "telemetry.logs.kind=file", - ), - }, - EnvOption { - env_name: "PERCAS_CONFIG_TELEMETRY_LOGS_OTLP_ENDPOINT", - path: [ - "telemetry", - "logs", - "otlp_endpoint", - ], - value_kind: "string", - optional: true, - condition: Some( - "telemetry.logs.kind=opentelemetry", - ), - }, - EnvOption { - env_name: "PERCAS_CONFIG_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", - path: [ - "telemetry", - "metrics", - "opentelemetry", - "push_interval", - ], - value_kind: "integer|string", - optional: true, - condition: None, - }, - EnvOption { - env_name: "PERCAS_CONFIG_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", - path: [ - "telemetry", - "traces", - "opentelemetry", - "otlp_endpoint", - ], - value_kind: "string", - optional: true, - condition: None, - }, -] diff --git a/tests/no_std/Cargo.toml b/tests/no_std/Cargo.toml index 696a562..d2f045d 100644 --- a/tests/no_std/Cargo.toml +++ b/tests/no_std/Cargo.toml @@ -25,8 +25,5 @@ release = false [dependencies] serde-shape = { workspace = true, features = ["derive"] } -[dev-dependencies] -insta = { workspace = true } - [lints] workspace = true diff --git a/tests/no_std/tests/shapes.rs b/tests/no_std/tests/shapes.rs new file mode 100644 index 0000000..46182e5 --- /dev/null +++ b/tests/no_std/tests/shapes.rs @@ -0,0 +1,80 @@ +// 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_shape::DeserializeDefinitionKind; +use serde_shape::DeserializeShape; +use serde_shape::FieldWireShape; +use serde_shape::SerializeDefinitionKind; +use serde_shape::SerializeShape; +use serde_shape::ShapeRef; +use serde_shape_test_no_std::NoStdConfig; + +#[test] +fn reflects_no_std_deserialization() { + let graph = NoStdConfig::deserialize_shape(); + let ShapeRef::Definition(root_id) = graph.root() else { + panic!("root shape should be a definition"); + }; + let root_id = *root_id; + assert_eq!(root_id.index(), 0); + assert_eq!(graph.definitions().len(), 1); + + let DeserializeDefinitionKind::Struct(shape) = &graph.definition(root_id).unwrap().kind else { + panic!("root definition should be a struct"); + }; + assert_eq!(shape.fields.len(), 3); + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Value(ShapeRef::String) + ); + assert_eq!( + shape.fields[1].wire_shape, + FieldWireShape::Value(ShapeRef::Seq(Box::new(ShapeRef::Option(Box::new( + ShapeRef::U16, + ))))) + ); + assert_eq!( + shape.fields[2].wire_shape, + FieldWireShape::Value(ShapeRef::Option(Box::new(ShapeRef::Definition(root_id)))) + ); +} + +#[test] +fn reflects_no_std_serialization() { + let graph = NoStdConfig::serialize_shape(); + let ShapeRef::Definition(root_id) = graph.root() else { + panic!("root shape should be a definition"); + }; + let root_id = *root_id; + assert_eq!(graph.definitions().len(), 1); + + let SerializeDefinitionKind::Struct(shape) = &graph.definition(root_id).unwrap().kind else { + panic!("root definition should be a struct"); + }; + assert_eq!(shape.fields.len(), 3); + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Value(ShapeRef::String) + ); + assert_eq!( + shape.fields[1].wire_shape, + FieldWireShape::Value(ShapeRef::Seq(Box::new(ShapeRef::Option(Box::new( + ShapeRef::U16, + ))))) + ); + assert_eq!( + shape.fields[2].wire_shape, + FieldWireShape::Value(ShapeRef::Option(Box::new(ShapeRef::Definition(root_id)))) + ); +} diff --git a/tests/no_std/tests/snapshots.rs b/tests/no_std/tests/snapshots.rs deleted file mode 100644 index 3705774..0000000 --- a/tests/no_std/tests/snapshots.rs +++ /dev/null @@ -1,27 +0,0 @@ -// 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_shape::DeserializeShape; -use serde_shape::SerializeShape; -use serde_shape_test_no_std::NoStdConfig; - -#[test] -fn snapshots_no_std_config_deserialize_shape() { - insta::assert_debug_snapshot!(NoStdConfig::deserialize_shape()); -} - -#[test] -fn snapshots_no_std_config_serialize_shape() { - insta::assert_debug_snapshot!(NoStdConfig::serialize_shape()); -} 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 deleted file mode 100644 index e4ea5ba..0000000 --- a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap +++ /dev/null @@ -1,87 +0,0 @@ ---- -source: tests/no_std/tests/snapshots.rs -expression: "NoStdConfig::deserialize_shape()" ---- -DeserializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - DeserializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: DeserializeTypeName { - rust_name: "serde_shape_test_no_std::NoStdConfig", - name: "NoStdConfig", - }, - kind: Struct( - DeserializeStructShape { - style: Struct, - fields: [ - DeserializeFieldShape { - member: Named( - "name", - ), - name: "name", - aliases: [ - "name", - ], - wire_shape: Value( - String, - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "values", - ), - name: "values", - aliases: [ - "values", - ], - wire_shape: Value( - Seq( - Option( - U16, - ), - ), - ), - default: None, - }, - DeserializeFieldShape { - member: Named( - "child", - ), - name: "child", - aliases: [ - "child", - ], - wire_shape: Value( - Option( - Definition( - ShapeId( - 0, - ), - ), - ), - ), - default: None, - }, - ], - attributes: DeserializeContainerAttributes { - tagging: External, - deny_unknown_fields: false, - default: None, - has_flatten: false, - transparent: false, - expecting: None, - non_exhaustive: false, - }, - }, - ), - }, - ], -} 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 deleted file mode 100644 index f58ca3c..0000000 --- a/tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap +++ /dev/null @@ -1,75 +0,0 @@ ---- -source: tests/no_std/tests/snapshots.rs -expression: "NoStdConfig::serialize_shape()" ---- -SerializeShapeGraph { - root: Definition( - ShapeId( - 0, - ), - ), - definitions: [ - SerializeDefinitionShape { - id: ShapeId( - 0, - ), - type_name: SerializeTypeName { - rust_name: "serde_shape_test_no_std::NoStdConfig", - name: "NoStdConfig", - }, - kind: Struct( - SerializeStructShape { - style: Struct, - fields: [ - SerializeFieldShape { - member: Named( - "name", - ), - name: "name", - wire_shape: Value( - String, - ), - skip_if: None, - }, - SerializeFieldShape { - member: Named( - "values", - ), - name: "values", - wire_shape: Value( - Seq( - Option( - U16, - ), - ), - ), - skip_if: None, - }, - SerializeFieldShape { - member: Named( - "child", - ), - name: "child", - wire_shape: Value( - Option( - Definition( - ShapeId( - 0, - ), - ), - ), - ), - skip_if: None, - }, - ], - attributes: SerializeContainerAttributes { - tagging: External, - has_flatten: false, - transparent: false, - non_exhaustive: false, - }, - }, - ), - }, - ], -} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c9e86bf..6c93e7c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -105,6 +105,7 @@ struct CommandLint { impl CommandLint { fn run(self) { run_command(make_clippy_cmd(self.fix)); + run_command(make_doc_cmd()); run_command(make_format_cmd(self.fix)); run_command(make_taplo_cmd(self.fix)); run_command(make_typos_cmd()); @@ -195,6 +196,19 @@ fn make_clippy_cmd(fix: bool) -> StdCommand { cmd } +fn make_doc_cmd() -> StdCommand { + let mut cmd = find_command("cargo"); + cmd.env("RUSTDOCFLAGS", "-D warnings --cfg docsrs"); + cmd.args([ + "+nightly", + "doc", + "--workspace", + "--all-features", + "--no-deps", + ]); + cmd +} + fn make_hawkeye_cmd(fix: bool) -> StdCommand { ensure_installed("hawkeye", "hawkeye"); let mut cmd = find_command("hawkeye");